【案例示範】智慧合約安全漏洞程式碼剖析及應對(二)
這一期,我們將繼續剖析智慧合約的安全問題並提供相應的應對策略。本期主題位置如下:

(獲取完整高清圖請關注"BinSTD"公眾號回覆“技術圖譜”)

現在,我們來展示一些應對智慧合約漏洞的安全策略,並提供錯誤程式碼解析:
1.儘早且明確的暴露問題
一個簡單且強大的最佳實踐是,讓儘早且明確的暴露問題。接下來,看一個有問題的函式實現:
// 有問題的程式碼,不要使用! contract BadFailEarly { uint constant DEFAULT_SALARY = 50000; mapping(string => uint) nameToSalary; function getSalary(string name) constant returns (uint) { if (bytes(name).length != 0 && nameToSalary[name] != 0) { return nameToSalary[name]; } else { return DEFAULT_SALARY; } } }
為避免合約潛在的問題,或者讓合約運行於一個不穩定或不一致的狀態。上面例子中的函式getSalary應該在返回結果前,檢查引數。那現在的例子有什麼問題呢,問題在於,如果條件不滿足,將返回預設值。這將掩蓋引數的嚴重問題,因為仍然可以按正常業務邏輯返回值。這雖然是一個比較極端的例子,但卻非常常見,原因是大家在程式設計時,擔心程式相容性不夠,所以設定一些兜底方案。但真相是,越快失敗,越容易發現問題。如果我們不恰當的掩蓋錯誤,錯誤將擴散到程式碼的其它地方,從而引起非常難以跟蹤的不一致錯誤。下面是一個調整後的示例:
contract GoodFailEarly { mapping(string => uint) nameToSalary; function getSalary(string name) constant returns (uint) { if (bytes(name).length == 0) throw; if (nameToSalary[name] == 0) throw; return nameToSalary[name]; } }
這個版本的程式碼,還展示了另外一種推薦的編碼方式,一種將條件預檢查分開,分開判斷,驗證失敗的方式。原因是可以使用Solidity提供的修改器的特性,來實現重用。
2. 在支付時使用(pull)模式而不是(push)模式
每次ether的轉移,都需要考慮對應帳戶,潛在的程式碼執行。一個接收的合約可以實現一個預設的回退函式,這個函式可能丟擲錯誤。由此,我們永遠要考慮在send執行中的可能的錯誤。一個解決方案是,我們應該在支付時使用(pull)模式而不是(push)模式。來看一個看起來沒有問題的,關於競標函式的例子:
// 有問題的程式碼,請不要直接使用! contract BadPushPayments { address highestBidder; uint highestBid; function bid() { if (msg.value < highestBid) throw; if (highestBidder != 0) { // return bid to previous winner if (!highestBidder.send(highestBid)) { throw; } } highestBidder = msg.sender; highestBid = msg.value; } }
上述的合約,呼叫了send函式,檢查了返回值,看起來是非常符合常理的。但它在函式中呼叫了send函式,這帶來了不安全,為什麼?需要時刻記住的一點是,就像之前說的,send會觸發另外一個合約的程式碼執行。
假如某個競標的地址,它會在每次有人轉帳給他時throw。而此時,其它人嘗試追加價格競標時會發生什麼呢?那麼send呼叫將總是會失敗,從而錯誤向上拋,讓bid函式產生一個異常。一個函式呼叫如果以錯誤結束,將會讓狀態不發生變更(所有的變化都將回滾)。這將意味著,沒有人將能繼續競標,合約失效了。
最簡單的解決方案是,將支付分離到另一個函式中,讓使用者請求(pull)金額,而不依賴於餘下的合約邏輯:
contract GoodPullPayments { address highestBidder; uint highestBid; mapping(address => uint) refunds; function bid() external { if (msg.value < highestBid) throw; if (highestBidder != 0) { refunds[highestBidder] += highestBid; } highestBidder = msg.sender; highestBid = msg.value; } function withdrawBid() external { uint refund = refunds[msg.sender]; refunds[msg.sender] = 0; if (!msg.sender.send(refund)) { refunds[msg.sender] = refund; } } }
這次,我們使用一個mapping來儲存每個待退款的競標者的資訊,提供了一個withdraw用於退款。如果在send呼叫時丟擲異常,僅僅只是那個有問題的競標者受到影響。這是一個非常簡單的模式,卻解決了非常多的問題(比如,可重入)。所以,記住一點,當傳送ether時,使用(pull)模式而不是(push)模式。
我已經實現了一個使用這個模式的合約,可以方便的繼承使用。
3.函式程式碼的順序:條件,行為,互動
作為儘可能早的暴露問題的原則的一個延伸,一個好的實踐是將你的函式結構化為:首先,檢查所有前置的條件;然後,對合約的狀態進行修改;最後,與其它合約進行互動。
條件,行為,互動。堅持使用這樣的函式結構,將會讓你避免大部分的問題。下面來看使用了這個模式的一個例子:
function auctionEnd() { // 1. Conditions if (now <= auctionStart + biddingTime) throw; // auction did not yet end if (ended) throw; // this function has already been called // 2. Effects ended = true; AuctionEnded(highestBidder, highestBid); // 3. Interaction if (!beneficiary.send(highestBid)) throw; } }
這首先符合儘可能早的暴露問題的原則,因為條件在一開始就進行了檢查。它讓存在潛在互動風險的,與其它合約的互動,留到了最後。
4.留意平臺侷限性
EVM有非常多的關於合約能做的硬限制。這些是平臺級的安全考慮,如果你不知道的話,卻可以會威脅你的合約安全。下面來看一個看起來正常的,僱員津貼管理的程式碼:
// 不安全的程式碼,不要直接使用! contract BadArrayUse { address[] employees; function payBonus() { for (var i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = calculateBonus(employee); employee.send(bonus); } } function calculateBonus(address employee) returns (uint) { // some expensive computation ... } }
讀完程式碼,業務實現非常直接,看起來也沒有什麼問題,但卻潛藏三個問題,基於平臺的一些獨特性。
第一個問題是i的型別將會是uint8,因為如果要存0,如果不指定型別,將自動選擇一個佔用空間最小的,恰當的型別,在這裡將是uint8。所以如果這個陣列的大小超過255個元素,這個迴圈將永遠不會結束,最終將導致gas耗盡。應當在定義變數時,儘可能的不要使用var,明確變數的型別,下面我們來修正一下上面的例子:
// 仍然是不安全的程式碼,請不要使用! contract BadArrayUse { address[] employees; function payBonus() { for (uint i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = calculateBonus(employee); employee.send(bonus); } } function calculateBonus(address employee) returns (uint) { // some expensive computation ... } }
第二個你需要考慮的事情是gas的限制。gas是以太坊的一種機制,來對資源的使用收費。每一個修改狀態的功能呼叫都會花費gas。假如calculateBonus計算津貼時有些複雜的運算,比如需要跨多個專案計算利潤。這將消耗非常多的gas,將會很容易的達到交易和區塊的gas限制。如果一個交易達到了gas的限制,所有的狀態的改變都將會撤銷,但消耗的gas不會退回。當使用迴圈的時候,尤其要注意變數對gas消耗的影響。讓我們來優化一下上述的程式碼,將津貼計算與迴圈分開。但需要注意的是,拆開後仍然有陣列變大後,帶來的gas消耗增長的問題:
// UNSAFE CODE, DO NOT USE! contract BadArrayUse { address[] employees; mapping(address => uint) bonuses; function payBonus() { for (uint i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = bonuses[employee]; employee.send(bonus); } } function calculateBonus(address employee) returns (uint) { uint bonus = 0; // some expensive computation modifying the bonus... bonuses[employee] = bonus; } }
最後,還有一個關於呼叫棧呼叫深度的限制。EVM棧呼叫的硬限制是1024。這意味著如果巢狀呼叫的深度達到1024,合約呼叫將會失敗。一個攻擊者可以呼叫遞迴的呼叫我們的合約1023次,從而因為棧深度的限制,讓send失敗。前述的(pull)模式,可以比較好的避免這個問題(譯者注:原連結找不到了,但找下github上的討論:OpenZeppelin/openzeppelin-solidity
下面是一個最終的修改版,解決了上述的所有問題:
import './PullPaymentCapable.sol'; contract GoodArrayUse is PullPaymentCapable { address[] employees; mapping(address => uint) bonuses; function payBonus() { for (uint i = 0; i < employees.length; i++) { address employee = employees[i]; uint bonus = bonuses[employee]; asyncSend(employee, bonus); } } function calculateBonus(address employee) returns (uint) { uint bonus = 0; // some expensive computation... bonuses[employee] = bonus; } }
總結一下,需要記住的 1)使用的變數型別的限制,2)合約的gas消耗,3)棧呼叫1024的限制。
5.測試用例
編寫測試用例會佔用大量的時間,但也能抵消你在新增新功能後迴歸問題需要花費的時間。迴歸問題具體是指在新增功能的修改過程中,導致之前的元件出現bug。
我將盡快寫一個更加廣泛的關於測試的指南,如果你比較好奇,可以先看看關於Truffle的測試指南 Truffle Suite | Documentation | Truffle | Testing Your Contracts
6.容錯及自動bug獎勵
首先感謝Peter Borah帶來的這兩個想法的靈感。程式碼審查和安全稽核對保證安全來說還不足夠。我們的程式碼需要做好最壞情況的準備。當我們的智慧合約中有漏洞時,應該有一種方法可以安全的恢復。不止如此,我們也應該儘可能早的發現漏洞。下面是一個內建的自動bug獎勵機制帶來的作用。
下面我們就來看一個自動bug獎勵的假設的代幣管理的例子:
import './PullPaymentCapable.sol'; import './Token.sol'; contract Bounty is PullPaymentCapable { bool public claimed; mapping(address => address) public researchers; function() { if (claimed) throw; } function createTarget() returns(Token) { Token target = new Token(0); researchers[target] = msg.sender; return target; } function claim(Token target) { address researcher = researchers[target]; if (researcher == 0) throw; // check Token contract invariants if (target.totalSupply() == target.balance) { throw; } asyncSend(researcher, this.balance); claimed = true; } }
首先,正如前面所述,我們使用PullPaymentCapable來讓我們的支付更加安全。這個賞金合約,允許研究者建立當前我們稽核的Token合約的副本。任何人都可以參與到這個賞金專案,通過傳送交易到這個賞金專案地址。如果任何研究者可以攻破他自己的Token合約的拷貝,讓一些本不該變的情況變化(比如這裡,讓總代幣發行量與當前代幣餘額不一致),他將獲得對應的賞金。一旦賞金被領取了,合約將不再繼續接受新的資金(無名的函式被稱為合約的回退函式,在每次合約接收ether時自動執行)。
正如你看到的,它有一個非常好的特性是分離了合約,不需要對原始的Token合約進行修改。這裡有一個完整,任何人都可以使用的版本。
而對於容錯性,我們需要修改我們原來的合約來增加額外的安全機制。一種簡單的方案是允許合約的監督者可以凍結合約,作為一種緊急的機制。我們來看一個通過繼承實現這種行為的例子:
contract Stoppable { address public curator; bool public stopped; modifier stopInEmergency { if (!stopped) _ } modifier onlyInEmergency { if (stopped) _ } function Stoppable(address _curator) { if (_curator == 0) throw; curator = _curator; } function emergencyStop() external { if (msg.sender != curator) throw; stopped = true; } }
Stoppable允許指定一個監督者,可以來停止整個合約。實現方式是,通過繼承這個合約,在對應的功能上使用修改器stopInEmergency和onlyInEmergency,下面我們來看一個例子:
import './PullPaymentCapable.sol'; import './Stoppable.sol'; contract StoppableBid is Stoppable, PullPaymentCapable { address public highestBidder; uint public highestBid; function StoppableBid(address _curator) Stoppable(_curator) PullPaymentCapable() {} function bid() external stopInEmergency { if (msg.value <= highestBid) throw; if (highestBidder != 0) { asyncSend(highestBidder, highestBid); } highestBidder = msg.sender; highestBid = msg.value; } function withdraw() onlyInEmergency { suicide(curator); } }
在上面這個非常簡單的例子中,bid可以被一個監督者停止,監督者在合約建立時指定。StoppableBid在正常情況下,只有bid函式可以被呼叫,而當出現緊急情況時,監督者可以介入,並激活緊急狀態。並讓bid函式不再可用,同時啟用withdraw功能。
在上面的例子中,緊急模式將允許監督者銷燬合約,恢復資金。但在實際場景中,恢復的邏輯更為複雜(舉例來說,需要返還資金給每個投資者)。這裡有一個可停止合約的實現(譯者注:給的這個連結無法訪問了)。
7.限制可存入的資金
另一個保護我們智慧合約遠離攻擊的方式是限制。攻擊者最有可能針對管理數百萬美元的高調合同。並不是所有的合約,有這樣的高的資金量。尤其是當我們正在初期。在這種情形下,限制合約可以接收的資金量就將非常有用。最簡單的方式,可以實現為一個餘額的硬上限。
下面是一個簡單的例子:
contract LimitFunds { uint LIMIT = 5000; function() { throw; } function deposit() { if (this.balance > LIMIT) throw; ... } }
回退函式裡,會拒絕接收所有的直接支付。deposit函式會首先檢查合約的餘額是否已經超限,超限將直接丟擲異常。其它一些更有意思的,比如動態上限,管理限制也很容易實現。
8.簡單和模組化的程式碼
安全來自,我們想寫的與程式碼實際可以做的距離。這非常的難以驗證,特別是當代碼量又大,又混亂時。這就是為什麼寫簡單和模組化的程式碼變得非常重要。
這意味著,函式應該儘可能的簡單,程式碼之間的依賴應該極盡可能的少,檔案應該儘可能的小,將獨立的邏輯放進模組,每塊的職責更加單一。
命名是我們在編碼過程中表達我們意圖的方式。想一個好的名字,儘可能的讓名字清晰。
讓我們來看一個關於Event的差命名的例子。看看DAO裡的函式。其中的函式程式碼都太長了。
最大的問題是太長,而且功能複雜。儘可能的讓你的函式短小,比如,最多不超過30到40行程式碼。理想情況下,你應該在1分鐘內弄明白函式的意圖。另一個問題是關於事件Transfer在第685行的命名。這個名字與一個叫transfer的函式名只有一字之差。這將帶來誤解。一般來說,關於事件的推薦命名是使用Log打頭,這樣的話,這個事件應該命名為LogTransfer。
記住,儘可能的將你的合約寫得簡單,模組化,良好的命名。這將極大的幫助其它人和你自己審查你自己的程式碼。
9.不要從0開始寫所有的程式碼
最後,正如一句格言所說,“不要從頭髮明你自己的加密幣”。我想它也適用於智慧合約程式碼。你的操作與錢有關,你的資料是公開的,你正在一個全新的成長中的平臺上。代價非常高,糟蹋機會的人無處不在。
上述這些實踐幫助我們寫出更安全的合約。但最終,我們應該開發出更好的建立智慧合約的工具。這裡有一些先行者,包括better type systems,Serenity Abstractions 和the Rootstock platform。
現在已經有非常多的安全的程式碼,以及框架出現了。我們整合了一部分最佳實踐到Github的資源庫Open Zeppelin。歡迎看看以及貢獻新程式碼,以及提供程式碼審查建議。
10.總結一下
回顧一下,這篇文章中描述的安全模式有:
- 儘早且明確的暴露問題。
- 使用(pull)模式而不是(push)模式
- 程式碼結構遵從:條件,行為,互動
- 注意平臺限制
- 測試用例
- 容錯及自動bug獎勵
- 限制存入的資金
- 簡單與模組化的程式碼
- 不要從零開始寫程式碼
ofollow,noindex"> http:// weixin.qq.com/r/X3UXDw7 Ek1PsrUps9yBN (二維碼自動識別)