警惕!Solidity缺陷易使合約狀態失控區塊鏈
。野指針問題是Solidity語言的最初設計欠缺考慮,而且Solidity編譯器為了向前兼容,對這類安全問題僅采取警告提示,而開發者往往又很容易忽視這些提示,最終導致問題代碼部署上線。
本文以蜜罐合約和 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 0×00)。
因此以上代碼中的賦值操作會分別更新 storage slot 0×00 ~ 0×01 上的值,即將 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() 將合約自毀,并把受害者轉入的以太幣轉出至自己的地址。
類似的蜜罐合約在以太坊主網上存在不少(不完全列表如下),大家牢記這個知識點,千萬別中招。
問題合約 BancorLender:從蜜罐到安全漏洞
除了“蜜罐合約”,「未初始化的 storage 指針」問題還會嚴重影響智能合約代碼質量,導致合約代碼無法正常執行,甚至留下安全漏洞。
結合 BancorLender 代碼具體分析。
BancorLender 合約 offerToLend() 函數中聲明了一個結構體(struct)變量 BorrowAgreement agreement。
顯然開發者原本想將 agreement 作為局部變量使用,但未初始化的 storage 指針會指向第 1035 行定義的狀態變量 agreements。
作為由結構體 BorrowAgreement 構成的動態數組,agreements 變量占據了 storage 的開始位置(slot 0×00),并按照動態數組的規則存放在 storage 上。
如果熟悉動態數組在 storage 上的排列方式 [1],則知道 slot 0×00 位置保存的是當前動態數組的大小,即 agreements 中的元素個數,而其他位置則依次保存的是數組中的實際值。
回到上面的問題代碼,在這里,slot 0×00 被未初始化的 agreement storage 指針所指向,因此,問題代碼中第 1051 行至 1054 行的賦值操作則會分別更新 storage slot 0×00 ~ 0×03 上的值。也就是說,slot 0×00 處原本存儲數組大小的值被設為 msg.sender。這完全不合情理,使得代碼邏輯十分混亂,代碼的功能完全無法正常完成,在一些情況下會造成很嚴重的后果。
那么,這里正確的代碼究竟該如何寫?
其實很簡單,只需給第 1050 行代碼,加上 memory 限定,即可標明 agreement 是局部變量,而不會影響到 storage 上的值。
事實上,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 術語中,引用與指針兩個概念并不做區分。
并解釋之所以不提升為 error 是為了兼容部分特殊場景下代碼可編譯通過。
由于 Solidity 編譯器開發團隊認為修復該問題可能會帶來兼容性問題,于是在今年 3 月份將該問題的修復放到了下一個大版本(0.5.0)。開發者需使用 pragma experimental "v0.5.0" 標記來觸發。
普通開發者很少會利用這個實驗特性,再加上普遍忽視警告信息,因此以太坊主網上一直部署著不少帶有此問題的代碼。
好消息是,安比(SECBIT)實驗室發現 Solidity 編譯器開發團隊于 20 多天前往 develop 分支合并了該問題的修復代碼,不區分是否是 0.5.0 以上的版本 [4]。也就是說上文中的所有問題代碼,不出意外在下一個版本(Solidity 0.4.25)都無法正常通過編譯。
安比(SECBIT)實驗室同步了最新編譯器代碼進行驗證。
對于以上問題代碼,新版編譯器報錯如下:
明確提示 Error: Uninitialized storage pointer,無法通過編譯。
而對于沒有顯示聲明變量存儲位置(storage 或 memory)的代碼,報錯如下:
同樣也無法通過編譯。
Solidity 0.4.25 應該很快進入正式發布階段。
很明顯,0.4.24 以來,Solidity 語法上新增了很多更嚴格的要求,強制要求開發者寫出更嚴謹的合約代碼。
案例帶來的提示
回顧本文中 Solidity「未初始化的 storage 指針」問題。Solidity 中函數內部聲明的引用類型變量默認存儲位置為 storage,而未初始化的 storage 指針會指向 storage 的起始地址,從而合約開頭定義的若干個狀態變量會被覆蓋修改。
以此案例為教訓,安比(SECBIT)實驗室有下列提示:
智能合約開發者需要搞清楚 storage 和 memory 等關鍵詞的意義和用法,盡量顯示標明
智能合約開發者必須重視合約編譯過程中的每一個 warning 信息
編譯器作為基礎工具,設計得當則可在一定程度上杜絕特定安全問題
編譯器開發和程序語言設計一定要嚴謹,從底層設計層面規避因使用者應理解偏差或使用不當帶來的風險
我們欣慰地看到,Solidity 語言正變得越來越嚴謹。有理由相信以太坊 Solidity 開發生態將迎來更大的發展。
1.TMT觀察網遵循行業規范,任何轉載的稿件都會明確標注作者和來源;
2.TMT觀察網的原創文章,請轉載時務必注明文章作者和"來源:TMT觀察網",不尊重原創的行為TMT觀察網或將追究責任;
3.作者投稿可能會經TMT觀察網編輯修改或補充。