1. 程式人生 > >智慧合約安全審計指南

智慧合約安全審計指南

譯者注:
智慧合約程式碼的審計,目前還不是技術社群內經常會討論的主題。今年3月6日,發表在部落格網站【Schneier on Security】上的一篇部落格(原文連結:【https://www.schneier.com/blog/archives/2018/03/security_vulner_13.html】,原文中附有一篇專業的研究報告【Finding The Greedy, Prodigal, and Suicidal Contracts at Scale】)指出,目前在以太坊中,有89%的智慧合約程式碼都或多或少存在安全漏洞/隱患,這顯然是一個非常驚人的調查結果,對社群而言也是一個巨大的風險因素。而隨著智慧合約的增多乃至未來可能的大規模發展,相信對各種合約程式碼的審計也將會變成一個專門的、專業的領域,並且是不能夠、也不應該被忽視的。
本文是作者結合自己所寫的一份智慧合約程式碼來講述智慧合約審計要點的技術文章,幷包含了對Solidity語言可能遇到的幾種危險攻擊的介紹。對於以太坊智慧合約開發者而言有一定的參考和學習價值。

你有沒有考慮過如何審計一個智慧合約來找出安全漏洞?

你可以自己學習,或者你可以使用這份便利的一步步的指南來準確地知道在什麼時候該做什麼,並對合約進行審計。

我已經研究過很多智慧合約的審計,並且我已經找到了從任何合約中提取所有重要資訊的最常規步驟。

在本文中,你將會學到以下內容:

  • 生成對一個智慧合約的完整審計報告所需的所有步驟。
  • 作為以太坊智慧合約審計人員需要了解的最重要的攻擊型別。
  • 應該在合約中尋找什麼,和一些你不會在其他任何地方找到的有用的提示。

讓我們直接開始審計合約吧:

如何審計一個智慧合約

為了教會你如何進行審計,我會審計我自己寫的一份合約。這樣,你可以看到可以由你自行完成的真實世界的審計。

現在你也許會問:智慧合約的審計到底是指什麼?

智慧合約審計就是仔細研究程式碼的過程,在這裡就是指在把 Solidity 合約部署到以太坊主網路中並使用之前發現錯誤、漏洞和風險;因為一旦釋出,這些程式碼將無法再被修改。這個定義僅僅是為了討論目的。

請注意,審計不是驗證程式碼安全的法律檔案。沒有人能100%確保程式碼不會在未來發生錯誤或產生漏洞。這僅僅是保證你的程式碼已被專家校訂過,基本上是安全的。

討論可能的改進,主要是為了找出那些可能會危害到使用者的以太幣的風險和漏洞。

好了,現在我們來看看一份智慧合約審計報告的結構:

  1. 免責宣告: 在這裡你會說審計不是一個具有法律約束力的檔案,它不保證任何東西。這只是一個討論性質的檔案。
  2. 審計概覽和優良特性: 快速檢視將被審計的智慧合約並找到良好的實踐。
  3. 對合約的攻擊: 在本節中,你將討論對合約的攻擊以及會產生的結果。這只是為了驗證它實際上是安全的。
  4. 合約中發現的嚴重漏洞: 可能嚴重損害合約完整性的關鍵問題。那些會允許攻擊者竊取以太幣的嚴重問題。
  5. 合約中發現的中等漏洞: 那些可能損害合約但危害有限的漏洞。比如一個允許人們修改隨機變數的錯誤。
  6. 低嚴重性的漏洞: 這些問題並不會真正損害合約,並且可能已經存在於合約的已部署版本中。
  7. 逐行評註: 在這部分中,你將分析那些具有潛在改進可能的最重要的語句行。
  8. 審計總結: 你對合約的看法和關於審計的最終結論。

將這份結構說明儲存在一個安全的地方,這是你安全地審計智慧合約時需要做的全部內容。它將確實地幫助你找到那些難以發現的漏洞。

我建議你從第7點“逐行評註”開始,因為當逐行分析合約時,你會發現最重要的問題,你會看到缺少了什麼,以及哪些地方應該修改或改進。

在後文中,我會給你展示一個免責宣告,你可以把它作為審計的第一步。你可以從第1點開始看下去,直到結束。

接下來,我將向你展示使用這樣的結構完成的審計結果,這是我針對我自己寫的一個合約來做的。你還將在第3點中看到對於智慧合約可能受到的最重要的攻擊的介紹。

賭場合約審計

以下就是我的合約Casino.sol的審計報告:

序言

在這份智慧合約審計報告中將包含以下內容:

1. 免責宣告

審計不會對程式碼的實用性、程式碼的安全性、商業模式的適用性、商業模式的監管制度或任何其他有關合約適用性的說明以及合約在無錯狀態的行為作出宣告或擔保。審計文件僅用於討論目的。

2. 審計概覽和優良特性

該專案只有一個包含142行Solidity程式碼的檔案 Casino.sol 。所有的函式和狀態變數的註釋都按照標準說明格式(譯者注:即Ethereum Nature Specification Format,縮寫為natspec,它是以太坊社群官方的程式碼註釋格式說明,原文參考github:【https://github.com/ethereum/wiki/wiki/Ethereum-Natural-Specification-Format】)進行編寫,這可以幫助我們快速地理解程式是如何工作。

該專案使用了一箇中心化的服務實現了Oraclize API,來在區塊鏈上生成真正的隨機數字。

譯者注:
Oraclize是一種為智慧合約和區塊鏈應用提供資料的獨立服務,官網:【http://www.oraclize.it】。因為類似於比特幣指令碼或者以太坊智慧合約這樣的區塊鏈應用無法直接獲取鏈外的資料,所以就需要一種可以提供鏈外資料並可以與區塊鏈進行資料互動的服務。Oraclize可以提供類似於資產/財務應用程式中的價格資訊、可用於點對點保險的天氣資訊或者對賭合約所需要的隨機數資訊。
這裡是指在這個專案的原始碼中引入了一個實現了Oraclize API的開源的Solidity程式碼庫。

在區塊鏈上生成隨機數字是一個相當困難的課題,因為以太坊的核心價值之一就是可預測性,其目標是確保沒有未定義的值。

譯者注:
這裡之所以說在區塊鏈上生成隨機數很困難,是因為,無論採用何種演算法,都需要使用時間戳作為生成隨機數的“種子”(因為時間戳是計算機領域內唯一可以理論上保證“不會重複”的數值);而在智慧合約中取得時間戳只能依賴某個節點(礦工)來做到。這就是說,合約中取得的時間戳是由執行其程式碼的節點(礦工)的計算機本地時間決定的;所以這個節點(礦工)的可信度就成了最大的問題。理論上,這個本地時間是可以由惡意程式偽造的,所以這種方法被認為是“不安全的”。通行的做法是採用一個鏈外(off-chain)的第三方服務,比如這裡使用的Oraclize,來獲取隨機數。因為Oraclize是一種公共基礎服務,不會針對特定的合約“作假”,所以這可以認為是“相對安全的”。

因為使用Oraclize可以在鏈外生成隨機數字,所以使用它來產生可信的數字被認為是一種很好的做法。 它實現了修飾符和一個回撥函式,用於驗證資訊是否來自可信實體。

此智慧合約的目的是參與隨機抽獎,人們在1到9之間下注。當有10個人下注時,獎金會自動分配給贏家。每個使用者都有一個最低下注金額。

每個玩家在每局遊戲中只能下一次注,並且只有在參與者數量達到要求時才會產生贏家號碼。

優秀特性

這個合約提供了一系列很好的功能性程式碼:

  • 使用 Oraclize 生成安全的隨機數並在回撥中進行驗證。
  • 修改器檢查遊戲結束條件,阻止關鍵功能,直到獎勵得以分配。
  • 做了較多的檢查來驗證bet函式的使用是合適的。
  • 只有在下注數達到最大條件時才安全地生成贏家號碼。

3. 對合約的攻擊

為了檢查合約的安全性,我們測試了多種攻擊,以確保合約是安全的並遵循了最佳實踐。

重入攻擊(Reentrancy attack)

此攻擊通過遞迴地呼叫 ERC20代幣中的 call.value() 方法來提取合約中的以太幣,如果使用者在傳送以太幣之後才更新發送者的 balance (即賬戶餘額,譯者注)的話,攻擊就會生效。

當你呼叫一個函式將以太幣傳送給合約時,你可以使用 fallback 函式再次執行該函式,直到以太幣被從合約中提取出來。

由於該合約使用了 transfer() 而不是 call.value() ,因此不存在重入攻擊的風險;因為 transfer 函式只允許使用2300 gas,這隻夠用來產生事件日誌資料並在失敗時丟擲異常。這樣就無法遞迴呼叫傳送者函式,從而避免了重入攻擊。

因為 transfer 函式只會在每局遊戲結束,向贏家分發獎勵時才會被呼叫一次,所以重入式攻擊在這裡不會導致任何問題。

請注意,呼叫此函式的條件是投注次數大於或等於10次,但這個投注次數只有在 distributePrizes() 函式結束時才會被重置為0,這是有風險的;因為理論上是可以在投注次數被清零之前呼叫該函式並執行所有邏輯的。

所以我的建議是在函式開始時就更新條件、將投注次數設定為0,以確保 distributePrizes()在被超出預期地多次呼叫時不會產生實際效果。

數值溢位(Over and under flows)

當一個 uint256 型別的變數值超出上限 2^256(即2的256次方,譯者注)時會發生溢位。其結果是變數值變為0,而不是更大。

例如,如果你想把一個 unit 型別的變數賦予大於2^256的值,它會簡單地變為0,這是危險的。

另一方面,當你從 0 值中減去一個大於 0 的數字時,則會發生下溢位(underflow)。例如,如果你用 0 減去1,結果將是2^256,而不是 -1。

在處理以太幣的時候,這非常危險;然而在這個合約中並不存在減法操作,所以也不會有下溢位的風險。

唯一可能發生溢位的情況是當你呼叫 bet() 向某個數字下注時, totalBet 變數的值會相應增加:

totalBet += msg.value;

有人可能會發送大量的以太幣而導致累加結果超過2**256,這會使totalBet變為0。這當然是不大可能發生的,但風險是有的。

所以我推薦使用類似於 OpenZeppelin’s SafeMath.sol 這樣的庫。它可以使你的計算處理更安全,免去發生溢位(overflow或者underflow)的風險。

可以將其匯入來使用,對uint256型別啟用它,然後使用 .mul() 、 .add() 、 .sub() 和 .div() 這些函式。例如:

import './SafeMath.sol';
contract Casino {
    using SafeMath for uint256;
    function example(uint256 _value) {
        uint number = msg.value.add(_value);
    }
}

重放攻擊(Replay attack)

重放攻擊是指在像以太坊這樣的區塊鏈上發起一筆交易,而後在像以太坊經典這樣的另一個鏈上重複這筆交易的攻擊。(就是說在主鏈上建立一個交易之後,在分岔鏈上重複同樣的交易。譯者注。)

以太幣會像普通的交易那樣,從一個鏈轉移到另一個鏈。

譯者注:
EIP,即Ethereum Improvement Proposal(以太坊改進建議),官方地址【https://github.com/ethereum/EIPs】是由以太坊社群所共同維護的以太坊平臺標準規範文件,涵蓋了基礎協議規格說明、客戶端API以及合約標準規範等等內容。

所以使用合約的使用者們需要自己升級客戶端程式來保證針對這個攻擊的安全性。

重排攻擊(Reordering attack)

這種攻擊是指礦工或其他方試圖通過將自己的資訊插入列表(list)或對映(mapping)中來與智慧合約參與者進行“競爭”,從而使攻擊者有機會將自己的資訊儲存到合約中。

當一個使用者使用 bet() 函式下注以後,因為實際的資料是儲存在鏈上的,所以任何人都可以簡單地通過呼叫公有狀態變數 playerBetsNumber 這個mapping看到所下注的數字。

這個 mapping 是用來表示每個人所選擇的數字的,所以,結合交易資料,你就可以很容易地看到他們各自下注了多少以太幣。這可能會發生在 distributePrizes() 函式中,因為它是在隨機數生成處理的回撥中被呼叫的。

因為這個函式起作用的條件在其結束之前才會被重置,所以這就有了重排攻擊(reordering attack)的風險。

因此,我的建議就像我之前談的那樣:在 distributePrizes() 函式開始時就重置下注人數來避免其產生非預期的行為。

短地址攻擊(Short address attack)

這種攻擊是由 Golem 團隊發現的針對 ERC20 代幣的攻擊:

  • 一個使用者建立一個空錢包,這並不難,它只是一串字元,例如:【0xiofa8d97756as7df5sd8f75g8675ds8gsdg0】
  • 然後他使用把地址中的最後一個0去掉的地址來購買代幣:也就是用【0xiofa8d97756as7df5sd8f75g8675ds8gsdg】作為收款地址來購買1000代幣。
  • 如果代幣合約中有足夠的餘額,且購買代幣的函式沒有檢查傳送者地址的長度,以太坊虛擬機器會在交易資料中補0,直到資料包長度滿足要求
  • 以太坊虛擬機器會為每個 1000 代幣的購買返回 256000 代幣。這是一個虛擬機器的bug,並且仍未被修復。所以如果你是一個代幣合約的開發者,請確保對地址長度進行了檢查。

但我們這個合約因為並不是 ERC20 代幣合約,所以這種攻擊並不能適用。

4. 合約中發現的嚴重漏洞

審計中並未發現嚴重漏洞。

5. 合約中發現的中等漏洞

checkPlayerExists() 應該是一個常態(constant)函式,然而實際上它並不是。因此這增加了呼叫這個函式的 gas 消耗,當有大量對此函式的呼叫發生時會產生很大的問題。

應該把它改為常態函式來避免昂貴的消耗gas的執行。

譯者注:
Solidity 語言中的常態(constant)函式,指的是在執行時不會改變合約狀態的函式,也就是不會改變合約級別的狀態變數(state variable)的值的函式。因為狀態變數的更改是會儲存到鏈上的,所以對狀態變數的更改都要消耗 gas(來支付給礦工),這是非常昂貴的。在本例中,因為 checkPlayerExists() 函式中訪問了狀態變數 playerBetsNumber 來判斷是否已經有人下過注了,雖然這是個合約級別的變數,但這個函式並沒有改變它的值,所以這個函式應該宣告為 constant 以節省其對gas的消耗。

6. 低嚴重性的漏洞

  • 你在 __callback() 函式和 pay() 函式的開始位置使用了 assert() 而不是 require()

assert() 和 require() 大體上是相同的,但 assert 函式一般用來在更改合約狀態之後做校驗,而require通常在函式的開頭用做輸入引數的檢查。

  • 你定義了一個合約級別的變數players,但沒有任何地方使用它。如果你不打算使用它,就把它刪除。

7. 逐行評註

  • 第1行:你在版本雜注(pragma version)中使用了脫字元號(^)來指定使用高於 0.4.11版本的編譯器。

這不是一個好實踐。因為大版本的變化可能會使你的程式碼不穩定,所以我推薦使用一個固定的版本,比如‘0.4.11’。

  • 第14行:你定義了一個 uint 型別的變數 totalBet ,這個變數名是不合適的,因為它儲存的是所有下注的合計值。我推薦使用 totalBets 作為變數名,而不是 totalBet 。
  • 第24行:你用大寫字母定義了一個常量(constant variable),這是一個好實踐,可以使人知道這是個固定的、不可變的變數。
  • 第30行:就像我之前提到的,你定義了一個未使用的陣列 player 。如果你不打算使用它,就把它刪除。
  • 第60行:函式 checkPlayerExists() 應該被宣告為 constant 。因為它並沒有更改合約狀態,把它宣告為 constant 可以節省下每次執行它所要消耗的gas。

即使函式預設是public型別,但顯式地給函式指定型別仍然是一個好實踐,它可以避免任何困惑。這裡可以在這個函式宣告的末尾確切地加上public宣告。

  • 第61行:你沒有檢查輸入引數 player 被正常傳入且格式正確。請確保在函式開頭使用 require(player != address(0)); 語句來檢查傳入地址是否為0。為了以防萬一,最好也要檢查地址的長度是否符合要求來應對短地址攻擊。
  • 第69行:同樣建議給 bet() 函式加上可見度(visibilty)關鍵字 public 來避免任何困惑,以明確應該如何使用此函式。
  • 第72行:使用 require() 來檢查函式輸入引數,而不是 assert() 。

同樣的,在函式開頭,一般更經常使用 require() 。請把所有在函式開頭使用的 assert() 改為 require() 。

  • 第90行:你使用了一個對 msg.value 的簡單合計,在value值很大時這會導致溢位。所以我建議你每次對數值進行運算時都要檢查是否會溢位。
  • 第98行: generateNumberWinner() 應該是 internal 函式,因為你肯定不希望任何人都可以從合約以外執行它。

譯者注:
在 Solidity 語言中, internal 關鍵字的效果,與面嚮物件語言比如 C++、Java中的protected 型別基本一致,此關鍵字限定的函式或者狀態變數,僅在當前合約及當前合約的子合約(contacts deriving from this contract)中可以訪問。 private 關鍵字則與其他語言中的此關鍵字相同,由其限定的函式或者狀態變數僅在當前合約中可以訪問。

  • 第103行:你把 oraclize_newRandomDSQuery() 函式的結果儲存在了一個bytes32型別的變數中。呼叫callback函式並不需要這麼做,而且你也沒有在其他地方再用到這個變數,所以我建議不要用變數儲存這個函式的返回值。
  • 第110行: __callback() 函式應該宣告為 external ,因為你只希望它從外部被呼叫。

譯者注:
在Solidity中,函式關鍵字 public 和 external 在gas的消耗上是有區別的。因為 public 的函式既可以在合約外呼叫,又可以在合約內呼叫,所以虛擬機器會在執行時為其分配記憶體,拷貝其所用到的所有變數。而 external 的函式只允許從合約外部進行呼叫,其呼叫會直接從calldata(即函式呼叫的二進位制位元組碼資料)中獲取引數,虛擬機器不會為其分配記憶體並拷貝變數值,所以其gas消耗比 public 的函式要低很多。

  • 第117行:這裡的 assert() 應該使用 require() ,就像我先前解釋的那樣。
  • 第119行:你使用了 sha3() 函式,但這並不是一個好的實踐。實際的演算法使用的是keccak256,並不是sha3。所以我建議這裡更明確地改為使用 keccak256() 。
  • 第125行: distributePrizes() 函式應該被宣告為 internal 。

譯者注:
此函式與第98行的 generateNumberWinner() 函式一樣,宣告為 internal 或者 private 都是可以的。區別僅在於你希不希望子合約中可以使用它們。

  • 第129行:儘管你在這裡用了一個變長陣列的大小來控制迴圈次數,但其實也沒有多糟糕,因為獲勝者的數量被限制為小於100。

8. 審計總結

總體上講,這個合約的程式碼有很好的註釋,清晰地解釋了每個函式的目的。

下注和分發獎勵的機制非常簡單,不會帶來什麼大問題。

我最終的建議是需要更加註意函式的可見性宣告,因為這對於明確函式應該供誰來執行的問題非常重要。然後就是需要在編碼中考慮 assert 、 require 和 keccak 的使用上的最佳實踐。

這是一個安全的合約,可以在其執行期間保證資金安全。

結論

以上就是我使用我在開篇介紹過的結構所進行的審計。希望你確實學到了一些東西並且可以對其他智慧合約進行安全審計了。

請繼續學習合約安全知識、編碼最佳實踐以及其他實用知識,並努力提高。