研究報告【Finding The Greedy, Prodigal, and Suicidal Contracts at Scale】)指出,目前在以太坊中,有89%的智能合約代碼都或多或少存在安全漏洞/隱患,這顯然是一個非常驚人的調查結果,對社區而言也是一個巨大的風險因素。而隨着智能合約的增多乃至未來可能的大規模發展,相信對各種合約代碼的審計也將會變成一個專門的、專業的領域,並且是不能夠、也不應該被忽視的。
本文譯自Merunas Grincalaitis(一位以太坊開發者)於2017年9月18日發表在Medium上的文章,點擊跳轉原文鏈接。本文是作者結合自己所寫的一份智能合約代碼來講述智能合約審計要點的技術文章,幷包含了對Solidity語言可能遇到的幾種危險攻擊的介紹。對於以太坊智能合約開發者而言有一定的參考和學習價值。
你有沒有考慮過如何審計一個智能合約來找出安全漏洞?
你可以自己學習,或者你可以使用這份便利的一步步的指南來準確地知道在什麼時候該做什麼,並對合約進行審計。
我已經研究過很多智能合約的審計,並且我已經找到了從任何合約中提取所有重要信息的最常規步驟。
在本文中,你將會學到以下內容:
- 生成對一個智能合約的完整審計報告所需的所有步驟。
- 作爲以太坊智能合約審計人員需要了解的最重要的攻擊類型。
- 應該在合約中尋找什麼,和一些你不會在其他任何地方找到的有用的提示。
讓我們直接開始審計合約吧:
如何審計一個智能合約
爲了教會你如何進行審計,我會審計我自己寫的一份合約。這樣,你可以看到可以由你自行完成的真實世界的審計。
現在你也許會問:智能合約的審計到底是指什麼?
智能合約審計就是仔細研究代碼的過程,在這裏就是指在把Solidity合約部署到以太坊主網絡中並使用之前發現錯誤、漏洞和風險;因爲一旦發佈,這些代碼將無法再被修改。這個定義僅僅是爲了討論目的。
請注意,審計不是驗證代碼安全的法律文件。沒有人能100%確保代碼不會在未來發生錯誤或產生漏洞。這僅僅是保證你的代碼已被專家校訂過,基本上是安全的。
討論可能的改進,主要是爲了找出那些可能會危害到用戶的以太幣的風險和漏洞。
好了,現在我們來看看一份智能合約審計報告的結構:
- 免責聲明: 在這裏你會說審計不是一個具有法律約束力的文件,它不保證任何東西。這只是一個討論性質的文件。
- 審計概覽和優良特性: 快速查看將被審計的智能合約並找到良好的實踐。
- 對合約的攻擊: 在本節中,你將討論對合約的攻擊以及會產生的結果。這只是爲了驗證它實際上是安全的。
- 合約中發現的嚴重漏洞: 可能嚴重損害合約完整性的關鍵問題。那些會允許攻擊者竊取以太幣的嚴重問題。
- 合約中發現的中等漏洞: 那些可能損害合約但危害有限的漏洞。比如一個允許人們修改隨機變量的錯誤。
- 低嚴重性的漏洞: 這些問題並不會真正損害合約,並且可能已經存在於合約的已部署版本中。
- 逐行評註: 在這部分中,你將分析那些具有潛在改進可能的最重要的語句行。
- 審計總結: 你對合約的看法和關於審計的最終結論。
將這份結構說明保存在一個安全的地方,這是你安全地審計智能合約時需要做的全部內容。它將確實地幫助你找到那些難以發現的漏洞。
我建議你從第7點“逐行評註”開始,因爲當逐行分析合約時,你會發現最重要的問題,你會看到缺少了什麼,以及哪些地方應該修改或改進。
在後文中,我會給你展示一個免責聲明,你可以把它作爲審計的第一步。你可以從第1點開始看下去,直到結束。
接下來,我將向你展示使用這樣的結構完成的審計結果,這是我針對我自己寫的一個合約來做的。你還將在第3點中看到對於智能合約可能受到的最重要的攻擊的介紹。
將這份結構說明保存在一個安全的地方,這是你安全地審計智能合約時需要做的全部內容。它將確實地幫助你找到那些難以發現的漏洞。
我建議你從第7點“逐行評註”開始,因爲當逐行分析合約時,你會發現最重要的問題,你會看到缺少了什麼,以及哪些地方應該修改或改進。
在後文中,我會給你展示一個免責聲明,你可以把它作爲審計的第一步。你可以從第1點開始看下去,直到結束。
接下來,我將向你展示使用這樣的結構完成的審計結果,這是我針對我自己寫的一個合約來做的。你還將在第3點中看到對於智能合約可能受到的最重要的攻擊的介紹。
賭場合約審計
你可以在我的Github上看到審計的代碼:https://github.com/merlox/casino-ethereum/blob/master/contracts/Casino.sol
對應的合約代碼:
pragma solidity ^0.4.11;
import "github.com/oraclize/ethereum-api/oraclizeAPI.sol";
/// @title Contract to bet Ether for a number and win randomly when the number of bets is met.
/// @author Merunas Grincalaitis
contract Casino is usingOraclize {
address owner;
// The minimum bet a user has to make to participate in the game
uint public minimumBet = 100 finney; // Equal to 0.1 ether
// The total amount of Ether bet for this current game
uint public totalBet;
// The total number of bets the users have made
uint public numberOfBets;
// The maximum amount of bets can be made for each game
uint public maxAmountOfBets = 10;
// The max amount of bets that cannot be exceeded to avoid excessive gas consumption
// when distributing the prizes and restarting the game
uint public constant LIMIT_AMOUNT_BETS = 100;
// The number that won the last game
uint public numberWinner;
// Array of players
address[] public players;
// Each number has an array of players. Associate each number with a bunch of players
mapping(uint => address[]) numberBetPlayers;
// The number that each player has bet for
mapping(address => uint) playerBetsNumber;
// Modifier to only allow the execution of functions when the bets are completed
modifier onEndGame(){
if(numberOfBets >= maxAmountOfBets) _;
}
/// @notice Constructor that's used to configure the minimum bet per game and the max amount of bets
/// @param _minimumBet The minimum bet that each user has to make in order to participate in the game
/// @param _maxAmountOfBets The max amount of bets that are required for each game
function Casino(uint _minimumBet, uint _maxAmountOfBets){
owner = msg.sender;
if(_minimumBet > 0) minimumBet = _minimumBet;
if(_maxAmountOfBets > 0 && _maxAmountOfBets <= LIMIT_AMOUNT_BETS)
maxAmountOfBets = _maxAmountOfBets;
// Set the proof of oraclize in order to make secure random number generations
oraclize_setProof(proofType_Ledger);
}
/// @notice Check if a player exists in the current game
/// @param player The address of the player to check
/// @return bool Returns true is it exists or false if it doesn't
function checkPlayerExists(address player) returns(bool){
if(playerBetsNumber[player] > 0)
return true;
else
return false;
}
/// @notice To bet for a number by sending Ether
/// @param numberToBet The number that the player wants to bet for. Must be between 1 and 10 both inclusive
function bet(uint numberToBet) payable{
// Check that the max amount of bets hasn't been met yet
assert(numberOfBets < maxAmountOfBets);
// Check that the player doesn't exists
assert(checkPlayerExists(msg.sender) == false);
// Check that the number to bet is within the range
assert(numberToBet >= 1 && numberToBet <= 10);
// Check that the amount paid is bigger or equal the minimum bet
assert(msg.value >= minimumBet);
// Set the number bet for that player
playerBetsNumber[msg.sender] = numberToBet;
// The player msg.sender has bet for that number
numberBetPlayers[numberToBet].push(msg.sender);
numberOfBets += 1;
totalBet += msg.value;
if(numberOfBets >= maxAmountOfBets) generateNumberWinner();
}
/// @notice Generates a random number between 1 and 10 both inclusive.
/// Must be payable because oraclize needs gas to generate a random number.
/// Can only be executed when the game ends.
function generateNumberWinner() payable onEndGame {
uint numberRandomBytes = 7;
uint delay = 0;
uint callbackGas = 200000;
bytes32 queryId = oraclize_newRandomDSQuery(delay, numberRandomBytes, callbackGas);
}
/// @notice Callback function that gets called by oraclize when the random number is generated
/// @param _queryId The query id that was generated to proofVerify
/// @param _result String that contains the number generated
/// @param _proof A string with a proof code to verify the authenticity of the number generation
function __callback(
bytes32 _queryId,
string _result,
bytes _proof
) oraclize_randomDS_proofVerify(_queryId, _result, _proof) onEndGame {
// Checks that the sender of this callback was in fact oraclize
assert(msg.sender == oraclize_cbAddress());
numberWinner = (uint(sha3(_result))%10+1);
distributePrizes();
}
/// @notice Sends the corresponding Ether to each winner then deletes all the
/// players for the next game and resets the `totalBet` and `numberOfBets`
function distributePrizes() onEndGame {
uint winnerEtherAmount = totalBet / numberBetPlayers[numberWinner].length; // How much each winner gets
// Loop through all the winners to send the corresponding prize for each one
for(uint i = 0; i < numberBetPlayers[numberWinner].length; i++){
numberBetPlayers[numberWinner][i].transfer(winnerEtherAmount);
}
// Delete all the players for each number
for(uint j = 1; j <= 10; j++){
numberBetPlayers[j].length = 0;
}
totalBet = 0;
numberOfBets = 0;
}
}
以下就是我的合約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
類型的變量值超出上限2256(即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)
重放攻擊是指在像以太坊這樣的區塊鏈上發起一筆交易,而後在像以太坊經典這樣的另一個鏈上重複這筆交易的攻擊。(就是說在主鏈上創建一個交易之後,在分岔鏈上重複同樣的交易。譯者注。)
以太幣會像普通的交易那樣,從一個鏈轉移到另一個鏈。
基於由Vitalik Buterin提出的EIP 155【https://github.com/ethereum/EIPs/blob/master/EIPS/eip-155.md】,從Geth的1.5.3版本和Parity的1.4.4版本開始,已經增加了對這個攻擊的防護。
譯者注:
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代幣合約,所以這種攻擊並不能適用。
你可以參考這篇文章【http://vessenes.com/the-erc20-short-address-attack-explained/】來獲得更多關於這種攻擊的信息。
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
的使用上的最佳實踐。
這是一個安全的合約,可以在其運行期間保證資金安全。
結論
以上就是我使用我在開篇介紹過的結構所進行的審計。希望你確實學到了一些東西並且可以對其他智能合約進行安全審計了。
請繼續學習合約安全知識、編碼最佳實踐以及其他實用知識,並努力提高。
轉自:https://www.jianshu.com/p/30931a210aad