Ethereum-智能合約最佳實踐之智能合約安全技術

本文翻譯自:https://github.com/ConsenSys/smart-contract-best-practices
為了使語句表達更加貼切,個別地方未按照原文逐字逐句翻譯,如有出入請以原文為準。

主要章節如下

這篇文檔旨在為Solidity開發人員提供一些智能合約的security準則。當然也包括智能合約的security理念、bug賞金計劃指南、文檔例程以及工具。

一般哲學

以太坊和其他復雜的區塊鏈項目都處于早期階段并且有很強的實驗性質。因此,隨著新的bug和安全漏洞被發現,新的功能不斷被開發出來,其面臨的安全威脅也是不斷變化的。這篇文章對于開發人員編寫安全的智能合約來說只是個開始。

開發智能合約需要一個全新的工程思維,它不同于我們以往項目的開發。因為它犯錯的代價是巨大的,并且很難像傳統軟件那樣輕易的打上補丁。就像直接給硬件編程或金融服務類軟件開發,相比于web開發和移動開發都有更大的挑戰。因此,僅僅防范已知的漏洞是不夠的,你還需要學習新的開發理念:

  • 對可能的錯誤有所準備。任何有意義的智能合約或多或少都存在錯誤。因此你的代碼必須能夠正確的處理出現的bug和漏洞。始終保證以下規則:

    • 當智能合約出現錯誤時使智能合約掛起(“斷路開關”)
    • 防范可能的資金風險(限制(轉賬)速率、最大(轉賬)額度)
    • 有效的途徑來進行bug修復和功能提升
  • 謹慎發布智能合約。 盡量在正式發布智能合約之前修復bug。

    • 對智能合約進行徹底的測試,并在任何新的攻擊手法被發現后及時的測試
    • 從發布到alpha testnet開始便提供bug賞金計劃
    • 階段性發布,每個階段都提供足夠的測試
  • 保持智能合約的簡潔。復雜只會增加出錯的風險。

    • 確保智能合約邏輯簡潔
    • 確保合約和函數模塊化
    • 使用已經被廣泛使用的合約或工具(比如,不要自己寫一個隨機數生成器)
    • 條件允許的話,清晰明了比性能更重要
    • 只在你系統的去中心化部分使用區塊鏈
  • 保持更新。通過下一章節所列出的資源來確保獲取到最新的安全進展。

    • 在任何新的漏洞被發現時檢查你的智能合約
    • 盡可能快的將使用到的庫或者工具更新到最新
    • 使用最新的安全技術
  • 清楚區塊鏈的特性。盡管你先前所擁有的編程經驗同樣適用于以太坊開發,但這里仍然有些陷阱你需要留意:

    • 特別小心針對外部合約的調用,因為你可能執行的是一段惡意代碼然后更改控制流程
    • 清楚你的public function是公開的,意味著可以被惡意調用。(在以太坊上)你的private data也是對他人可見的
    • 清楚gas的花費和區塊的gas limit

基本權衡:簡單性與復雜性

在評估一個智能合約的架構和安全性時有很多需要權衡的地方。對任何智能合約的建議是在各個權衡點中找到一個平衡點。

從軟件工程的角度出發一個理想的智能合約首先需要模塊化,能夠重用代碼而不是重復編寫,并且支持組件升級。從安全架構的角度出發一個理想的智能合約需要盡可能做到共享,特別是在智能合約這種復雜的應用場景。

然而,這里有幾個重要的例外,它們在安全和軟件工程最佳實踐里可能沒有被衡量。當中每一條,都需要針對智能合約系統的特點找到最優的組合方式來達到平衡。

  • 固化 vs 可升級
  • 龐大 vs 模塊化
  • 重復 vs 可重用

固化 vs 可升級

在多個場景中,包括這個,都會強調延展性比如:可終止,可升級或可更改的特性,以上在延展性和安全之間這是個基本權衡

延展性和復雜性會增加潛在的攻擊面。在智能合約這種只在特定的時間段內提供有限的功能的使用場景下,簡單性比復雜性顯得更加高效,比如governance-fee、finite-time-frame、token-sale的智能合約系統。

龐大 vs 模塊化

一個龐大的獨立的智能合約把所有的可識別和可讀取的信息都放到本地。盡管很少有智能合約真的做到了大體量,但在是否應該將數據和流程都放到本地這個問題上仍然存在爭議--比如,優化代碼review效率。

和在這里討論的其他權衡點一樣,智能合約的最佳安全實踐從軟件工程中提倡簡單短生命周期轉移到了提倡更加復雜的永久性。

重復 vs 可重用

從軟件工程角度看,智能合約系統希望在合理的情況下最大程度地實現重用。 在Solidity中重用合約代碼有很多方法。 使用你擁有的以前部署的經過驗證的智能合約是實現代碼重用的最安全的方式。

在以前所擁有已部署智能合約不可用時重復還是很需要的。 現在Live LibsZeppelin Solidity 正尋求提供安全的智能合約組件使其能夠被重用而不需要每次都重新編寫。任何合約安全性分析都必須包括以前沒有建立與目標智能合同系統中處于風險中的資金相稱的信任級別的重用代碼。

安全通知

以下這些地方通常會通報在Ethereum或Solidity中新發現的漏洞。安全通告的官方來源是Ethereum Blog,但是一般漏洞都會在其他地方先被披露和討論。

強烈建議你經常瀏覽這些網站,尤其是他們提到的可能會影響你的智能合約的漏洞。

另外, 這里列出了以太坊參與安全模塊相關的核心開發成員, 瀏覽 bibliography 獲取更多信息。

除了關注核心開發成員,參與到各個區塊鏈安全社區更加重要,因為安全漏洞的披露或研究將通過各方進行。

關于智能合約安全的建議

外部調用

盡量避免外部調用

調用不受信任的外部合約可能會引發一系列意外的風險和錯誤。外部調用可能在其合約和它所依賴的其他合約內執行惡意代碼。因此,每一個外部調用都會有潛在的安全威脅,盡可能的從你的智能合約內移除外部調用。當無法完全去除外部調用時,可以使用這一章節其他部分提供的建議來盡量減少風險。

仔細權衡“send()”、“transfer()”、以及“call.value()”

當發送Ether時,需要仔細權衡“someAddress.send()”、“someAddress.transfer()”、和“someAddress.call.value()()”之間的差別。

  • x.transfer(y)if (!x.send(y)) throw;是等價的。send是transfer的底層實現,建議盡可能直接使用transfer。
  • someAddress.send()someAddress.transfer() 能保證可重入 安全
    盡管這些智能合約方法可以被觸發執行,但補貼給智能合約的2,300 gas僅僅只夠捕獲一個event。
  • someAddress.call.value()() 將會發送指定數量的Ether并且觸發對應代碼的執行。執行的代碼被給予了賬戶所有可用的gas,通過這種方式發起的交易對于可重入來說是 不安全的。

使用send()transfer() 可以通過制定gas值來預防可重入, 但是這樣做可能會導致在和合約調用fallback函數時出現問題,由于gas可能不足,而合約的fallback函數執行至少需要2,300 gas消耗。(譯者注:原文中描述的并不詳細,我覺得這里把其中的原理說出來要好些

一種被稱為pushpull的 機制試圖來平衡兩者, 在 push 部分使用send()transfer(),在pull 部分使用call.value()()。(譯者注:在需要對以太坊執行write操作時使用send()transfer()read操作使用call.value()()

需要注意的是使用send()transfer() 進行轉賬并不能保證該智能合約本身重入安全,它僅僅只保證了這次轉賬操作時重入安全的。

處理外部調用錯誤

Solidity提供了一系列在raw address上執行操作的底層方法,比如: address.call()address.callcode()address.delegatecall()address.send。這些底層方法不會拋出異常,只是會在遇到錯誤時返回false。另一方面, contract calls (比如,ExternalContract.doSomething()))會自動傳遞異常,(比如,doSomething()拋出異常,那么ExternalContract.doSomething() 同樣會進行throw) )。

如果你選擇使用底層方法,一定要檢查返回值來對可能的錯誤進行處理。

// bad
someAddress.send(55);
someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesn't check for result
someAddress.call.value(100)(bytes4(sha3("deposit()"))); // if deposit throws an exception, the raw call() will only return false and transaction will NOT be reverted

// good
if(!someAddress.send(55)) {
    // Some failure code
}

ExternalContract(someAddress).deposit.value(100);

不要假設你知道外部調用的控制流程

無論是使用raw calls 或是contract calls,如果這個ExternalContract是不受信任的都應該假設存在惡意代碼。即使ExternalContract不包含惡意代碼,但它所調用的其他合約代碼可能會包含惡意代碼。一個具體的危險例子便是惡意代碼可能會劫持控制流程導致競態。(瀏覽Race Conditions獲取更多關于這個問題的討論)

對于外部合約優先使用pull 而不是push

外部調用可能會有意或無意的失敗。為了最小化這些外部調用失敗帶來的損失,通常好的做法是將外部調用隔離到其內部的交易中,調用發起方只負責初始化外部調用。這種做法對付款操作尤為重要,比如讓用戶自己撤回資產而不是直接發送給他們。(譯者注:事先設置需要付給某一方的資產的值,表明接收方可以從當前賬戶撤回資金的額度,然后由接收方調用當前合約提現函數完成轉賬)。(這種方法同時也避免了造成 gas limit相關問題。)

// bad
contract auction {
    address highestBidder;
    uint highestBid;

    function bid() payable {
        if (msg.value < highestBid) throw;

        if (highestBidder != 0) {
            if (!highestBidder.send(highestBid)) { // if this call consistently fails, no one else can bid
                throw;
            }
        }

       highestBidder = msg.sender;
       highestBid = msg.value;
    }
}

// good
contract auction {
    address highestBidder;
    uint highestBid;
    mapping(address => uint) refunds;

    function bid() payable external {
        if (msg.value < highestBid) throw;

        if (highestBidder != 0) {
            refunds[highestBidder] += highestBid; // record the refund that this user can claim
        }

        highestBidder = msg.sender;
        highestBid = msg.value;
    }

    function withdrawRefund() external {
        uint refund = refunds[msg.sender];
        refunds[msg.sender] = 0;
        if (!msg.sender.send(refund)) {
            refunds[msg.sender] = refund; // reverting state because send failed
        }
    }
}

標記不受信任的合約

當你自己的函數調用外部合約時,你的變量、方法、合約接口命名應該表明和他們可能是不安全的。

// bad
Bank.withdraw(100); // Unclear whether trusted or untrusted

function makeWithdrawal(uint amount) { // Isn't clear that this function is potentially unsafe
    Bank.withdraw(amount);
}

// good
UntrustedBank.withdraw(100); // untrusted external call
TrustedBank.withdraw(100); // external but trusted bank contract maintained by XYZ Corp

function makeUntrustedWithdrawal(uint amount) {
    UntrustedBank.withdraw(amount);
}

使用assert()強制不變性

當斷言條件不滿足時將觸發斷言保護 -- 比如不變的屬性發生了變化。舉個例子,代幣在以太坊上的發行比例,在代幣的發行合約里可以通過這種方式得到解決。斷言保護經常需要和其他技術組合使用,比如當斷言被觸發時先掛起合約然后升級。(否則將一直觸發斷言,你將陷入僵局)

例如:

contract Token {
    mapping(address => uint) public balanceOf;
    uint public totalSupply;

    function deposit() public payable {
        balanceOf[msg.sender] += msg.value;
        totalSupply += msg.value;
        assert(this.balance >= totalSupply);
    }
}

注意斷言保護 不是 嚴格意義的余額檢測, 因為智能合約可以不通過deposit() 函數被 強制發送Ether

正確使用assert()require()

在Solidity 0.4.10 中assert()require()被加入。require(condition)被用來驗證用戶的輸入,如果條件不滿足便會拋出異常,應當使用它驗證所有用戶的輸入。 assert(condition) 在條件不滿足也會拋出異常,但是最好只用于固定變量:內部錯誤或你的智能合約陷入無效的狀態。遵循這些范例,使用分析工具來驗證永遠不會執行這些無效操作碼:意味著代碼中不存在任何不變量,并且代碼已經正式驗證。

小心整數除法的四舍五入

所有整數除數都會四舍五入到最接近的整數。 如果您需要更高精度,請考慮使用乘數,或存儲分子和分母。

(將來Solidity會有一個fixed-point類型來讓這一切變得容易。)

// bad
uint x = 5 / 2; // Result is 2, all integer divison rounds DOWN to the nearest integer

// good
uint multiplier = 10;
uint x = (5 * multiplier) / 2;

uint numerator = 5;
uint denominator = 2;

記住Ether可以被強制發送到賬戶

謹慎編寫用來檢查賬戶余額的不變量。

攻擊者可以強制發送wei到任何賬戶,而且這是不能被阻止的(即使讓fallback函數throw也不行)

攻擊者可以僅僅使用1 wei來創建一個合約,然后調用selfdestruct(victimAddress)。在victimAddress中沒有代碼被執行,所以這是不能被阻止的。

不要假設合約創建時余額為零

攻擊者可以在合約創建之前向合約的地址發送wei。合約不能假設它的初始狀態包含的余額為零。瀏覽issue 61 獲取更多信息。

記住鏈上的數據是公開的

許多應用需要提交的數據是私有的,直到某個時間點才能工作。游戲(比如,鏈上游戲rock-paper-scissors(石頭剪刀布))和拍賣機(比如,sealed-bid second-price auctions)是兩個典型的例子。如果你的應用存在隱私保護問題,一定要避免過早發布用戶信息。

例如:

  • 在游戲石頭剪刀布中,需要參與游戲的雙方提交他們“行動計劃”的hash值,然后需要雙方隨后提交他們的行動計劃;如果雙方的“行動計劃”和先前提交的hash值對不上則拋出異常。
  • 在拍賣中,要求玩家在初始階段提交其所出價格的哈希值(以及超過其出價的保證金),然后在第二階段提交他們所出價格的資金。
  • 當開發一個依賴隨機數生成器的應用時,正確的順序應當是(1)玩家提交行動計劃,(2)生成隨機數,(3)玩家支付。產生隨機數是一個值得研究的領域;當前最優的解決方案包括比特幣區塊頭(通過http://btcrelay.org驗證),hash-commit-reveal方案(比如,一方產生number后,將其散列值提交作為對這個number的“提交”,然后在隨后再暴露這個number本身)和 RANDAO
  • 如果你正在實現頻繁的批量拍賣,那么hash-commit機制也是個不錯的選擇。

權衡Abstract合約和Interfaces

Interfaces和Abstract合約都是用來使智能合約能更好的被定制和重用。Interfaces是在Solidity 0.4.11中被引入的,和Abstract合約很像但是不能定義方法只能申明。Interfaces存在一些限制比如不能夠訪問storage或者從其他Interfaces那繼承,通常這些使Abstract合約更實用。盡管如此,Interfaces在實現智能合約之前的設計智能合約階段仍然有很大用處。另外,需要注意的是如果一個智能合約從另一個Abstract合約繼承而來那么它必須實現所有Abstract合約內的申明的函數,否則它也會成為一個Abstract合約。

在雙方或多方參與的智能合約中,參與者可能會“脫機離線”后不再返回

不要讓退款和索賠流程依賴于參與方執行的某個特定動作而沒有其他途徑來獲取資金。比如,在石頭剪刀布游戲中,一個常見的錯誤是在兩個玩家提交他們的行動計劃之前不要付錢。然而一個惡意玩家可以通過一直不提交它的行動計劃來使對方蒙受損失 -- 事實上,如果玩家看到其他玩家泄露的行動計劃然后決定他是否會損失(譯者注:發現自己輸了),那么他完全有理由不再提交他自己的行動計劃。這些問題也同樣會出現在通道結算。當這些情形出現導致問題后:(1)提供一種規避非參與者和參與者的方式,可能通過設置時間限制,和(2)考慮為參與者提供額外的經濟激勵,以便在他們應該這樣做的所有情況下仍然提交信息。

使Fallback函數盡量簡單

Fallback函數在合約執行消息發送沒有攜帶參數(或當沒有匹配的函數可供調用)時將會被調用,而且當調用 .send() or .transfer()時,只會有2,300 gas 用于失敗后fallback函數的執行(譯者注:合約收到Ether也會觸發fallback函數執行)。如果你希望能夠監聽.send().transfer()接收到Ether,則可以在fallback函數中使用event(譯者注:讓客戶端監聽相應事件做相應處理)。謹慎編寫fallback函數以免gas不夠用。

// bad
function() payable { balances[msg.sender] += msg.value; }

// good
function deposit() payable external { balances[msg.sender] += msg.value; }

function() payable { LogDepositReceived(msg.sender); }

明確標明函數和狀態變量的可見性

明確標明函數和狀態變量的可見性。函數可以聲明為 externalpublicinternalprivate。 分清楚它們之間的差異, 例如external 可能已夠用而不是使用 public。對于狀態變量,external是不可能的。明確標注可見性將使得更容易避免關于誰可以調用該函數或訪問變量的錯誤假設。

// bad
uint x; // the default is private for state variables, but it should be made explicit
function buy() { // the default is public
    // public code
}

// good
uint private y;
function buy() external {
    // only callable externally
}

function utility() public {
    // callable externally, as well as internally: changing this code requires thinking about both cases.
}

function internalAction() internal {
    // internal code
}

將程序鎖定到特定的編譯器版本

智能合約應該應該使用和它們測試時使用最多的編譯器相同的版本來部署。鎖定編譯器版本有助于確保合約不會被用于最新的可能還有bug未被發現的編譯器去部署。智能合約也可能會由他人部署,而pragma標明了合約作者希望使用哪個版本的編譯器來部署合約。

// bad
pragma solidity ^0.4.4;


// good
pragma solidity 0.4.4;

譯者注:這當然也會付出兼容性的代價

小心除零 (Solidity < 0.4)

早于0.4版本, 當一個數嘗試除以零時,Solidity 返回zero 并沒有 throw 一個異常。確保你使用的Solidity版本至少為 0.4。

區分函數和事件

為了防止函數和事件(Event)產生混淆,聲明一個事件使用大寫并加入前綴(我們建議LOG)。對于函數, 始終以小寫字母開頭,構造函數除外。

// bad
event Transfer() {}
function transfer() {}

// good
event LogTransfer() {}
function transfer() external {}

使用Solidity更新的構造器

更合適的構造器/別名,如selfdestruct(舊版本為'suicide)和keccak256(舊版本為sha3)。 像require(msg.sender.send(1 ether))``的模式也可以簡化為使用transfer(),如msg.sender.transfer(1 ether)

已知的攻擊

競態<a href='#footnote-race-condition-terminology'>*</a>

調用外部契約的主要危險之一是它們可以接管控制流,并對調用函數意料之外的數據進行更改。 這類bug有多種形式,導致DAO崩潰的兩個主要錯誤都是這種錯誤。

重入

這個版本的bug被注意到是其可以在第一次調用這個函數完成之前被多次重復調用。對這個函數不斷的調用可能會造成極大的破壞。

// INSECURE
mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // At this point, the caller's code is executed, and can call withdrawBalance again
    userBalances[msg.sender] = 0;
}

譯者注:使用msg.sender.call.value()())傳遞給fallback函數可用的氣是當前剩余的所有氣,在這里,假如從你賬戶執行提現操作的惡意合約的fallback函數內遞歸調用你的withdrawBalance()便可以從你的賬戶轉走更多的幣。

可以看到當調msg.sender.call.value()()時,并沒有將userBalances[msg.sender] 清零,于是在這之前可以成功遞歸調用很多次withdrawBalance()函數。 一個非常相像的bug便是出現在針對 DAO 的攻擊。

在給出來的例子中,最好的方法是 使用 send() 而不是call.value()()。這將避免多余的代碼被執行。

然而,如果你沒法完全移除外部調用,另一個簡單的方法來阻止這個攻擊是確保你在完成你所有內部工作之前不要進行外部調用:

mapping (address => uint) private userBalances;

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    userBalances[msg.sender] = 0;
    if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // The user's balance is already 0, so future invocations won't withdraw anything
}

注意如果你有另一個函數也調用了 withdrawBalance(), 那么這里潛在的存在上面的攻擊,所以你必須認識到任何調用了不受信任的合約代碼的合約也是不受信任的。繼續瀏覽下面的相關潛在威脅解決辦法的討論。

跨函數競態

攻擊者也可以使用兩個共享狀態變量的不同的函數來進行類似攻擊。

// INSECURE
mapping (address => uint) private userBalances;

function transfer(address to, uint amount) {
    if (userBalances[msg.sender] >= amount) {
       userBalances[to] += amount;
       userBalances[msg.sender] -= amount;
    }
}

function withdrawBalance() public {
    uint amountToWithdraw = userBalances[msg.sender];
    if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // At this point, the caller's code is executed, and can call transfer()
    userBalances[msg.sender] = 0;
}

著這個例子中,攻擊者在他們外部調用withdrawBalance函數時調用transfer(),如果這個時候withdrawBalance還沒有執行到userBalances[msg.sender] = 0;這里,那么他們的余額就沒有被清零,那么他們就能夠調用transfer()轉走代幣盡管他們其實已經收到了代幣。這個弱點也可以被用到對DAO的攻擊。

同樣的解決辦法也會管用,在執行轉賬操作之前先清零。也要注意在這個例子中所有函數都是在同一個合約內。然而,如果這些合約共享了狀態,同樣的bug也可以發生在跨合約調用中。

競態解決辦法中的陷阱

由于競態既可以發生在跨函數調用,也可以發生在跨合約調用,任何只是避免重入的解決辦法都是不夠的。

作為替代,我們建議首先應該完成所有內部的工作然后再執行外部調用。這個規則可以避免競態發生。然而,你不僅應該避免過早調用外部函數而且應該避免調用那些也調用了外部函數的外部函數。例如,下面的這段代碼是不安全的:

// INSECURE
mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function withdraw(address recipient) public {
    uint amountToWithdraw = userBalances[recipient];
    rewardsForA[recipient] = 0;
    if (!(recipient.call.value(amountToWithdraw)())) { throw; }
}

function getFirstWithdrawalBonus(address recipient) public {
    if (claimedBonus[recipient]) { throw; } // Each recipient should only be able to claim the bonus once

    rewardsForA[recipient] += 100;
    withdraw(recipient); // At this point, the caller will be able to execute getFirstWithdrawalBonus again.
    claimedBonus[recipient] = true;
}

盡管getFirstWithdrawalBonus() 沒有直接調用外部合約,但是它調用的withdraw() 卻會導致競態的產生。在這里你不應該認為withdraw()是受信任的。

mapping (address => uint) private userBalances;
mapping (address => bool) private claimedBonus;
mapping (address => uint) private rewardsForA;

function untrustedWithdraw(address recipient) public {
    uint amountToWithdraw = userBalances[recipient];
    rewardsForA[recipient] = 0;
    if (!(recipient.call.value(amountToWithdraw)())) { throw; }
}

function untrustedGetFirstWithdrawalBonus(address recipient) public {
    if (claimedBonus[recipient]) { throw; } // Each recipient should only be able to claim the bonus once

    claimedBonus[recipient] = true;
    rewardsForA[recipient] += 100;
    untrustedWithdraw(recipient); // claimedBonus has been set to true, so reentry is impossible
}

除了修復bug讓重入不可能成功,不受信任的函數也已經被標記出來 。同樣的情景: untrustedGetFirstWithdrawalBonus() 調用untrustedWithdraw(), 而后者調用了外部合約,因此在這里untrustedGetFirstWithdrawalBonus() 是不安全的。

另一個經常被提及的解決辦法是(譯者注:像傳統多線程編程中一樣)使用mutex。它會"lock" 當前狀態,只有鎖的當前擁有者能夠更改當前狀態。一個簡單的例子如下:

// Note: This is a rudimentary example, and mutexes are particularly useful where there is substantial logic and/or shared state
mapping (address => uint) private balances;
bool private lockBalances;

function deposit() payable public returns (bool) {
    if (!lockBalances) {
        lockBalances = true;
        balances[msg.sender] += msg.value;
        lockBalances = false;
        return true;
    }
    throw;
}

function withdraw(uint amount) payable public returns (bool) {
    if (!lockBalances && amount > 0 && balances[msg.sender] >= amount) {
        lockBalances = true;

        if (msg.sender.call(amount)()) { // Normally insecure, but the mutex saves it
          balances[msg.sender] -= amount;
        }

        lockBalances = false;
        return true;
    }

    throw;
}

如果用戶試圖在第一次調用結束前第二次調用 withdraw(),將會被鎖住。 這看上去很有效果,但當你使用多個合約互相交互時問題變得嚴峻了。 下面是一段不安全的代碼:

// INSECURE
contract StateHolder {
    uint private n;
    address private lockHolder;

    function getLock() {
        if (lockHolder != 0) { throw; }
        lockHolder = msg.sender;
    }

    function releaseLock() {
        lockHolder = 0;
    }

    function set(uint newState) {
        if (msg.sender != lockHolder) { throw; }
        n = newState;
    }
}

攻擊者可以只調用getLock(),然后就不在調用 releaseLock()。如果他們真這樣做,那么這個合約將會被永久鎖住,任何接下來的操作都不會發生了。如果你使用mutexs來避免競態,那么一定要確保沒有地方能夠打斷鎖的進程或絕不釋放鎖。(這里還有一個潛在的威脅,比如死鎖和活鎖(譯者注:livelocks,我第一次聽說)。在你決定使用鎖之前最好大量閱讀相關文獻(譯者注:這是真的,傳統的在多線程環境下對鎖的使用一直是個容易犯錯的地方))

<div style='font-size: 80%; display: inline;'>* 有些人可能會發反對使用該術語 <i>競態</i>,因為以太坊并沒有真正意思上實現并行執行。然而在邏輯上依然存在對資源的競爭,同樣的陷阱和潛在的解決方案。 </div>

交易順序依賴(TOD) / 前面的先運行

以上是涉及攻擊者在單個交易內執行惡意代碼產生競態的示例。接下來演示在區塊鏈上存在的競態:(同一個block內的)交易順序很容易受到操縱。

由于交易在短暫的時間內會先存放到mempool中,所以在礦工將其打包進block之前,是可以知道會發生什么動作的。這對于一個去中心化的市場來說是麻煩的,因為可以查看到代幣的交易信息,并且可以在它被打包進block之前改變交易順序。避免這一點很困難,因為它歸結為具體的合同本身。例如,在市場上,最好實施批量拍賣(這也可以防止高頻交易問題)。 另一種使用預提交方案的方法(“我稍后會提供詳細信息”)。

時間戳依賴

請注意,塊的時間戳可以由礦工操縱,并且應考慮時間戳的所有直接和間接使用。 區塊數量平均出塊時間可用于估計時間,但這不是區塊時間在未來可能改變(例如Casper期望的更改)的證明。

uint someVariable = now + 1;

if (now % 2 == 0) { // the now can be manipulated by the miner

}

if ((someVariable - 100) % 2 == 0) { // someVariable can be manipulated by the miner

}

整數上溢和下溢

這里大概有 20關于上溢和下溢的例子

考慮如下這個簡單的轉賬操作:

mapping (address => uint256) public balanceOf;

// INSECURE
function transfer(address _to, uint256 _value) {
    /* Check if sender has balance */
    if (balanceOf[msg.sender] < _value)
        throw;
    /* Add and subtract new balances */
    balanceOf[msg.sender] -= _value;
    balanceOf[_to] += _value;
}

// SECURE
function transfer(address _to, uint256 _value) {
    /* Check if sender has balance and for overflows */
    if (balanceOf[msg.sender] < _value || balanceOf[_to] + _value < balanceOf[_to])
        throw;

    /* Add and subtract new balances */
    balanceOf[msg.sender] -= _value;
    balanceOf[_to] += _value;
}

如果余額到達uint的最大值(2^256),便又會變為0。應當檢查這里。溢出是否與之相關取決于具體的實施方式。想想uint值是否有機會變得這么大或和誰會改變它的值。如果任何用戶都有權利更改uint的值,那么它將更容易受到攻擊。如果只有管理員能夠改變它的值,那么它可能是安全的,因為沒有別的辦法可以跨越這個限制。

對于下溢同樣的道理。如果一個uiint別改變后小于0,那么將會導致它下溢并且被設置成為最大值(2^256)。

對于較小數字的類型比如uint8、uint16、uint24等也要小心:他們更加容易達到最大值。

通過(Unexpected) Throw發動DoS

考慮如下簡單的智能合約:

// INSECURE
contract Auction {
    address currentLeader;
    uint highestBid;

    function bid() payable {
        if (msg.value <= highestBid) { throw; }

        if (!currentLeader.send(highestBid)) { throw; } // Refund the old leader, and throw if it fails

        currentLeader = msg.sender;
        highestBid = msg.value;
    }
}

當它試圖退款給舊的leader時,如果退款失敗則會拋出異常。這意味著,惡意投標人可以成為領導者,同時確保對其地址的任何退款始終失敗。這樣就可以阻止任何人調用“bid()”函數,使自己永遠保持領先。建議向之前所說的那樣建立基于pull的支付系統

另一個例子是合約可能通過數組迭代來向用戶支付(例如,眾籌合約中的支持者)時。 通常要確保每次付款都成功。 如果沒有,應該拋出異常。 問題是,如果其中一個支付失敗,您將恢復整個支付系統,這意味著該循環將永遠不會完成。 因為一個地址沒有轉賬成功導致其他人都沒得到報酬。

address[] private refundAddresses;
mapping (address => uint) public refunds;

// bad
function refundAll() public {
    for(uint x; x < refundAddresses.length; x++) { // arbitrary length iteration based on how many addresses participated
        if(refundAddresses[x].send(refunds[refundAddresses[x]])) {
            throw; // doubly bad, now a single failure on send will hold up all funds
        }
    }
}

再一次強調,同樣的解決辦法: 優先使用pull 而不是push支付系統

通過區塊Gas Limit發動DoS

在先前的例子中你可能已經注意到另一個問題:一次性向所有人轉賬,很可能會導致達到以太坊區塊gas limit的上限。以太坊規定了每一個區塊所能花費的gas limit,如果超過你的交易便會失敗。

即使沒有故意的攻擊,這也可能導致問題。然而,最為糟糕的是如果gas的花費被攻擊者操控。在先前的例子中,如果攻擊者增加一部分收款名單,并設置每一個收款地址都接收少量的退款。這樣一來,更多的gas將會被花費從而導致達到區塊gas limit的上限,整個轉賬的操作也會以失敗告終。

又一次證明了 優先使用pull 而不是push支付系統

如果你實在必須通過遍歷一個變長數組來進行轉賬,最好估計完成它們大概需要多少個區塊以及多少筆交易。然后你還必須能夠追蹤得到當前進行到哪以便當操作失敗時從那里開始恢復,舉個例子:

struct Payee {
    address addr;
    uint256 value;
}
Payee payees[];
uint256 nextPayeeIndex;

function payOut() {
    uint256 i = nextPayeeIndex;
    while (i < payees.length && msg.gas > 200000) {
      payees[i].addr.send(payees[i].value);
      i++;
    }
    nextPayeeIndex = i;
}

如上所示,你必須確保在下一次執行payOut()之前另一些正在執行的交易不會發生任何錯誤。如果必須,請使用上面這種方式來處理。

Call Depth攻擊

由于EIP 150 進行的硬分叉,Call Depth攻擊已經無法實施<a >*</a> (由于以太坊限制了Call Depth最大為1024,確保了在達到最大深度之前gas都能被正確使用)

軟件工程技術

正如我們先前在基本理念 章節所討論的那樣,避免自己遭受已知的攻擊是不夠的。由于在鏈上遭受攻擊損失是巨大的,因此你還必須改變你編寫軟件的方式來抵御各種攻擊。

我們倡導“時刻準備失敗",提前知道你的代碼是否安全是不可能的。然而,我們可以允許合約以可預知的方式失敗,然后最小化失敗帶來的損失。本章將帶你了解如何為可預知的失敗做準備。

注意:當你向你的系統添加新的組件時總是伴隨著風險的。一個不良設計本身會成為漏洞-一些精心設計的組件在交互過程中同樣會出現漏洞。仔細考慮你在合約里使用的每一項技術,以及如何將它們整合共同創建一個健壯的系統。

升級有問題的合約

如果代碼中發現了錯誤或者需要對某些部分做改進都需要更改代碼。在以太坊上發現一個錯誤是沒有好處的,而且沒有辦法處理它們。

關于如何在以太坊上設計一個合約升級系統是一個正處于積極研究的領域,在這篇文章當中我們沒法覆蓋所有復雜的領域。然而,這里有兩個通用的基本方法。最簡單的是專門設計一個注冊合約,在注冊合約中保存最新版合約的地址。對于合約使用者來說更能實現無縫銜接的方法是設計一個合約,使用它轉發調用請求和數據到最新版的合約。

無論采用何種技術,組件之間都要進行模塊化和良好的分離,由此代碼的更改才不會破壞原有的功能,造成孤兒數據,或者帶來巨大的成本。 尤其是將復雜的邏輯與數據存儲分開,這樣你在使用更改后的功能時不必重新創建所有數據。

當需要多方參與決定升級代碼的方式也是至關重要的。根據你的合約,升級代碼可能會需要通過單個或多個受信任方參與投票決定。如果這個過程會持續很長時間,你就必須要考慮是否要換成一種更加高效的方式以防止遭受到攻擊,例如緊急停止或斷路器

例1:使用注冊合約存儲合約的最新版本

在這個例子中,調用沒有被轉發,因此用戶必須每次在交互之前都先獲取最新的合約地址。

contract SomeRegister {
    address backendContract;
    address[] previousBackends;
    address owner;

    function SomeRegister() {
        owner = msg.sender;
    }

    modifier onlyOwner() {
        if (msg.sender != owner) {
            throw;
        }
        _;
    }
    
    function changeBackend(address newBackend) public
    onlyOwner()
    returns (bool)
    {
        if(newBackend != backendContract) {
            previousBackends.push(backendContract);
            backendContract = newBackend;
            return true;
        }

        return false;
    }
}

這種方法有兩個主要的缺點:

1、用戶必須始終查找當前合約地址,否則任何未執行此操作的人都可能會使用舊版本的合約
2、在你替換了合約后你需要仔細考慮如何處理合約數據

替代方法是設計一個用來轉發調用請求和數據到最新版的合約:

例2: 使用DELEGATECALL 轉發數據和調用

contract Relay {
    address public currentVersion;
    address public owner;

    modifier onlyOwner() {
        if (msg.sender != owner) {
            throw;
        }
        _;
    }

    function Relay(address initAddr) {
        currentVersion = initAddr;
        owner = msg.sender; // this owner may be another contract with multisig, not a single contract owner
    }

    function changeContract(address newVersion) public
    onlyOwner()
    {
        currentVersion = newVersion;
    }

    function() {
        if(!currentVersion.delegatecall(msg.data)) throw;
    }
}

這種方法避免了先前的問題,但也有自己的問題。它使得你必須在合約里小心的存儲數據。如果新的合約和先前的合約有不同的存儲層,你的數據可能會被破壞。另外,這個例子中的模式沒法從函數里返回值,只負責轉發它們,由此限制了它的適用性。(這里有一個更復雜的實現 想通過內聯匯編和返回大小的注冊表來解決這個問題)

無論你的方法如何,重要的是要有一些方法來升級你的合約,否則當被發現不可避免的錯誤時合約將沒法使用。

斷路器(暫停合約功能)

由于斷路器在滿足一定條件時將會停止執行,如果發現錯誤時可以使用斷路器。例如,如果發現錯誤,大多數操作可能會在合約中被掛起,這是唯一的操作就是撤銷。你可以授權給任何你受信任的一方,提供給他們觸發斷路器的能力,或者設計一個在滿足某些條件時自動觸發某個斷路器的程序規則。

例如:

bool private stopped = false;
address private owner;

modifier isAdmin() {
    if(msg.sender != owner) {
        throw;
    }
    _;
}

function toggleContractActive() isAdmin public
{
    // You can add an additional modifier that restricts stopping a contract to be based on another action, such as a vote of users
    stopped = !stopped;
}

modifier stopInEmergency { if (!stopped) _; }
modifier onlyInEmergency { if (stopped) _; }

function deposit() stopInEmergency public
{
    // some code
}

function withdraw() onlyInEmergency public
{
    // some code
}

速度碰撞(延遲合約動作)

速度碰撞使動作變慢,所以如果發生了惡意操作便有時間恢復。例如,The DAO 從發起分割DAO請求到真正執行動作需要27天。這樣保證了資金在此期間被鎖定在合約里,增加了系統的可恢復性。在DAO攻擊事件中,雖然在速度碰撞給定的時間段內沒有有效的措施可以采取,但結合我們其他的技術,它們是非常有效的。

例如:

struct RequestedWithdrawal {
    uint amount;
    uint time;
}

mapping (address => uint) private balances;
mapping (address => RequestedWithdrawal) private requestedWithdrawals;
uint constant withdrawalWaitPeriod = 28 days; // 4 weeks

function requestWithdrawal() public {
    if (balances[msg.sender] > 0) {
        uint amountToWithdraw = balances[msg.sender];
        balances[msg.sender] = 0; // for simplicity, we withdraw everything;
        // presumably, the deposit function prevents new deposits when withdrawals are in progress

        requestedWithdrawals[msg.sender] = RequestedWithdrawal({
            amount: amountToWithdraw,
            time: now
        });
    }
}

function withdraw() public {
    if(requestedWithdrawals[msg.sender].amount > 0 && now > requestedWithdrawals[msg.sender].time + withdrawalWaitPeriod) {
        uint amountToWithdraw = requestedWithdrawals[msg.sender].amount;
        requestedWithdrawals[msg.sender].amount = 0;

        if(!msg.sender.send(amountToWithdraw)) {
            throw;
        }
    }
}

速率限制

速率限制暫停或需要批準進行實質性更改。 例如,只允許存款人在一段時間內提取總存款的一定數量或百分比(例如,1天內最多100個ether) - 該時間段內的額外提款可能會失敗或需要某種特別批準。 或者將速率限制做在合約級別,合約期限內只能發出發送一定數量的代幣。

瀏覽例程

合約發布

在將大量資金放入合約之前,合約應當進行大量的長時間的測試。

至少應該:

  • 擁有100%測試覆蓋率的完整測試套件(或接近它)
  • 在自己的testnet上部署
  • 在公共測試網上部署大量測試和錯誤獎勵
  • 徹底的測試應該允許各種玩家與合約進行大規模互動
  • 在主網上部署beta版以限制風險總額
自動棄用

在合約測試期間,你可以在一段時間后強制執行自動棄用以阻止任何操作繼續進行。例如,alpha版本的合約工作幾周,然后自動關閉所有除最終退出操作的操作。

modifier isActive() {
    if (block.number > SOME_BLOCK_NUMBER) {
        throw;
    }
    _;
}

function deposit() public
isActive() {
    // some code
}

function withdraw() public {
    // some code
}

限制每個用戶/合約的Ether數量

在早期階段,你可以限制任何用戶(或整個合約)的Ether數量 - 以降低風險。

Bug賞金計劃

運行賞金計劃的一些提示:

  • 決定賞金以哪一種代幣分配(BTC和/或ETH)
  • 決定賞金獎勵的預算總額
  • 從預算來看,確定三級獎勵:
    • 你愿意發放的最小獎勵
    • 通常可發放的最高獎勵
    • 設置額外的限額以避免非常嚴重的漏洞被發現
  • 確定賞金發放給誰(3是一個典型)
  • 核心開發人員應該是賞金評委之一
  • 當收到錯誤報告時,核心開發人員應該評估bug的嚴重性
  • 在這個階段的工作應該在私有倉庫進行,并且在Github上的issue板塊提出問題
  • 如果這個bug需要被修復,開發人員應該在私有倉庫編寫測試用例來復現這個bug
  • 開發人員需要修復bug并編寫額外測試代碼進行測試確保所有測試都通過
  • 展示賞金獵人的修復;并將修復合并回公共倉庫也是一種方式
  • 確定賞金獵人是否有任何關于修復的其他反饋
  • 賞金評委根據bug的可能性影響來確定獎勵的大小
  • 在整個過程中接受賞金參與者的意見并確保賞金發放不會延遲

有關三級獎勵的例子,參見 Ethereum's Bounty Program

獎勵的價值將根據影響的嚴重程度而變化。 獎勵輕微的“無害”錯誤從0.05 BTC開始。 主要錯誤,例如導致協商一致的問題,將獲得最多5個BTC的獎勵。 在非常嚴重的漏洞的情況下,更高的獎勵是可能的(高達25 BTC)。

安全相關的文件和程序

當發布涉及大量資金或重要任務的合約時,必須包含適當的文檔。有關安全性的文檔包括:

規范和發布計劃

  • 規范,圖表,狀態機,模型和其他文檔,幫助審核人員和社區了解系統打算做什么。
  • 許多bug從規范中就能找到,而且它們的修復成本最低。
  • 發布計劃所涉及到的參考這里列出的詳細信息和完成日期。

狀態

  • 當前代碼被部署到哪里
  • 編譯器版本,使用的標志以及用于驗證部署的字節碼的步驟與源代碼匹配
  • 將用于不同階段的編譯器版本和標志
  • 部署代碼的當前狀態(包括未決問題,性能統計信息等)

已知問題

  • 合約的主要風險
    • 例如, 你可能會丟掉所有的錢,黑客可能會通過投票支持某些結果
  • 所有已知的錯誤/限制
  • 潛在的攻擊和解決辦法
  • 潛在的利益沖突(例如,納入自己的腰包,像Slock.it與DAO一樣)

歷史記錄

  • 測試(包括使用統計,發現的錯誤,測試時間)
  • 已審核代碼的人員(及其關鍵反饋)

程序

  • 發現錯誤的行動計劃(例如緊急情況選項,公眾通知程序等)
  • 如果出現問題,就可以降級程序(例如,資金擁有者在被攻擊之前的剩余資金占現在剩余資金的比例)
  • 負責任的披露政策(例如,在哪里報告發現的bug,任何bug賞金計劃的規則)
  • 在失敗的情況下的追索權(例如,保險,罰款基金,無追索權)

聯系信息

  • 發現問題后和誰聯系
  • 程序員姓名和/或其他重要參與方的名稱
  • 可以詢問問題的聊天室

安全工具

  • Oyente - 根據這篇文章分析Ethereum代碼以找到常見的漏洞。
  • solidity-coverage - Solidity代碼覆蓋率測試
  • Solgraph - 生成一個DOT圖,顯示了Solidity合約的功能控制流程,并highlight了潛在的安全漏洞。

Linters

Linters通過約束代碼風格和排版來提高代碼質量,使代碼更容易閱讀和查看。

  • Solium - 另一種Solidity linting。
  • Solint - 幫助你實施代碼一致性約定來避免你合約中的錯誤的Solidity linting
  • Solcheck - 用JS寫的Solidity linter,(實現上)深受eslint的影響。

將來的改進

  • 編輯器安全警告:編輯器將很快能夠實現醒常見的安全錯誤,而不僅僅是編譯錯誤。 Solidity瀏覽器即將推出這些功能。
  • 新的能夠被編譯成EVM字節碼的函數式編程語言: 像Solidity這種函數式編程語言相比面向過程編程語言能夠保證功能的不變性和編譯時間檢查。通過確定性行為來減少出現錯誤的風險。(更多相關信息請參閱 這里, Curry-Howard 一致性和線性邏輯)

智能合約安全參考書目

很多包含代碼,示例和見解的文檔已經由社區編寫完成。這里是其中的一些,你可以隨意添加更多新的內容。

來自以太坊核心開發人員
來自社區

Reviewers

The following people have reviewed this document (date and commit they reviewed in parentheses):
Bill Gleim (07/29/2016 3495fb5)
Bill Gleim (03/15/2017 0244f4e)

License

Licensed under Apache 2.0

Licensed under Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International


版權所有,轉載請注明出處。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 227,572評論 6 531
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 98,071評論 3 414
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 175,409評論 0 373
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 62,569評論 1 307
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 71,360評論 6 404
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 54,895評論 1 321
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 42,979評論 3 440
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,123評論 0 286
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 48,643評論 1 333
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 40,559評論 3 354
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 42,742評論 1 369
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,250評論 5 356
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 43,981評論 3 346
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 34,363評論 0 25
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 35,622評論 1 280
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 51,354評論 3 390
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 47,707評論 2 370

推薦閱讀更多精彩內容