Storage布局原理以及冲突介绍

说明

本篇文章要讲解的智能合约中的一种名为Storage Collision的漏洞,中文翻译就是storage冲突漏洞。这类漏洞一般是出现在代理合约和升级合约之前因为某些原因导致storage冲突,从而读取了一些非预期的变量,从而导致了漏洞。

本篇文章先会讲解智能合约中的storage相关概念,然后再会引出storage冲突漏洞。

storage

solidity数据存储位置有三类:storagememorycalldata。不同存储位置的gas成本不同。storage类型的数据存在链上,类似计算机的硬盘,消耗gas多;memorycalldata类型的临时存在内存里,消耗gas少。他们之前的大致区别如下:

  1. storage:合约里的状态变量默认都是storage,存储在链上

  2. memory:函数里的参数和临时变量一般用memory,存储在内存中,不上链

  3. calldata:和memory类似,存储在内存中,不上链。与memory的不同点在于calldata变量不能修改(immutable),一般用于函数的参数。例子:

    1
    2
    3
    4
    5
    function fCalldata(uint[] calldata _x) public pure returns(uint[] calldata){
    //参数为calldata数组,不能被修改
    // _x[0] = 0 //这样修改会报错
    return(_x);
    }

对于智能合约中定义的变量,一般都会是以storage的方式保存在链上。而在函数方法中定义的临时变量只是一个临时的内存变量。如下一个简单的智能合约:

1
2
3
4
5
6
7
8
contract Storage {
uint256 public a;
bytes32 public b;

function foo() public {
uint256 c;
}
}

在这个例子中,变量ab就是以storage的方式保存在链上。通过foundry就可以查看到此合约的实际的storage分布情况。

1
2
3
4
5
6
7
+------+---------+------+--------+-------+------------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+=============================================================================+
| a | uint256 | 0 | 0 | 32 | src/test/storagetest.t.sol:Storage |
|------+---------+------+--------+-------+------------------------------------|
| b | bytes32 | 1 | 0 | 32 | src/test/storagetest.t.sol:Storage |
+------+---------+------+--------+-------+------------------------------------+

上表就是显示了所有的storage类型的变量,变量名(Name),变量数据类型(Type),变量位置(Slot),以及偏移量(offset)。

基本概念

Solidity 中的内存布局,有一个插槽(slot)的概念。每一个合约,都有 2 ^ 256 个内存插槽用于存储状态变量,但是这些插槽并不是实际存在的,也就是说,并没有实际占用了这么多空间,而是按需分配,用到时就会分配,不用时就不存在。插槽数量的上限是 2 ^ 256,每个插槽的大小是 32 个字节。图示如下:

插槽分配

在合约中存在不同类型的变量,包括固定类型的,比如uintbytes(n)addressboolstring,还有uint8uint256 等,bytes(n) 也包括 bytes2bytes32 等,还有非固定类型,比如mapping数组等等。

在实际的插槽分配时,并不是按照一个变量就分配一个插槽的做法。

固定长度类型

下面通过不同的实验来观察和分析:

1
2
3
4
5
6
7
8
9
10
11
contract MyStorage {
uint256 public a;
uint256 public b;
uint256 public c;

function foo() public {
a = 1;
b = 2;
c = 123;
}
}

合约中存在a,b,c 三个变量都是 uint256 类型的,恰好每个变量都占用了一个插槽,分别是插槽0,1,2。实际查看:

1
2
3
4
5
6
7
8
9
+------+---------+------+--------+-------+--------------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+===============================================================================+
| a | uint256 | 0 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |
|------+---------+------+--------+-------+--------------------------------------|
| b | uint256 | 1 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |
|------+---------+------+--------+-------+--------------------------------------|
| c | uint256 | 2 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |
+------+---------+------+--------+-------+--------------------------------------+

和我们设想的一致。

如果将合约修改成为下面的结果:

1
2
3
4
5
contract MyStorage {
uint8 public a;
uint8 public b;
uint256 public c;
}

实际查看结果:

1
2
3
4
5
6
7
8
+------+---------+------+--------+-------+--------------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+===============================================================================+
| a | uint8 | 0 | 0 | 1 | src/test/storagetest.t.sol:MyStorage |
|------+---------+------+--------+-------+--------------------------------------|
| b | uint8 | 0 | 1 | 1 | src/test/storagetest.t.sol:MyStorage |
|------+---------+------+--------+-------+--------------------------------------|
| c | uint256 | 1 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |

实际查看可以发现,变量ab都是在Slot0中,cSlot1中。为什么呢?因为从资源节约的角度老说,变量a和变量b的长度都不足一个slot,而且两者时并列在一起的,所以可以房子一个Slot中。配合Offset就可以获取到变量a或者是变量b的值了。

继续变换,如果将上述的合约更改成为如下的代码:

1
2
3
4
5
contract MyStorage {
uint8 public a;
uint256 public c;
uint8 public b;
}

查看结果:

1
2
3
4
5
6
7
8
+------+---------+------+--------+-------+--------------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+===============================================================================+
| a | uint8 | 0 | 0 | 1 | src/test/storagetest.t.sol:MyStorage |
|------+---------+------+--------+-------+--------------------------------------|
| c | uint256 | 1 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |
|------+---------+------+--------+-------+--------------------------------------|
| b | uint8 | 2 | 0 | 1 | src/test/storagetest.t.sol:MyStorage |

这是因为,虽然 a 只占据了插槽 0 中的 1 个字节,但是由于下一个变量 c 要占据一整个插槽,所以 c 只能去下一个插槽,那么 b 也就只能去第三个插槽了。

这个就表明,其实智能合约的开发人员在编写智能合约时需要着重考虑内存布局。合理的内存布局不仅可以节省内存空间还可以节省Gas。

非固定长度类型

着重关注的是map和数组这两种类型。

首先看数组的内存分配。

1
2
3
4
contract MyStorage {
int256[5] arr; // slot: 0, 1, 2, 3, 4
int256 value; // slot: 5
}

查看结果:

1
2
3
4
5
6
+-------+-----------+------+--------+-------+--------------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+==================================================================================+
| arr | int256[5] | 0 | 0 | 160 | src/test/storagetest.t.sol:MyStorage |
|-------+-----------+------+--------+-------+--------------------------------------|
| value | int256 | 5 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |

没有确切大小的Array会以Slot Index作为其Index。后续通过计算 keccak256(slot) 可以得知 _arr[0] 被存在哪里,如果要取得 _arr[1] 则将计算出来的杂凑加上 Array 的 index 即可。这种方式就和之前在C语言中计算访问数组中的元素类似。

如果要访问或者是修改中的元素,就可以如下的方式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract ArrayLayout {
uint256[] private _arr;

struct Slot {
uint256 value;
}

function _getSlot(bytes32 slot_) internal pure returns (Slot storage ret) {
assembly {
ret.slot := slot_
}
}

function get(uint256 index) public view returns (uint256) {
uint256 s = uint256(keccak256(abi.encodePacked(uint256(0)))) + index;
return _getSlot(bytes32(s)).value;
}

function update(uint256 index, uint256 val) public {
uint256 s = uint256(keccak256(abi.encodePacked(uint256(0)))) + index;
_getSlot(bytes32(s)).value = val;
}
}

刚才的数组的元素长度是int256,意味着一个元素就会占用一个Slot。所以int256[5] arr;就会占用0-4这5个Slot。

如果将数组中的元素的数据类型换成int8,如下:

1
2
3
4
contract MyStorage {
int8[5] arr;
int256 value;
}

得到的结果是:

1
2
3
4
5
6
+-------+---------+------+--------+-------+--------------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+================================================================================+
| arr | int8[5] | 0 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |
|-------+---------+------+--------+-------+--------------------------------------|
| value | int256 | 1 | 0 | 32 | src/test/storagetest.t.sol:MyStorage |

因为这5个数组元素的的总长度可以完全放入到一个Slot中,就看到5个元素全部位于第一个Slot中。

Mapping 则是以 Slot的索引Index 和 Key 计算出一个哈希值并将其作为 Slot 链上存储的Index。

具体的代码演示如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
contract MappingLayout {
mapping(uint256 => uint256) private _map;

struct Slot {
uint256 value;
}

function _getSlot(bytes32 slot_) internal pure returns (Slot storage ret) {
assembly {
ret.slot := slot_
}
}

function get(uint256 key) public view returns (uint256 ret) {
bytes32 b = keccak256(abi.encodePacked(key, uint256(0)));
console2.logBytes32(b);
ret = _getSlot(b).value;
}

function update(uint256 key, uint256 val) public {
bytes32 b = keccak256(abi.encodePacked(key, uint256(0)));
_getSlot(b).value = val;
}
}

contract MyTest is Test {
MappingLayout mappingLayout;
function setUp() public {
mappingLayout = new MappingLayout();
mappingLayout.update(0, 1);
mappingLayout.update(1, 2);

}

function testExp() public {
mappingLayout.get(0);
mappingLayout.get(1);
}
}

最终得到两个Slot对应的Hash Index如下所示:

1
2
Slot0:0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5
Slot1:0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d

我们通过web3.eth.getStorageAt()的方式就可以获得对应的Hash Index的值。

1
2
web3.eth.getStorageAt(contract_address,"0xad3228b676f7d3cd4284a5443f17f1962b36e491b30a40b2405849e597ba5fb5")
web3.eth.getStorageAt(contract_address,"0xada5013122d395ba3c54772283fb069b10426056ef8ca54750cb9bb552a59e7d")

上面的结果分别就是0和1,和我们设置的值是对应的。

Solidity Data Collision

翻译成为中文含义就是solidity数据冲突。这种情况常见于代理合约和实现合约中因为一些冲突或者是覆盖而导致存在的漏洞。

delegatecall

当 A 合约对 B 合约执行 delegatecall 时,B 合约的函式会被执行,但是对 storage 的操作都会作用在 A 合约上。如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
contract A {
uint256 public value;

function add(address b, uint256 x, uint256 y) public returns (bool) {
(bool success,) = b.delegatecall(
abi.encodeWithSelector(
bytes4(keccak256('add(uint256,uint256)')),
x,
y
)
);
return success;
}
}

contract B {
uint256 public value;

function add(uint256 x, uint256 y) public returns (uint256) {
value = x + y;
return value;
}
}

观察合约A和合约B之前的storage layout之间的关系。

1
2
3
4
5
6
7
8
9
10
+-------+---------+------+--------+-------+------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+========================================================================+
| value | uint256 | 0 | 0 | 32 | src/test/storagetest.t.sol:A |

+-------+---------+------+--------+-------+------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+========================================================================+
| value | uint256 | 0 | 0 | 32 | src/test/storagetest.t.sol:B |
+-------+---------+------+--------+-------+------------------------------+

因为执行了delegatecall操作,合约B的value会被设置为2,同样也会做用到合约A上,所以合约A的value是2。

如果在合约A中增加一个变量other,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
contract A {
uint256 public other;
uint256 public value;

function add(address b, uint256 x, uint256 y) public returns (bool) {
(bool success,) = b.delegatecall(
abi.encodeWithSelector(
bytes4(keccak256('add(uint256,uint256)')),
x,
y
)
);
return success;
}
}

修改了合约A之后,得到此时的Storage的布局如下,此时value的slot变为了1,other的slot index变为了0。

1
2
3
4
5
6
7
+-------+---------+------+--------+-------+------------------------------+
| Name | Type | Slot | Offset | Bytes | Contract |
+========================================================================+
| other | uint256 | 0 | 0 | 32 | src/test/storagetest.t.sol:A |
|-------+---------+------+--------+-------+------------------------------|
| value | uint256 | 1 | 0 | 32 | src/test/storagetest.t.sol:A |

合约A执行完毕add(b,1,1)操作,变量的结果变为:

1
2
value:0
other:2

因为合约B将slot为0的位置映射到合约A的slot为0的位置,这样就导致合约A中的other变为了2。

Storage Collision

前面说了合约A和合约B之前因为storage slot的不对等,导致出现了问题。这种问题对于代理合约和实现合约也会存在一样的问题。如下所示:

1
2
3
4
5
6
|Proxy                     |Implementation           |
|--------------------------|-------------------------|
|address _implementation |address _owner | <= collision
|... |mapping _balances |
| |uint256 _supply |
| |... |

当修改_owner时同时也会将代理合约的_implementation修改了,这样就是非预期的。

历史上出现的storage冲突的原因都是因为代理合约和实现合约的storgae存在错误导致的。比如经典的Audius漏洞、EFVault漏洞。

有机会再对这两个漏洞进行深入的分析加深对storage冲突的理解。

总结

storage在合约中是一个非常重要的课题,了解并掌握storage的原理不仅可以帮助我们在编写智能合约时节省Gas同时也可以避免我们写出有漏洞的代码。

参考

https://medium.com/taipei-ethereum-meetup/solidity-data-collision-51e88f1529a8

https://mirror.xyz/xyyme.eth/5eu3_7f7275rqY-fNMUP5BKS8izV9Tshmv8Z5H9bsec

https://mirror.xyz/xyyme.eth/IQ8uMgQ11S7YK_Tt4sR3E1iPq6MBrdu5WqHwdFPwWuw

https://learnblockchain.cn/books/geth/part7/storage.html

文章作者: Oooverflow
文章链接: https://www.oooverflow.com/2023/03/07/Storage-Collision-Intro/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来自 Oooverflow