0%

comp - 2024SekaiCTF

复现一下最近比赛区块链的题目

[1] Play to Earn

这道题实现的 ArcadeMachine 即游戏机可以向 0 地址转账,我一开始觉得这是烧币没啥用...后来发现 permit 里的 ecrecovor 出错时会返回 0 而不是直接回滚,所以可以让 allowance[0][my_address] 为 13.37,然后烧币+转账就可以了

[2] イベント!

这道题怎么还用 rust 搞了个服务器...有点复杂,改天再看

[3] ZOO

这道题怎么还要手撕字节码...有点复杂,今天看了吧...

逻辑很简单,ZOO 里的 fallback 函数接收 calldata 设置 local_animals,然后调用 commit 把 memory 里的 animals 推到 storage 里。问题出在下面这段代码

1
2
3
4
5
6
7
8
9
uint256 public isSolved;
AnimalWrapper[] public animals;

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

由于 isSolved 就在 animals 的上面,所以我们可以让 animal_addr 指向 isSolved - 1,animal_counter 就指向了 isSolved,而且对于内联汇编的溢出,GPT是这样说的:

在Solidity 0.8.0 及更高版本中,整型运算默认启用了溢出和下溢检查。这意味着,在高层次的Solidity代码中,诸如 +、- 等运算符在出现溢出或下溢时会自动抛出异常(revert)。然而,这个默认的安全机制并不自动适用于内联汇编(Yul)代码。

所以我们可以大胆构造了:

构造...构造不出来...因为有 whenNotPaused 这个修饰符不让你进这个 commit 函数,并且 idx 不能大于 7...

但跟 Nightu 师傅交流之后我得知 forge 可以动调合约(★ ω ★),所以准备跑起来看看。另外,fallback 函数中还有这一段可以控制 idx=7 的 local_animals 指针:

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

既然我们可以控制指针,就可以在 0x21 修改时对任意内存写了,我们来布置一下任意memory写的payload:

首先,10 00 0000 xxxx(2byte的animal_index) + 30 07,这样就可以把 idx = 7 的 offset 改成我们任意想要的 2byte

其次,20 07 21 xxxx(2byte的name_length) + payload(长为 name_length),这样就可以把长度为 payload 的 name_length 复制到上面 animal_index 指向的内存位置了,但这种方式覆盖之后,如果op不是10,20,30,立刻就会break,并且就算不break,由于temp已经被写完,我们也无法再修改了。

既然我们解决了如何写,我们要解决一下要写什么。主要问题有两个:①绕过 whenNotPaused,这个可以通过修改 functions 这一函数指针,直接跳到 whennotPaused 之后。②在 commit 最后一个 sstore 时把 isSolved 改成 1.

我们先来动调看看第一个问题:要解决这个问题,首先我们要知道函数指针存在 memory 的哪里,之后我们得确定修改成什么才能绕过。

存在 memory 的什么地方这个很简单,我们只要看跳转都 commit 时 JUMPDEST 之前的 JUMP 语句栈上第一个变量是什么就行了,我们可以看到是 0x31b,而0x31b存在 memory 的 0xa0 那一行,所以我们要改掉 0xa0 这一行,不过我们先不改,继续看需要改成什么才能绕过。

我们继续单步走下去,看到走到 Address 为 322 时就要跳到 431 去 revert 了,所以我们只需要改成 323,就能绕过这条 jump。综上,我们知道要将 0xa0 这一行的 0x31b 改为 0x323.

所以我们构造的 payload 是:10 00 0000 007e 30 07 20 07 21 0002 0323,动调起来发现已经可以绕过 whenNotPaused 了。

之后,我们再来解决第二个问题:如何写进 isSolved.

commit 里的 sstore 就只有最后那一个,所以我们要想办法在这里做手脚。但由于我们之前布置的 payload 已经 break 了,所以我们希望修改一下:

1
2
3
4
5
6
10 00 0000 01db
10 00 0000 0000 <- 由于我们需要控制 length 为1,所以要多布置一个块
30 07
20 07 21 0025
20 07 22 0323
00000000000000000000000000000000 00000000000000000000000000000080

现在我们已经可以顺利进入 commit 的 for(i<length) 循环了,我们接下来的任务是看“什么操控了最后sstore的参数”

我们注意到 40 57 87 fa 12 a8 23 e0 f2 b7 63 1c c4 1b 3b a8 82 8b 33 21 ca 81 11 11 fa 75 cd 3a a3 bb 5a ce 是 slot_hash,而又有下面的代码可知

1
2
3
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))

slot_hash + 2*idx 是 animal_addr,slot_hash + 2*idx + 1 是 animal_counter,那我们合理怀疑 slot[2] 里存的就是这个 slot_hash,动态数组通过访问这个hash值+offset来访问成员。又因为 slot[1] 是 isSolved 变量,所以在 sstore 的参数add(add(slot_hash, mul(2, idx)), 1),中,我们需要计算出 idx,使这个参数等于1,最后可以算出来是0x5fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299,因此最后的payload为1000000001fb10010000133730072007210045200722032300000000000000000000000000000000000000000000000000000000000000805fd43c02f6abee0f86a44e719df2622bbeba666f1abf777702c51962ae225299

另外,通过这道题我发现了其他几个之前没注意的点:

  1. 在函数被调用之前,EVM会先给这个函数初始化一些环境,还有检查一些内容

  2. 编译器对 solidity 做的优化还挺多的,比如 switch 里的判断

  3. 太夸张了,看N1的wp,我最多就能想到用一个指针去任意地址写,N1的payload用 21 覆盖了原有的指针后,再用覆盖的指针去修改了其他位置

  4. 如果无法方便地利用任意地址读写控制一片内存区域,不妨想想这篇内存区域本身的定义是什么,我们是否可以通过原本的定义让他存下某个值(比如本题的 length 就是 animals_counter)

-------------文章就到这里啦!感谢您的阅读XD-------------