区块链安全

Ethereum Storage 进阶

0x01 例题1:复杂数据结构 storage 位置计算

pragma solidity >=0.4.0 <0.7.0;

contract C {
  struct s { uint a; uint b; }
  uint x;
  mapping(uint => mapping(uint => s)) data;
}

计算 data[4][9].b 的 slot 位置。

  1. 要计算 data[4][9].b的位置,因为 data[4][9] 是一个 struct s 类型的数据结构,所以我们先要计算这个 struct 的位置。也就是计算 data[4][9] 的位置。
  2. 因为 data[4][9] 是一个映射,所以要计算值 data[4][9] 的位置,得先计算键 data[4] 的位置。
  3. 要计算 data[4] 的位置,根据 Ethereum Storage 一文中提到的数组的存储规律,此刻就可以套用公式了。
  4. contract C 中的布局如下图,要注意 uint 类型变量 x 会默认初始化为 0,占 1 个 slot。mapping 也会默认初始化为0,占 1 个 slot(后面的键值对会继续按照上文提到的规律实例化占 slot)。但是 struct 不赋值不会默认初始化,也就是说结构体不赋值则不会占 slot,所以内存中没有结构体 s 的位置。这段红字说的规律我在下一个小节会证明。
-----------------------------------------------------
|                      x(32)                        | <- slot 0
-----------------------------------------------------
|           occupied by data but unused (32)        | <- slot 1
-----------------------------------------------------

5. 所以 mapping data 位于slot 1(该变量本来的位置),求 data[4] 所在的slot位置,则公式为:keccak256(bytes32(4),bytes32(1))
6. 找到了 key data[4] 的位置,要找值 data[4][9] 的位置,因为这个变量本来位于 slot keccak256(bytes32(4),bytes32(1)),所以求 data[4][9] 的位置,则公式为 keccak256(bytes32(9),keccak256(bytes32(4),bytes32(1)))
注: keccak 函数的返回值为 bytes32
7. 最后求 data[4][9].b 的位置。因为结构体其实就是变量的组合,struct s 结构体的布局应该是如下这样的:

-----------------------------------------------------
|                      a(32)                        | <- slot 0
-----------------------------------------------------
|                      b(32)                        | <- slot 1
-----------------------------------------------------

8. data[4][9] 这个结构体的位置在 keccak256(bytes32(9), keccak256(bytes32(4), bytes32(1))) 。所以 data[4][9].b 的位置实际上应该是这个结构体的位置往下移动1slot也就是 keccak256(bytes32(9), keccak256(bytes32(4), bytes32(1)))+1。注:在 solidity 中直接把前一部分转 uint 然后加1最后输出的值是十进制的,再转为 bytes32 则输出16进制的。

验证一下计算结果,如下图所示是正确的:

0x02 一些规律证明

  • uint 类型变量 x 在不赋值的情况下会默认初始化为 0,占一个 slot(uint = uint256)

不给 uint 变量赋值,在 remix 上面查看变量看上去没有占用 slot:

看上去是如果赋值之后就会占用:

但其实如下图,uint 变量 a 虽然没被赋值,但其实占了 slot 0,缺省值为0;uint 变量 b 顺次排到 slot 1,值为 10。

对于 mapping 类型也是同理:

但对于 struct 对象则不是这样,没有实例化的话不占 slot:

实例化之后,就算不赋值,也占拆分出的变量大小的slot:

对比一下赋值之后:

再对比一下 mapping 赋值之后:可以看到总会预留一个 slot 给 mapping,但是 mapping 键值对的存储却是根据公式另外算的。

再来看一种情况:Uninitialized Storage Pointer 会覆盖变量 b 的值。

注:贴出来上面用汇编语句取 storage slot 的一段代码:

pragma solidity ^0.4.24;
contract test {
    struct s{
        uint x;
        uint y;
    }
    uint b = 10;
    constructor() public{
        s s1;
        s1.x = 0xb;
        s1.y = 0xc;
    }
    
    function get_value_from_slot(uint s) public view returns(bytes32 _value){
    	assembly{
    		let _v := sload(s)
    		_value := _v
        }
    }
}

0x03 例题2

pragma solidity ^0.4.24;
contract Bank {
    event SendEther(address addr);
    event SendFlag(address addr);
    address public owner;
    uint randomNumber = 0;
    constructor() public {
        owner = msg.sender;
    }
    struct SafeBox {
        bool done;
        function(uint, bytes12) internal callback;
        bytes12 hash;
        uint value;
    }
    SafeBox[] safeboxes;
    struct FailedAttempt {
        uint idx;
        uint time;
        bytes12 triedPass;
        address origin;
    }
    mapping(address => FailedAttempt[]) failedLogs;
    modifier onlyPass(uint idx, bytes12 pass) {
        if (bytes12(sha3(pass)) != safeboxes[idx].hash) {
            FailedAttempt info;
            info.idx = idx;
            info.time = now;
            info.triedPass = pass;
            info.origin = tx.origin;
            failedLogs[msg.sender].push(info);
        }
        else {
            _;
        }
    }
    function deposit(bytes12 hash) payable public returns(uint) {
        SafeBox box;
        box.done = false;
        box.hash = hash;
        box.value = msg.value;
        if (msg.sender == owner) {
            box.callback = sendFlag;
        }
        else {
            require(msg.value >= 1 ether);
            box.value -= 0.01 ether;
            box.callback = sendEther;
        }
        safeboxes.push(box);
        return safeboxes.length-1;
    }
    function withdraw(uint idx, bytes12 pass) public payable {
        SafeBox box = safeboxes[idx];
        require(!box.done);
        box.callback(idx, pass);
        box.done = true;
    }
    function sendEther(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
        msg.sender.transfer(safeboxes[idx].value);
        emit SendEther(msg.sender);
    }
    function sendFlag(uint idx, bytes12 pass) internal onlyPass(idx, pass) {
        require(msg.value >= 100000000 ether);
        emit SendFlag(msg.sender);
        selfdestruct(owner);
    }
}
  • 求 failedLogs[msg.sender][0] 中的 tx.origin | triedPass 的 slot 位置。
  • 求 safeboxes 数组中第一个元素所在的 slot 的位置。

failedLogs 是 mapping 加上数组的形式:mapping(address => FailedAttempt[]) failedLogs
onlyPass 中的 FailedAttempt 的布局如下:

-----------------------------------------------------
|                      idx (32)                     |
-----------------------------------------------------
|                     time (32)                     |
-----------------------------------------------------
|          tx.origin (20)      |   triedPass (12)   |
-----------------------------------------------------
  • 首先求 failedLogs[msg.sender] 的位置,这是一个 mapping。因为合约刚创建的时候的 slot 的布局如下:
-----------------------------------------------------
|     unused (12)     |          owner (20)         | <- slot 0
-----------------------------------------------------
|                 randomNumber (32)                 | <- slot 1
-----------------------------------------------------
|               safeboxes.length (32)               | <- slot 2
-----------------------------------------------------
|       occupied by failedLogs but unused (32)      | <- slot 3
-----------------------------------------------------
  • 所以 failedLogs 应该位于 slot 3,然后 key = msg.sender,所以根据公式这个mapping 位于keccak256(bytes32(msg.sender),bytes32(3))
  • 计算完了 mapping,然后再计算数组的第一个元素也是位于 keccak256(bytes32(msg.sender),bytes32(3));
  • 然后再计算 tx.origin | triedPass 的位置,每一个数组元素是一个 FailedAttempt 类型的结构体,所以位置为 keccak256(bytes32(msg.sender),bytes32(3)) +2

计算 safeboxes 数组中第一个元素所在的 slot 的位置,本来 deposit 中的 SafeBox 的布局如下:

-----------------------------------------------------
| unused (11) | hash (12) | callback (8) | done (1) |
-----------------------------------------------------
|                     value (32)                    |
-----------------------------------------------------

但只是让我们计算第一个元素所在的 slot 位置,而不是元素中的具体结构体变量。所以根据数组的 slot 位置公式计算即可:
 base = keccak256(bytes32(2))

这个2是 safeboxes 数组长度位于的 slot 位置,如下图为 slot2:

-----------------------------------------------------
|     unused (12)     |          owner (20)         | <- slot 0
-----------------------------------------------------
|                 randomNumber (32)                 | <- slot 1
-----------------------------------------------------
|               safeboxes.length (32)               | <- slot 2
-----------------------------------------------------
|       occupied by failedLogs but unused (32)      | <- slot 3
-----------------------------------------------------

0x04 总结

复杂数据结构对象的 slot 位置是区块链 ctf 里面的常考题,还需要多加练习。

感谢 @r4v3n @Rivaill 一直以来的不吝赐教。

本文参考:Uninitialized Storage Pointer