跳转至

Ethereum Storage

插槽

以太坊數據存儲會爲合約的每項數據指定一個可計算的存儲位置,存放在一個容量爲 2^256 的超級數組中,數組中每個元素稱爲插槽,其初始值爲 0。雖然數組容量的上限很高,但實際上存儲是稀疏的,只有非零(空值)數據纔會被真正寫入存儲。

# 插槽式數組存儲
----------------------------------
|               0                |     # slot 0
----------------------------------
|               1                |     # slot 1
----------------------------------
|               2                |     # slot 2
----------------------------------
|              ...               |     # ...
----------------------------------
|              ...               |     # 每個插槽 32 字節
----------------------------------
|              ...               |     # ...
----------------------------------
|            2^256-1             |     # slot 2^256-1
----------------------------------

當數據長度是已知時,其具體的存儲位置將在編譯時指定,而對於長度不確定的類型(如動態數組、映射),則會按一定規則計算存儲位置。以下是對不同類型變量的儲存模型的具體分析。

值類型

除映射和動態數組之外的所有類型,其數據長度都是已知的,如定長整型(int/uint/...), 地址(address), 定長浮點型(fixed/ufixed/...), 定長字節數組(bytes1-bytes32),編譯時將嚴格根據字段排序順序,從位置 0 開始連續放置在存儲中。如果可能的話,大小少於 32 字節的多個變量會被打包到一個插槽中,而當某項數據超過 32 字節,則需要佔用多個連續插槽(data.length / 32)。規則如下:

  • 存儲插槽的第一項會以低位對齊(即右對齊)的方式儲存。
  • 基本類型僅使用存儲它們所需的字節。
  • 如果存儲插槽中的剩餘空間不足以儲存一個基本類型,那麼它會被移入下一個存儲插槽。
  • 結構和數組數據總是會佔用一整個新插槽(但結構或數組中的各項,都會以這些規則進行打包)。

如以下合約:

pragma solidity ^0.4.0;

contract C {
    address a;      // 0
    uint8 b;        // 0
    uint256 c;      // 1
    bytes24 d;      // 2
}

其存儲佈局如下:

-----------------------------------------------------
| unused (11) | b (1) |            a (20)           | <- slot 0
-----------------------------------------------------
|                       c (32)                      | <- slot 1
-----------------------------------------------------
| unused (8) |                d (24)                | <- slot 2
-----------------------------------------------------

映射

對於形如 mapping(address => uint) a; 的映射類型變量,就無法簡單仿照值類型按順序儲存了。對於映射,其會根據上節提到的規則佔據位置 p 處的一個插槽,但該插槽不會被真正使用。映射中的鍵 k 所對應的值會位於 keccak256(k . p), 其中 . 是連接符。如果該值同時是一個非基本類型,則將 keccak256(k . p) 作爲偏移量來找到具體的位置。

如以下合約:

pragma solidity ^0.4.0;

contract C {
    mapping(address => uint) a;      // 0
    uint256 b;                       // 1
}

其存儲佈局如下:

-----------------------------------------------------
|                    reserved (a)                   | <- slot 0
-----------------------------------------------------
|                      b (32)                       | <- slot 1
-----------------------------------------------------
|                        ...                        |   ......
-----------------------------------------------------
|                     a[addr] (32)                  | <- slot `keccak256(addr . 0)`
-----------------------------------------------------
|                        ...                        |   ......
-----------------------------------------------------

動態數組

對於形如 uint[] b; 的動態數組,其同樣會佔用對應位置 p 處的插槽,用以儲存數組的長度,而數組真正的起始點會位於 keccak256(p) 處(字節數組和字符串在這裏是一個例外,見下文)。

如以下合約:

pragma solidity ^0.4.0;

contract C {
    uint256 a;      // 0
    uint[] b;       // 1
    uint256 c;      // 2
}

其存儲佈局如下:

-----------------------------------------------------
|                      a (32)                       | <- slot 0
-----------------------------------------------------
|                    b.length (32)                  | <- slot 1
-----------------------------------------------------
|                      c (32)                       | <- slot 2
-----------------------------------------------------
|                        ...                        |   ......
-----------------------------------------------------
|                      b[0] (32)                    | <- slot `keccak256(1)`
-----------------------------------------------------
|                      b[1] (32)                    | <- slot `keccak256(1) + 1`
-----------------------------------------------------
|                        ...                        |   ......
-----------------------------------------------------

字節數組和字符串

如果 bytesstring 的數據很短,那麼它們的長度也會和數據一起存儲到同一個插槽。具體地說:如果數據長度小於等於 31 字節, 則它存儲在高位字節(左對齊),最低位字節存儲 length * 2。如果數據長度超出 31 字節,則在主插槽存儲 length * 2 + 1, 數據照常存儲在 keccak256(slot) 中。

可見性

由於以太坊上的所有信息都是公開的,所以即使一個變量被聲明爲 private,我們仍能讀到變量的具體值。

利用 web3 提供的 web3.eth.getStorageAt() 方法,可以讀取一個以太坊地址上指定位置的存儲內容。所以只要計算出了一個變量對應的插槽位置,就可以通過調用該函數來獲得該變量的具體值。

調用:

// web3.eth.getStorageAt(address, position [, defaultBlock] [, callback])
web3.eth.getStorageAt("0x407d73d8a49eeb85d32cf465507dd71d507100c1", 0)
.then(console.log);
> "0x033456732123ffff2342342dd12342434324234234fd234fd23fd4f23d4234"

參數:

  • address:String - 要讀取的地址
  • position:Number - 存儲中的索引編號
  • defaultBlock:Number|String - 可選,使用該參數覆蓋 web3.eth.defaultBlock 屬性值
  • callback:Function - 可選的回調函數, 其第一個參數爲錯誤對象,第二個參數爲結果。

例子

以 Balsn CTF 2019 的 Bank 一題爲例,更爲具體講解以太坊的存儲佈局。題目中變量和結構的定義如下:

contract Bank {
    address public owner;
    uint randomNumber = 0;

    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;
}

合約的變量按照以下佈局存儲在插槽 0 到 3 上:

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

對於結構 SafeBoxFailedAttempt,每個結構佔據的存儲佈局如下:

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

# FailedAttempt
-----------------------------------------------------
|                      idx (32)                     |
-----------------------------------------------------
|                     time (32)                     |
-----------------------------------------------------
|          origin (20)         |   triedPass (12)   |
-----------------------------------------------------

對於數組 safeboxes,數組內元素的起始點在 keccak256(2) 處,每個元素佔據 2 個插槽;而對於映射 failedLogs,需要先通過 keccak256(addr . 3) 來得到特定地址 addr 對應數組的位置,該位置記錄着數組的長度,而數組真正的起始點位於 keccak256(keccak256(addr . 3)) 處,每個元素佔據 3 個插槽。

可以藉助以下代碼方便地計算數組和映射對應元素的真正位置:

function read_slot(uint k) public view returns (bytes32 res) {
    assembly { res := sload(k) }
}

function cal_addr(uint k, uint p) public pure returns(bytes32 res) {
    res = keccak256(abi.encodePacked(k, p));
}

function cal_addr(uint p) public pure returns(bytes32 res) {
    res = keccak256(abi.encodePacked(p));
}

題目

與以太坊的存儲相關的攻擊一般分爲兩類:

  • 利用以太坊上存儲本質上都是公開的這一特性,任意讀取聲明爲 private 的變量。
  • 結合任意寫的漏洞,覆蓋以太坊上的特定位置的存儲

XCTF_final 2019

  • 題目名稱 Happy_DOuble_Eleven

Balsn 2019

  • 題目名稱 Bank

參考