solidity 变量内存布局——理论篇

背景

本文主要参考 其他人的分析 然后实战结合一个标准的 代理合约源码 来分析 storage 变量在内存里的存储情况。

嫌文章太长的,这里先直接总结规律如下:

  1. 变量会按照申明顺序从 0x0 地址开始依次分配存储位置,即 slot
  2. 按照申明先后顺序相邻两个变量能挤进一个 slot(byte32)就挤,不行就另起一个 slot
  3. 定长数组 等价于 连续申明几个相同类型的变量 ,所以参照第 1、2 点处理即可
  4. 变长数组(以 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)
  5. 对于长度不大于 31 字节的字符串变量,先按照第 1 点顺序分配 slot,然后低位最后一个字节存储字符串变量的长度,高位存储字符串的 ascii 值。如果长度大于或等于 32 字节,则将其等价视为 变长的 bytes 数组,参照第 4 点处理其内存存储方案
  6. 结构体类型 也等价于 连续申明几个相同类型的变量 ,所以参照第 1、2 点处理即可
  7. 映射类型变量同样先按照第 1 点分配 slot,然后存储每个 value 的 slot 位置为:keccak(key + slot),这个 + 指字节级别的直接拼接。key 参与了存 value 的 slot 的计算
  8. 对于数组、结构体、映射类型,参照上述 1~7 点找到存储 对应的位置 slot 后,再根据 的类型再次参照上述规则递归地继续解析内容

正文

先要分析所有变量的申明顺序,proxy 合约本身没有 storage 变量,所有变量都在 逻辑合约 。先从第 1550 行开始:

1
contract dotbit is ERC721Upgradeable, AccessControlUpgradeable {
按顺序递归查看父类 ERC721Upgradeable 和 AccessControlUpgradeable(若有同样的父类,前面已经查看的话,后面就忽略),这些变量都是从 0 开始分配 slot

行数 变量 类型 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
6
from _pysha3 import keccak_256
import binascii
def byte32(i):
return binascii.unhexlify('%064x' % i)

# keccak 的计算方法:keccak_256(byte32(0x0)).hexdigest()

初始化

先分析第一笔设置 logic 并调用其 initialize 函数的 交易,展开它的 state 项: file file

mint

这笔 mint 交易为例: file

所以想要用代码获取 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
18
assembly {
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 碰撞问题(碰撞的代码其实不难实现),但这(至少目前)几乎不可能做到。