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`
-----------------------------------------------------
| ... | ......
-----------------------------------------------------
字節數組和字符串¶
如果 bytes
和 string
的數據很短,那麼它們的長度也會和數據一起存儲到同一個插槽。具體地說:如果數據長度小於等於 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
-----------------------------------------------------
對於結構 SafeBox
和 FailedAttempt
,每個結構佔據的存儲佈局如下:
# 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