標籤:

以太坊智能合約 —— 最佳安全開發指南

以太坊智能合約 —— 最佳安全開發指南

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

主要章節如下:

  • Solidity安全貼士
  • 已知的攻擊手段
    • 競態
      • 可重入
      • 交易順序依賴
    • 針對Gas的攻擊
    • 整數上溢/整數下溢
  • 軟體工程開發技巧
  • 參考文獻

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

我們邀請社區對該文檔提出修改或增補建議,歡迎各種合併請求(Pull Request)。若有相關的文章或者博客的發表,也清將其加入到參考文獻中,具體詳情請參見我們的社區貢獻指南。

更多期待內容

我們歡迎並期待社區開發者貢獻以下幾個方面的內容:

  • Solidity代碼測試(包括代碼結構,程序框架 以及 常見軟體工程測試)
  • 智能合約開發經驗總結,以及更廣泛的基於區塊鏈的開發技巧分享

基本理念

以太坊和其他複雜的區塊鏈項目都處於早期階段並且有很強的實驗性質。因此,隨著新的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 contracts)。

龐大 vs 模塊化

一個龐大的獨立的智能合約把所有的變數和模塊都放到一個合約中。儘管只有少數幾個大家熟知的智能合約系統真的做到了大體量,但在將數據和流程都放到一個合約中還是享有部分優點--比如,提高代碼審核(code review)效率。

和在這裡討論的其他權衡點一樣,傳統軟體開發策略和從合約安全形度出發考慮,兩者不同主要在對於簡單、短生命周期的智能合約;對於更複雜、長生命周期的智能合約,兩者策略理念基本相同。

重複 vs 可重用

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

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

安全通知

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

  • Ethereum Blog: The official Ethereum blog
    • Ethereum Blog - Security only: 所有相關博客都帶有Security標籤
  • Ethereum Gitter 聊天室
    • Solidity
    • Go-Ethereum
    • CPP-Ethereum
    • Research
  • Reddit
  • Network Stats

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

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

  • Vitalik Buterin: Twitter, Github, Reddit, Ethereum Blog
  • Dr. Christian Reitwiessner: Twitter, Github, Ethereum Blog
  • Dr. Gavin Wood: Twitter, Blog, Github
  • Vlad Zamfir: Twitter, Github, Ethereum Blog

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

關於使用Solidity開發的智能合約安全建議

外部調用

盡量避免外部調用

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

仔細權衡「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消耗。

一種被稱為ref="https://github.com/ConsenSys/sm">push 和pull的 機制試圖來平衡兩者, 在 push 部分使用send() 或transfer(),在pull 部分使用call.value()()。(*譯者註:在需要對外未知地址轉賬Ether時使用send() 或transfer(),已知明確內部無惡意代碼的地址轉賬Ether使用call.value()())

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

處理外部調用錯誤

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

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

// badsomeAddress.send(55);someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesnt check for resultsomeAddress.call.value(100)(bytes4(sha3("deposit()"))); // if deposit throws an exception, the raw call() will only return false and transaction will NOT be reverted// goodif(!someAddress.send(55)) { // Some failure code}ExternalContract(someAddress).deposit.value(100);

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

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

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

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

// badcontract 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; }}// goodcontract 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 } }}

標記不受信任的合約

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

// badBank.withdraw(100); // Unclear whether trusted or untrustedfunction makeWithdrawal(uint amount) { // Isnt clear that this function is potentially unsafe Bank.withdraw(amount);}// goodUntrustedBank.withdraw(100); // untrusted external callTrustedBank.withdraw(100); // external but trusted bank contract maintained by XYZ Corpfunction 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類型來讓這一切變得容易。)

// baduint x = 5 / 2; // Result is 2, all integer divison rounds DOWN to the nearest integer// gooduint 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值對不上則拋出異常。
  • 在拍賣中,要求玩家在初始階段提交其所出價格的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不夠用。

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

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

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

// baduint x; // the default is private for state variables, but it should be made explicitfunction buy() { // the default is public // public code}// gooduint 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標明了合約作者希望使用哪個版本的編譯器來部署合約。

// badpragma solidity ^0.4.4;// goodpragma solidity 0.4.4;

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

小心分母為零 (Solidity < 0.4)

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

區分函數和事件

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

// badevent Transfer() {}function transfer() {}// goodevent LogTransfer() {}function transfer() external {}

使用Solidity更新的構造器

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

已知的攻擊

競態*

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

重入

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

// INSECUREmapping (address => uint) private userBalances;function withdrawBalance() public { uint amountToWithdraw = userBalances[msg.sender]; if (!(msg.sender.call.value(amountToWithdraw)())) { throw; } // At this point, the callers 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 的攻擊。

在給出來的例子中,最好的方法是 href="github.com/ConsenSys/sm">使用 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 users balance is already 0, so future invocations wont withdraw anything}

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

跨函數競態

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

// INSECUREmapping (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 callers code is executed, and can call transfer() userBalances[msg.sender] = 0;}

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

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

競態解決辦法中的陷阱

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

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

// INSECUREmapping (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 statemapping (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(),將會被鎖住。 這看上去很有效果,但當你使用多個合約互相交互時問題變得嚴峻了。 下面是一段不安全的代碼:

// INSECUREcontract 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來避免競態,那麼一定要確保沒有地方能夠打斷鎖的進程或絕不釋放鎖。(這裡還有一個潛在的威脅,比如死鎖和活鎖。在你決定使用鎖之前最好大量閱讀相關文獻(譯者註:這是真的,傳統的在多線程環境下對鎖的使用一直是個容易犯錯的地方))

* 有些人可能會發反對使用該術語 競態,因為以太坊並沒有真正意思上實現並行執行。然而在邏輯上依然存在對資源的競爭,同樣的陷阱和潛在的解決方案。

交易順序依賴(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;// INSECUREfunction 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;}// SECUREfunction 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的值,那麼它將更容易受到攻擊。如果只有管理員能夠改變它的值,那麼它可能是安全的,因為沒有別的辦法可以跨越這個限制。

對於下溢同樣的道理。如果一個uint別改變後小於0,那麼將會導致它下溢並且被設置成為最大值(2^256)。

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

通過(Unexpected) Throw發動DoS

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

// INSECUREcontract 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; }}

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

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

address[] private refundAddresses;mapping (address => uint) public refunds;// badfunction 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攻擊已經無法實施* (由於以太坊限制了Call Depth最大為1024,確保了在達到最大深度之前gas都能被正確使用)

軟體工程開發技巧

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

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

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

升級有問題的合約

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

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

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

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

Example 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: href="ethereum.stackexchange.com">使用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 weeksfunction 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() publicisActive() { // some code}function withdraw() public { // some code}

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

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

Bug賞金計劃

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

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

有關三級獎勵的例子,參見 Ethereums Bounty Program:

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

安全相關的文件和程序

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

規範和發布計劃

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

狀態

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

已知問題

  • 合約的主要風險
    • 例如, 你可能會丟掉所有的錢,黑客可能會通過投票支持某些結果
  • 所有已知的錯誤/限制
  • 潛在的攻擊和解決辦法
  • 潛在的利益衝突(例如,籌集的Ether將納入自己的腰包,像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 一致性和線性邏輯)

智能合約安全參考書目

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

來自以太坊核心開發人員

  • How to Write Safe Smart Contracts (Christian Reitwiessner)
  • Smart Contract Security (Christian Reitwiessner)
  • Thinking about Smart Contract Security (Vitalik Buterin)
  • Solidity
  • Solidity Security Considerations

來自社區

  • forum.ethereum.org/disc
  • hackingdistributed.com/
  • hackingdistributed.com/
  • hackingdistributed.com/
  • martin.swende.se/blog/D
  • publications.lib.chalmers.se
  • vessenes.com/deconstruc
  • vessenes.com/ethereum-g
  • vessenes.com/more-ether
  • blog.blockstack.org/sim
  • blog.slock.it/deja-vu-d
  • blog.vdice.io/wp-conten
  • eprint.iacr.org/2016/10
  • github.com/Bunjin/Roule
  • github.com/LeastAuthori
  • medium.com/@ConsenSys/a
  • medium.com/@coriacetic/
  • medium.com/@hrishiolick
  • medium.com/@peterborah/
  • medium.com/zeppelin-blo
  • pdaian.com/blog/chasing
  • comp.nus.edu.sg/~loiluu

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


推薦閱讀:

比特幣,萊特幣,以太坊,以太經典,BCH,Dash, XRP價格分析(59)--12/05/2017
馬斯克出局OpenAI董事會
FintruX(FTX)區塊鏈項目推薦及分析
ENS域名解析:像使用DNS一樣使用ENS
完全理解以太坊ETH(一)——初衷

TAG:以太坊 |