背景
本文主要参考 其他人的分析 然后实战结合一个标准的 代理合约源码 来分析 storage 变量在内存里的存储情况。
嫌文章太长的,这里先直接总结规律如下:
- 变量会按照申明顺序从 0x0 地址开始依次分配存储位置,即 slot
- 按照申明先后顺序相邻两个变量能挤进一个 slot(byte32)就挤,不行就另起一个 slot
- 定长数组 等价于 连续申明几个相同类型的变量 ,所以参照第 1、2 点处理即可
- 变长数组(以 int 数组举例)会先按第 1 点分配 slot,这个 slot
会存当前数组的长度,假设这个长度所在的位置为
slot_0,后续的内存分配将会满足下述条件
- 第一个元素所在 slot:slot_1 = keccak256(slot_0)
- 第二个元素所在 slot:slot_2 = slot_1 + 1
- 第 n 个元素所在 slot:slot_n = slot_n-1 + (n - 1)
- 对于长度不大于 31 字节的字符串变量,先按照第 1 点顺序分配 slot,然后低位最后一个字节存储字符串变量的长度,高位存储字符串的 ascii 值。如果长度大于或等于 32 字节,则将其等价视为 变长的 bytes 数组,参照第 4 点处理其内存存储方案
- 结构体类型 也等价于 连续申明几个相同类型的变量 ,所以参照第 1、2 点处理即可
- 映射类型变量同样先按照第 1 点分配 slot,然后存储每个 value 的 slot 位置为:keccak(key + slot),这个 + 指字节级别的直接拼接。key 参与了存 value 的 slot 的计算
- 对于数组、结构体、映射类型,参照上述 1~7 点找到存储 值 对应的位置 slot 后,再根据 值 的类型再次参照上述规则递归地继续解析内容
正文
先要分析所有变量的申明顺序,proxy 合约本身没有 storage
变量,所有变量都在 逻辑合约
。先从第 1550 行开始: 1
contract dotbit is ERC721Upgradeable, AccessControlUpgradeable {
行数 | 变量 | 类型 | slot(hex/dec) |
---|---|---|---|
426 | _initialized | uint8 | 0x0/0 |
431 | _initializing | bool | 0x0/0 |
552 | __gap | uint256[50] | 0x1/1 |
654 | __gap | uint256[50] | 0x33/51 |
1106 | _name | string | 0x65/101 |
1109 | _symbol | string | 0x66/102 |
1112 | _owners | mapping(uint256 => address) | 0x67/103 |
1115 | _balances | mapping(address => uint256) | 0x68/104 |
1118 | _tokenApprovals | mapping(uint256 => address) | 0x69/105 |
1121 | _operatorApprovals | mapping(address => mapping(address => bool)) | 0x6a/106 |
1539 | __gap | uint256[44] | 0x6b/107 |
718 | _roles | mapping(bytes32 => RoleData) struct RoleData { mapping(address => bool) members; bytes32 adminRole; } |
0x97/151 |
904 | __gap | uint256[49] | 0x98/152 |
1556 | expires | mapping(uint256 => uint) | 0xc9/201 |
1558 | BASE_URL | string | 0xca/202 |
工具
以 python 为例,计算 slot 相关的 hash 为: 1
2
3
4
5
6from _pysha3 import keccak_256
import binascii
def byte32(i):
return binascii.unhexlify('%064x' % i)
# keccak 的计算方法:keccak_256(byte32(0x0)).hexdigest()
初始化
先分析第一笔设置 logic 并调用其 initialize 函数的 交易,展开它的 state 项:
mint
以 这笔 mint 交易为例:
所以想要用代码获取 slot
的内容,那就需要(以获取上面例子中的过期时间为例): 1
2
3
4
5
6# 承接上段代码
from web3 import Web3
w3 = Web3(Web3.HTTPProvider('https://rinkeby.infura.io/v3/<your own key>'))
proxy_addr = "0xb03b90A971bAAF922fd6eDcd8d264BCeBE2E3931"
slot_addr = keccak_256(byte32(0x54ba43f5a4bbde48934741b830d0537c8567735e) + byte32(0xc9)).hexdigest()
print(int(w3.eth.getStorageAt(proxy_addr, slot_addr).hex(), 16))
拓展
以下面的代码举例( 来源
): 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18assembly {
let ptr := mload(0x40)
calldatacopy(ptr, 0, calldatasize)
let result := delegatecall(gas, _impl, ptr, calldatasize, 0, 0)
}
contract Calldata {
function add(uint256 _a, uint256 _b) public view
returns(uint256 result) {
assembly {
let a: = mload(0x40)
let b: = add(a, 32)
calldatacopy(a, 4, 32)
calldatacopy(b, add(4, 32), 32)
result: = add(mload(a), mload(b))
}
}
}
- memory 变量从 0x40 开始
- calldata 的前 4 个字节为函数签名的 keccak 结果前 4 字节,接着就是函数的(依次)入参
- mload 指加载 memory 数据到 stack,sload 指加载 storage 数据到 stack。mstore 和 sstore 类似
更详细的资料参见 官网文档
无论是 call 还是 delegatecal ,消耗的 gas 都应该占比总交易的 63/64,剩下的 1/64 的 gas 要去处理 OOG(out of gas) 的错误异常
总结
如果我们试图通过 slot 碰撞来攻击合约的话,以 ENS 举例:
ENS 合约里有记录 ENS 所有权的 _tokenOwner 变量,还有个记录 ENS 过期时间的 expiries 变量,我们的目标是试图通过注册一个特别的 ENS 账户和特别的过期时间使得合约在设置 expiries 的时候将我们想要的目标 ENS 账户的所有权(_tokenOwner )设置成特别的过期时间(原理就是通过 slot 碰撞实现),然后过期时间以 address 的格式解析的时候就是一个私人 EOA 地址。
那么我们所面临的问题本质其实就是一个 keccak256 碰撞问题(碰撞的代码其实不难实现),但这(至少目前)几乎不可能做到。