警惕!Solidity缺陷易使合約狀態失控

作者:安比(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 行)。而數組 gameHistoryGame 結構體(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)實驗室有下列提示:

  1. 智能合約開發者需要搞清楚 storagememory 等關鍵詞的意義和用法,儘量顯示標明
  2. 智能合約開發者必須重視合約編譯過程中的每一個 warning 信息
  3. 編譯器作爲基礎工具,設計得當則可在一定程度上杜絕特定安全問題
  4. 編譯器開發和程序語言設計一定要嚴謹,從底層設計層面規避因使用者應理解偏差或使用不當帶來的風險

我們欣慰地看到,Solidity 語言正變得越來越嚴謹。有理由相信以太坊 Solidity 開發生態將迎來更大的發展。

參考文獻

以上數據均由安比(SECBIT)實驗室提供,合作交流請聯繫[email protected]


安比(SECBIT)實驗室

安比(SECBIT)實驗室專注於區塊鏈與智能合約安全問題,全方位監控智能合約安全漏洞、提供專業合約安全審計服務,在智能合約安全技術上開展全方位深入研究,致力於參與共建共識、可信、有序的區塊鏈經濟體。

安比(SECBIT)實驗室創始人郭宇,中國科學技術大學博士、耶魯大學訪問學者、曾任中科大副教授。專注於形式化證明與系統軟件研究領域十餘年,具有豐富的金融安全產品研發經驗,是國內早期關注並研究比特幣與區塊鏈技術的科研人員之一。研究專長:區塊鏈技術、形式化驗證、程序語言理論、操作系統內核、計算機病毒。

發佈了42 篇原創文章 · 獲贊 8 · 訪問量 1萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章