By:小白@慢霧安全團隊

背景概述

上次我們了解了solidity 中自帶的函數——自毀函數,相信大家多少已經對它有所了解,這次我們將了解如何訪問合約中的私有數據(private 數據)。

前置知識

我們先來了解一下solidity 中的三種數據存儲方式:

1. storage(存儲)

storage 中的數據被永久存儲。其以鍵值對的形式存儲在slot 插槽中。 storage 中的數據會被寫在區塊鏈中(因此它們會更改狀態),這就是為什麼使用存儲非常昂貴的原因。佔用256 位插槽的gas 成本為20,000 gas。修改storage 的值將花費5,000 gas 。清理存儲插槽時(即將非零字節設置為零),將退還一定量的gas 。 storage 共有2^256 個插槽,每個插槽32 個字節數據按聲明順序依次存儲,數據將會從每個插槽的右邊開始存儲,如果相鄰變量適合單個32 字節,然後它們被打包到同一個插槽中否則將會啟用新的插槽來存儲。

(storage 的存儲方式圖)

storage 中的數組的存儲方式就比較獨特了,首先,solidity 中的數組分為兩種:

a.定長數組(長度固定):

定長數組中的每個元素都會有一個獨立的插槽來存儲。以一個含有三個uint64 元素的定長數組為例,下圖可以清楚的看出其存儲方式:

(定長數組存儲方式圖)

b.變長數組(長度隨元素的數量而改變):

變長數組的存儲方式就很奇特,在遇到變長數組時,會先啟用一個新的插槽slotA 用來存儲數組的長度,其數據存儲在另外的編號為slotV 的插槽中。 slotA 表示變長數組聲明的位置,用length 表示變長數組的長度,用slotV 表示變長數組數據存儲的位置,用value 表示變長數組某個數據的值,用index 表示value 對應的索引下標,則

length = sload(slotA)

slotV = keccak256(slotA) + index

value = sload(slotV)

變長數組在編譯期間無法知道數組的長度,沒辦法提前預留存儲空間,所以Solidity 就用slotA 位置存儲了變長數組的長度。

我們寫一個簡單的例子來驗證上面描述的變長數組的存儲方式:

pragma solidity ^0.8.0;contract haha{ uint[] user; function addUser(uint a) public returns (bytes memory){ user.push(a); return abi.encode(user); }}

部署這個合約後調用addUser 函數並傳入參數a = 998,debug 後可以看出變長數組的存儲方式:

其中第一個插槽為(這裡存儲的是變長數組的長度):

0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563

這個值等於:

sha3('0x0000000000000000000000000000000000000000000000000000000000000000')

key = 0 這是當前插槽的編號

value = 1 這說明變長數組user[] 中只有一條數據也就是數組長度為1 ;

第二個插槽為(這裡存儲的是變長數組中的數據):

0x510e4e770828ddbf7f7b00ab00a9f6adaf81c0dc9cc85f1f8249c256942d61d9

這個值等於:

sha3('0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563')

插槽編號為:

key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563

這個值等於:

sha3('0x0000000000000000000000000000000000000000000000000000000000000000')+0

插槽中存儲的數據為:

value=0x00000000000000000000000000000000000000000000000000000000000003e6

也就是16 進製表示的998 ,也就是我們傳入的a 的值。

為了更準確的驗證我們再調用一次addUser 函數並傳入a=999 可以得到下面的結果:

這裡我們可以看到新的插槽為:

0x6c13d8c1c5df666ea9ca2a428504a3776c8ca01021c3a1524ca7d765f600979a

這個值等於:

sha3('0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564')

插槽編號為: key=0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e564

這個值等於:

sha3('0x0000000000000000000000000000000000000000000000000000000000000000')+1

插槽中的存儲數據為:

value=0x00000000000000000000000000000000000000000000000000000000000003e7

這個值就是16 進製表示的999 也就是我們剛剛調用addUser 函數傳入的a 的值。

通過上面的例子應該可以大致理解變長數組的存儲方式了。

2. memory(內存)

memory 是一個字節數組,其插槽大小為256 位(32 個字節)。數據僅在函數執行期間存儲,執行完之後,將會被刪除。它們不會保存到區塊鏈中。讀或寫一個字節(256 位)需要3 gas 。為了避免給礦工帶來太多工作,在進行22 次讀寫操作後,之後的讀寫成本開始上升。

3.calldata(調用數據)

calldata 是一個不可修改的,非持久性的區域,用於存儲函數參數,並且其行為基本上類似於memory。調用外部函數的參數需要calldata,也可用於其他變量。它避免了複製,並確保了數據不能被修改。帶有calldata 數據位置的數組和結構體也可以從函數中返回,但是不可以為這種類型賦值。

了解了solidity 中的三種存儲方式後我們再來了解一下合約中的四種可見性關鍵字:在solidity 中,有四種可見性關鍵字:external,public,internal 和private。默認時函數可見性為public。對狀態變量而言,除了不能用external 來定義,其它三個都可以來定義變量,狀態變量默認的可見性為internal。

1.external 關鍵字

external 定義的外部函數可以被其它合約調用。用external 修飾的外部函數function() 不能作為內部函數直接調用,也就是說function() 的調用方式必須用this.function() 。

2.public 關鍵字

public 定義的函數可以被內部函數或外部消息調用。對用public 定義的狀態變量,系統會自動生成一個getter 函數。

3.internal 用關鍵字

internal 定義的函數和狀態變量只能在(當前合約或當前合約派生的合約)內部進行訪問。

4.private 關鍵字

private 定義的函數和狀態變量只對定義它的合約可見,該合約派生的合約都不能調用和訪問該函數及狀態變量。

綜上可知,合約中修飾變量存儲的關鍵字僅僅限制了其調用的範圍,並沒有限制其是否可讀。所以我們今天就來帶大家了解如何讀取合約中的所有數據。

漏洞示例

這次我們的目標合約是部署在Ropsten 上的一個合約。

合約地址:

0x3505a02BCDFbb225988161a95528bfDb279faD6b

鏈接:

https://ropsten.etherscan.io/address/0x3505a02BCDFbb225988161a95528bfDb279faD6b#code

這裡我也給大家把合約源碼展示出來:

contract Vault { uint public count = 123; address public owner = msg.sender; bool public isTrue = true; uint16 public u16 = 31; bytes32 private password; uint public constant someConst = 123; bytes32[3] public data; struct User { uint id; bytes32 password; } User[] private users; mapping(uint => User) private idToUser; constructor(bytes32 _password) { password = _password; } function addUser(bytes32 _password) public { User memory user = User({id : users.length, password: _password}); users.push(user); idToUser[user.id] = user; } function getArrayLocation( uint slot, uint index, uint elementSize) public pure returns (uint) { return uint( keccak256(abi.encodePacked(slot))) + (index * elementSize); } function getMapLocation(uint slot, uint key) public pure returns (uint) { return uint(keccak256(abi.encodePacked(key, slot))); }}

漏洞分析

由上面的合約代碼我們可以看到,Vault 合約將用戶的用戶名和密碼這樣的敏感數據記錄在了合約中,由前置知識中我們可以了解到,合約中修飾變量的關鍵字僅限制其調用範圍,這也就間接證明了合約中的數據均是公開的,可任意讀取的,將敏感數據記錄在合約中是不安全的。

讀取數據

下面我們就帶大家來讀取這個合約中的數據。首先我們先看slot0 中的數據:

由合約中可以看到slot0 中只存儲了一個uint 類型的數據,我們讀取出來看一下:

我這裡使用Web3.py 取得數據

首先寫好程序

運行後得到

我們使用進制轉換器轉換一下

這裡我們就成功的去到了合約中的第一個插槽slot0 中存儲的uint 類型的變量count=123 ,下面我們繼續:

slot1 中存儲三個變量:u16, isTrue, owner

從右往左依次為

owner = f36467c4e023c355026066b8dc51456e7b791d99

isTrue = 01 = true

u16 = 1f = 31

slot2 中就存儲著私有變量password 我們讀取看看

slot 3, 4, 5 中存儲著定長數組中的三個元素

slot6 中存儲著變長數組的長度

我們從合約代碼中可以看到用戶的id 和password 是由鍵值對的形式存儲的,下面我們來讀取兩個用戶的id 和password:

user1

user2

好了,這裡我們就成功的將合約中的所有數據讀取完成,現在大家應該都能得出一個結論:合約中的私有數據也是可以讀取的。

修復建議

(1)作為開發者

不要將任何敏感數據存放在合約中,因為合約中的任何數據都可被讀取。

(2)作為審計者

在審計過程中應當注意合約中是否存在敏感數據,例如:秘鑰,遊戲通關口令等。

參考文獻

本期講解的知識有點偏底層,可以參考以下文章幫助你更好地理解:

https://solidity-by-example.org/hacks/accessing-private-data/

《跟我學Solidity : 變量的存儲》https://learnblockchain.cn/article/1759《快速入門——web3.py》https://web3py.readthedocs.io/《狀態變量、函數的權限》https: //blog.csdn.net/liyuechun520/article/details/78408608