AlienCodeX
这道题综合利用了 solidity 低版本的一些特性,并且是两种环环相扣,最终才会实现这种难以预料的情况发生。
源码:
1 | // SPDX-License-Identifier: MIT |
首先分析源码,发现合约版本 5.0(对于合约版本 < 8.0 的需要留意上下溢出)
刚开始做的时候,不知道继承合约会把原合约中的状态变量一块继承,也算是找到了一个盲点。
在 EVM 中,变量存储都是存在插槽(slot)中的,一共有 2**256 个正好对应 sha256。所以slot0存储的是父合约(Ownable)中的地址类型变量(推断出来的,因为这道题的目标是成为 Owner)。
而想要成为 Owner,在这个合约是没有函数的,但是,存在函数retract()
,该函数可以减少bytes32[] codex
的长度(在 Solidity 0.8 版本之前,动态数组没有内置 pop 方法来删除最后一个变量,只能通过array.length--
的方式)这里就出现了问题:Solidity < 0.8 版本存在溢出问题,可以利用该函数,使得动态数组下溢,因为 EVM 只有 2 ** 256
个插槽,下溢后,动态数组覆盖了所有的插槽,这样就可以直接覆盖 slot0 修改 Owner 为我们自己。
还有一点就是关于:动态数组的实际存储位置(之前的博客文章——Yul内联汇编入门里有写)然后在我尝试攻击的时候,忽略了几个点:
keccak256(abi.encode(uint256("实际插槽位置")))
,我使用的是。abi.encodePacked
紧密打包方式,这里错了进制转换的问题:
我最开始的转换方式为:bytes32(bytes20(uint160(msg.sender)))
- 而实际上的转换方式为:
bytes32(uint256(uint160(msg.sender)))
一眼看上去没什么区别,但是这涉及到了显示转换补码的方式(之前的博客文章——solidity类型转换相关有写):
uint
这一类(包括int
)显示转换的时,举例:高位转低位:保留低位
uint32 --> uint16: 0x12345678 --> 0x5678
低位转高位:前补零
uint16 --> uint32: 0x5678 --> 0x00005678
bytes
类:- 高位转地位:保留高位
bytes2 --> bytes1: 0x1234 --> 0x12
- 低位转高位:后补零
bytes1 --> bytes2: 0x12 --> 0x1200
- 高位转地位:保留高位
所以现在不难发现我的问题了:
- 我得到的:
0xaddress000...
- 实际上的:
0x000...address
完整的 PoC:
1 | // SPDX-License-Identifier: MIT |
补充:由于源码中涉及到了修饰符contacted
绕过这个需要调用一次函数makeContact
。
关于 slot0 对应的字节数组序号,这就涉及到数学计算了:我们通过slot = uint256(keccak256(abi.encode(uint256(1))))
得到了动态数组中第一个元素的实际存储位置,数组中一共有2 ** 256
个变量,第一个是变量slot
对应的位置,而 0 对应的数组序号,一样利用下溢计算即可,即0 - slot
。由于我们 PoC 的合约版本 > 0.8 所以利用下溢时,需要使用unchecked
代码块,取消内置的溢出检测。
还有一个可以优化的点,在构造函数中,我传入的类型为address
地址类型,但实际上,可以直接传递IAlienCodex
这个接口类型,后续的书写会简单很多,直接target.函数名
即可,而我现在的就比较麻烦IAlienCodex(target).函数名