作者:安比(SECBIT)實驗室 & 輕信科技(LedgerGo)
本文以蜜罐合約和 BancorLender 合約爲例,詳細介紹 Solidity 語言中「未初始化的 storage 指針」問題,並追蹤 Solidity 編譯器關於此問題的開發進展。
安比(SECBIT)實驗室在 BancorLender (0x2d820ea3A6b9302c500feeb7F6361bA1DdfA5aBa) 合約中發現野指針問題(uninitialized-wild-pointer)。該合約中的一個狀態變量會意外地被另一個函數修改,偏離原本設計意圖。目前項目方不明確。建議項目方應立即廢棄該合約,並重新發布修復後的合約。野指針問題是 Solidity 語言的最初設計欠缺考慮,而且 Solidity 編譯器爲了向前兼容,對這類安全問題僅採取警告提示,而開發者往往又很容易忽視這些提示,最終導致問題代碼部署上線。
下面我們通過一個蜜罐例子來解釋「未初始化的 storage 指針」這個缺陷。
蜜罐合約:別人看中的是你的本金
在計算機領域,蜜罐(Honeypot)通常指故意僞裝成看似有利用價值並故意留有 bug 的系統,用來吸引黑客攻擊,從而達到分析、監控、收集證據、拖延攻擊等目的。
而以太坊主網上存在這樣一類遊戲合約:以高額回報爲誘餌,並故意露出破綻,讓參與者誤認爲自己有很高的概率可以獲勝,誘導參與者轉入以太參與遊戲而損失本金。通常稱這類合約爲“蜜罐合約”。
“蜜罐”這個詞,其實很形象:罐子裏有可口的蜂蜜,吸引着熊去吃,但周邊其實有暗藏的陷阱,真正目的是爲了抓住熊。
“蜜罐合約”的部署者通常利用各種技巧使代碼部分特殊用途不易被參與者發現,利用當中的信息不對稱,使參與者產生錯誤判斷,從而被騙取本金。
「未初始化的 storage 指針」正是“蜜罐合約”部署者最常用的一種技巧。這個問題源於 Solidity 語言以及編譯器設計上的失誤。
我們結合下面這個名爲 Honeypot 的簡化合約說明。這是一個競猜合約,參與者調用 guess()
接口,傳入 _number
數字進行競猜,如果猜的數字等於合約中的 luckyNum
,則競猜成功,參與者可獲取兩倍回報。
聰明的你可以仔細思考一下,競猜數字 _number
應該填多少?
終極答案是 42 嗎?由於變量 luckyNum
在最開始(第 2 行)被賦值 42,並且沒有其他被賦值操作,因此絕大多數人都會猜 42。
然而這個合約極具迷惑性,42 並不是正確答案。到底哪裏出了問題?變量 luckyNum
什麼時候被修改了?
讓我們來理一理:函數 guess()
先把參與者的地址和競猜數字放入 gameHistory 數組中保存(第 12 ~ 15 行)。而數組 gameHistory
由 Game
結構體(Struct)構成。函數開始先通過 Game game
聲明瞭一個結構體變量 game(第 12 行)
,再分別對成員變量進行賦值(第 13 ~ 14 行),最後將變量 game
塞到 gameHistory
數組中(第 15 行)。
看着“似乎”沒毛病。然而,這裏有很嚴重的問題。
傳統編程語言中,我們在函數內部申明一個變量,通常默認是局部變量。但 Solidity 在語言設計上埋了個坑,在此處反直覺地默認讓引用類型(Reference Type)變量 game
(第 12 行)存儲位置爲 storage,因此對變量 game
的修改,作用範圍是“全局”的。並且對於未初始化的 storage 指針(類似傳統語言中的空指針),Solidity 默認其指向 storage 的起始地址,即指向合約開頭定義的狀態變量(第 2 ~ 3 行)。
變量 luckyNum
值不是 42,那麼到底是多少呢?
Solidity 將源碼中的狀態變量(常量除外),根據一定規則,按照出現順序依次排列存儲在 storage 中。
而 luckyNum
變量正是這個合約中第一個被定義的狀態變量,佔據了 storage 的開始位置(slot 0x00)。
game.player = msg.sender;
game.number = _number;
因此以上代碼中的賦值操作會分別更新 storage slot 0x00 ~ 0x01 上的值,即將 luckyNum
值設爲 msg.sender
,將 last
值設爲 _number
。
如果參與者猜 42,則會白白丟幣。
luckyNum
的正確答案應該是調用者自己的地址。
安比(SECBIT)實驗室發現,有很多人會利用 Solidity 語言以及編譯器的這種“特性”,再加上其他複雜的干擾條件或故意漏出的破綻,部署“蜜罐”合約欺騙其他人。在大部分案例裏,參與者根本無法獲勝,而部署者有權限將合約裏的幣全部轉走,並且通常中招者還具備不少智能合約安全常識。
再如另一個名爲 OpenAddressLottery 的彩票合約(0x741F1923974464eFd0Aa70e77800BA5d9ed18902),根據參與者的地址“隨機”生成一個 0 至 7 間的整數。合約聲稱任何人均有八分之一的概率中獎而贏走 7 倍於投注金額的以太幣,中獎條件爲生成的數等於代碼中的 LuckyNumber
[2]。
與第一個例子類似,代碼中標明瞭 LuckyNumber
值爲 7(第 11 行),並且看上去沒有其他方法可以修改該變量。目前以太坊智能合約中很難生成無法預測的隨機數(其實這是部署者故意留的破綻)。有智能合約安全知識的人可能會躍躍欲試,利用在其他智能合約中調用的方法來預測隨機數,從而獲取獎勵(不可能的,這輩子都不可能)。
注意 forceReseed()
函數中的 SeedComponents s
(第 16 行),這與前面的問題代碼如出一轍,並且該函數只有 owner
才能調用。蜜罐部署者可利用該函數中第 20 行的 s.component4 = tx.gasprice * 7
來修改 LuckyNumber
爲想要的任意值,從而使任何人都無法中獎。蜜罐部署者最終利用 selfdestruct()
將合約自毀,並把受害者轉入的以太幣轉出至自己的地址。
類似的蜜罐合約在以太坊主網上存在不少(不完全列表如下),大家牢記這個知識點,千萬別中招。
0xd1915A2bCC4B77794d64c4e483E43444193373Fa
0x650734bfd0465b7c6cd2932ea555e721308fd0b3
0x0d83102ec81853f3334Bd2b9E9fcCE7adf96ccC7
0xe6f245bb5268b16c5d79a349ec57673e477bd015
0x787b9a8978b21476abb78876f24c49c0e513065e
0xd4342df2c7cfe5938540648582c8d222f1513c50
0xe19ca313512e0231340e778abe7110401c737c23
0x6324d9d0a23f5ddba165bf8cc61da455350895f2
0xEFba96262F277cC8073dA87e564955666D30a03b
0x6a2e025f43ca4d0d3c61bdee85a8e37e81880528
問題合約 BancorLender:從蜜罐到安全漏洞
除了“蜜罐合約”,「未初始化的 storage 指針」問題還會嚴重影響智能合約代碼質量,導致合約代碼無法正常執行,甚至留下安全漏洞。
結合 BancorLender 代碼具體分析。
BancorLender 合約 offerToLend()
函數中聲明瞭一個結構體(struct)變量 BorrowAgreement agreement
。
顯然開發者原本想將 agreement
作爲局部變量使用,但未初始化的 storage 指針會指向第 1035 行定義的狀態變量 agreements
。
作爲由結構體 BorrowAgreement
構成的動態數組,agreements
變量佔據了 storage 的開始位置(slot 0x00),並按照動態數組的規則存放在 storage 上。
如果熟悉動態數組在 storage 上的排列方式 [1],則知道 slot 0x00 位置保存的是當前動態數組的大小,即 agreements
中的元素個數,而其他位置則依次保存的是數組中的實際值。
回到上面的問題代碼,在這裏,slot 0x00 被未初始化的 agreement
storage 指針所指向,因此,問題代碼中第 1051 行至 1054 行的賦值操作則會分別更新 storage slot 0x00 ~ 0x03 上的值。也就是說,slot 0x00 處原本存儲數組大小的值被設爲 msg.sender
。這完全不合情理,使得代碼邏輯十分混亂,代碼的功能完全無法正常完成,在一些情況下會造成很嚴重的後果。
那麼,這裏正確的代碼究竟該如何寫?
其實很簡單,只需給第 1050 行代碼,加上 memory
限定,即可標明 agreement
是局部變量,而不會影響到 storage 上的值。
BorrowAgreement memory agreement;
事實上,Solidity 編譯器對於這種“常見”錯誤寫法有警告,提示開發者使用關鍵字 storage 顯式標明變量,以及未初始化的 storage 指針(Uninitialized storage pointer)警告。
但是報 warning 並不會影響正常編譯,而開發者往往很容易忽略編譯器的各種警告提示(而且僅憑少量且模糊的警告信息,開發者並不知道如何正確修改代碼),繼續部署問題代碼進行使用,從而留下極大的安全風險。
Solidity 的 storage 空指針(引用)是一個設計缺陷
在傳統編程語言中(如C, C++),對空指針(Null Pointer)的訪問,通常會引起程序的報錯或崩潰。空指針的值等於零,但是語言和底層系統也同時保證內存中地址爲 0
的位置是不能存放有意義的值。而在例如 Java 或者 C# 中有 引用
的概念,但是它們都定義了一個空引用
的值,"null"
。空引用
是一個引用的安全保護值,保證這個引用不會指向任何數據。
但與傳統編程語言不同,以太坊智能合約語言 Solidity 中存在 memory 與 storage 的兩個數據存儲的概念,其中 storage 是一個外部的持久化存儲空間,位於區塊鏈上。然而,Solidity 語言卻允許定義一個指向外部存儲 storage 的指針(引用),這個引用在未初始化的情況下等於 0
,而在 storage 地址爲 0
的位置存放着有意義的數據。大家這時候可能已經感覺到哪裏不對了,在 Solidity 語言中,竟然允許存在一個沒有定義 空引用
狀態的數據引用,即一個未初始化的指針會默認指向有意義的數據,如果此時直接對「未初始化的 storage 引用」進行賦值,那麼就會錯誤覆蓋合約存儲在 storage 上面的狀態變量。如果 Solidity在設計初期考慮了 空引用
的值,或者像 C++ 那樣禁止定義 空引用
,那麼這類問題就能徹底避免。
注:在 Solidity 術語中,引用與指針兩個概念並不做區分。
新曙光:Solidity 編譯器即將改進升級
編譯器把源碼編譯成字節碼的過程中,會對源碼進行語法以及安全性檢查,並給出各類提示。其中代表有問題的提示級別有 warning 和 error。通常 warning 級別不會影響編譯結果,而 error 級別的問題會導致編譯器罷工。
追溯 Solidity 編譯器開發歷程我們發現,早期版本的 Solidity,一直把上文提到的「未初始化的 storage 指針」問題作爲 error 處理。2016 年 10 月 15 日,開發者爲了修復其他一些問題,將此處的提示級別降爲 warning [3]。
此後一直不斷有人提 issue,警告不應允許「未初始化的 storage 指針」。而開發團隊則一直迴應稱編譯器已提示 warning 信息 [4]。
並解釋之所以不提升爲 error 是爲了兼容部分特殊場景下代碼可編譯通過。
由於 Solidity 編譯器開發團隊認爲修復該問題可能會帶來兼容性問題,於是在今年 3 月份將該問題的修復放到了下一個大版本(0.5.0)。開發者需使用 pragma experimental "v0.5.0"
標記來觸發。
普通開發者很少會利用這個實驗特性,再加上普遍忽視警告信息,因此以太坊主網上一直部署着不少帶有此問題的代碼。
好消息是,安比(SECBIT)實驗室發現 Solidity 編譯器開發團隊於 20 多天前往 develop 分支合併了該問題的修復代碼,不區分是否是 0.5.0 以上的版本 [4]。也就是說上文中的所有問題代碼,不出意外在下一個版本(Solidity 0.4.25)都無法正常通過編譯。
安比(SECBIT)實驗室同步了最新編譯器代碼進行驗證。
// test1.sol
contract C {
function f() public {
uint[] storage x;
uint[10] storage y;
uint[10] z;
}
}
對於以上問題代碼,新版編譯器報錯如下:
test1.sol:3:3: Error: Uninitialized storage pointer.
uint[] storage x;
^--------------^
test1.sol:4:3: Error: Uninitialized storage pointer.
uint[10] storage y;
^----------------^
明確提示 Error: Uninitialized storage pointer
,無法通過編譯。
// test2.sol
contract C {
function f() public {
uint[] x;
}
}
而對於沒有顯示聲明變量存儲位置(storage 或 memory)的代碼,報錯如下:
test2.sol:4:3: Error: Data location must be specified as either "memory" or "storage".
uint[] x;
^------^
同樣也無法通過編譯。
Solidity 0.4.25 應該很快進入正式發佈階段。
很明顯,0.4.24 以來,Solidity 語法上新增了很多更嚴格的要求,強制要求開發者寫出更嚴謹的合約代碼。
案例帶來的提示
回顧本文中 Solidity「未初始化的 storage 指針」問題。Solidity 中函數內部聲明的引用類型變量默認存儲位置爲 storage,而未初始化的 storage 指針會指向 storage 的起始地址,從而合約開頭定義的若干個狀態變量會被覆蓋修改。
以此案例爲教訓,安比(SECBIT)實驗室有下列提示:
- 智能合約開發者需要搞清楚
storage
和memory
等關鍵詞的意義和用法,儘量顯示標明 - 智能合約開發者必須重視合約編譯過程中的每一個 warning 信息
- 編譯器作爲基礎工具,設計得當則可在一定程度上杜絕特定安全問題
- 編譯器開發和程序語言設計一定要嚴謹,從底層設計層面規避因使用者應理解偏差或使用不當帶來的風險
我們欣慰地看到,Solidity 語言正變得越來越嚴謹。有理由相信以太坊 Solidity 開發生態將迎來更大的發展。
參考文獻
- [1] 動態數組存儲講解, https://medium.com/@hayeah/diving-into-the-ethereum-vm-the-hidden-costs-of-arrays-28e119f04a9b
- [2] 蜜罐合約討論, https://www.reddit.com/r/ethdev/comments/7wp363/how_does_this_honeypot_work_it_seems_like_a/
- [3] Use warning function in TypeChecker, https://github.com/ethereum/solidity/commit/0dd75ac100d59d81321d8815638c8f252b2fe467
- [4] Uninitialised storage references should not be allowed, https://github.com/ethereum/solidity/issues/1789
- [5] Turn uninitialized storage variables into error, https://github.com/ethereum/solidity/pull/4415/files
以上數據均由安比(SECBIT)實驗室提供,合作交流請聯繫[email protected]。
安比(SECBIT)實驗室
安比(SECBIT)實驗室專注於區塊鏈與智能合約安全問題,全方位監控智能合約安全漏洞、提供專業合約安全審計服務,在智能合約安全技術上開展全方位深入研究,致力於參與共建共識、可信、有序的區塊鏈經濟體。
安比(SECBIT)實驗室創始人郭宇,中國科學技術大學博士、耶魯大學訪問學者、曾任中科大副教授。專注於形式化證明與系統軟件研究領域十餘年,具有豐富的金融安全產品研發經驗,是國內早期關注並研究比特幣與區塊鏈技術的科研人員之一。研究專長:區塊鏈技術、形式化驗證、程序語言理論、操作系統內核、計算機病毒。