這篇文檔旨在爲Solidity開發人員提供一些智能合約的安全準則(security baseline)。固然也**包括智能合約的安全開發理念、bug賞金計劃指南、文檔例程以及工具。**對該文檔提出修改或增補建議,請點擊「閱讀原文」。git
以太坊和其餘複雜的區塊鏈項目都處於早期階段而且有很強的實驗性質。所以,隨着新的bug和安全漏洞被發現,新的功能不斷被開發出來,其面臨的安全威脅也是不斷變化的。這篇文章對於開發人員編寫安全的智能合約來講只是個開始。程序員
開發智能合約須要一個全新的工程思惟,它不一樣於咱們以往項目的開發。由於它犯錯的代價是巨大的,而且很難像傳統軟件那樣輕易的打上補丁。就像直接給硬件編程或金融服務類軟件開發,相比於web開發和移動開發都有更大的挑戰。所以,僅僅防範已知的漏洞是不夠的,你還須要學習新的開發理念:github
**對可能的錯誤有所準備。**任何有意義的智能合約或多或少都存在錯誤。所以你的代碼必須可以正確的處理出現的bug和漏洞。始終保證如下規則: - 當智能合約出現錯誤時,中止合約,(「斷路開關」) - 管理帳戶的資金風險(限制(轉帳)速率、最大(轉帳)額度)web
**謹慎發佈智能合約。**儘可能在正式發佈智能合約以前發現並修復可能的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
(地址:https://blog.ethereum.org/)
Ethereum Blog - Security only: 全部相關博客都帶有Security標籤
Ethereum Gitter 聊天室
(地址:https://gitter.im/ethereum/home)
Solidity(地址:https://gitter.im/ethereum/solidity)
Go-Ethereum(地址:https://gitter.im/ethereum/go-ethereum)
CPP-Ethereum(地址:https://gitter.im/ethereum/cpp-ethereum)
Research(地址:https://gitter.im/ethereum/research)
Reddit(地址:https://www.reddit.com/r/ethereum/)
Network Stats(地址:https://ethstats.net/)
強烈建議你常常瀏覽這些網站,尤爲是他們提到的可能會影響你的智能合約的漏洞。
另外, 這裏列出了以太坊參與安全模塊相關的核心開發成員, 瀏覽 bibliography 獲取更多信息。
(地址:https://github.com/ConsenSys/smart-contract-best-practices#smart-contract-security-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
除了關注核心開發成員,參與到各個區塊鏈安全社區也很重要,由於安全漏洞的披露或研究將經過各方進行。
外部調用
儘可能避免外部調用
調用不受信任的外部合約可能會引起一系列意外的風險和錯誤。外部調用可能在其合約和它所依賴的其餘合約內執行惡意代碼。所以,每個外部調用都會有潛在的安全威脅,儘量的從你的智能合約內移除外部調用。當沒法徹底去除外部調用時,可使用這一章節其餘部分提供的建議來儘可能減小風險。
仔細權衡「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消耗。
一種被稱爲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 )。
若是你選擇使用底層方法,必定要檢查返回值來對可能的錯誤進行處理。
// bad someAddress.send(55);
someAddress.call.value(55)(); // this is doubly dangerous, as it will forward all remaining gas and doesn'tcheck 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 failurecode
}
ExternalContract(someAddress).deposit.value(100);
不要假設你知道外部調用的控制流程
不管是使用raw calls 或是contract calls,若是這個ExternalContract是不受信任的都應該假設存在惡意代碼。即便ExternalContract不包含惡意代碼,但它所調用的其餘合約代碼可能會包含惡意代碼。一個具體的危險例子即是惡意代碼可能會劫持控制流程致使競態。
(瀏覽Race Conditions獲取更多關於這個問題的討論,
地址:https://github.com/ConsenSys/smart-contract-best-practices/#race-conditions)
對於外部合約優先使用pull 而不是push
外部調用可能會有意或無心的失敗。爲了最小化這些外部調用失敗帶來的損失,一般好的作法是將外部調用函數與其他代碼隔離,最終是由收款發起方負責發起調用該函數。這種作法對付款操做尤其重要,好比讓用戶本身撤回資產而不是直接發送給他們。(譯者注:事先設置須要付給某一方的資產的值,代表接收方能夠從當前帳戶撤回資金的額度,而後由接收方調用當前合約提現函數完成轉帳)。
(這種方法同時也避免了形成 gas limit相關問題。
地址:https://github.com/ConsenSys/smart-contract-best-practices/#dos-with-block-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 獲取更多信息。
(地址:https://github.com/ConsenSys/smart-contract-best-practices/issues/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不夠用。
// bad
function() payable { balances[msg.sender] += msg.value; }
// good
function deposit() payable external { balances[msg.sender] += msg.value; }
function() payable { LogDepositReceived(msg.sender); }
明確標明函數和狀態變量的可見性
明確標明函數和狀態變量的可見性。函數能夠聲明爲 external,public, internal 或 private。
分清楚它們之間的差別,例如external 可能已夠用而不是使用 public。對於狀態變量,external是不可能的。明確標註可見性將使得更容易避免關於誰能夠調用該函數或訪問變量的錯誤假設。
// bad uint x; // the default is private for state variables, but it should be made explicit
functionbuy() { // 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)
。
競態*
調用外部契約的主要危險之一是它們能夠接管控制流,並對調用函數意料以外的數據進行更改。 這類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;
functionwithdrawBalance() 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;
functionwithdraw(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來避免競態,那麼必定要確保沒有地方可以打斷鎖的進程或毫不釋放鎖。(這裏還有一個潛在的威脅,好比死鎖和活鎖。在你決定使用鎖以前最好大量閱讀相關文獻*(譯者注:這是真的,傳統的在多線程環境下對鎖的使用一直是個容易犯錯的地方))*
交易順序依賴(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關於上溢和下溢的例子。
(https://github.com/ethereum/solidity/issues/796#issuecomment-253578925)
考慮以下這個簡單的轉帳操做:
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的值,那麼它將更容易受到攻擊。若是隻有管理員可以改變它的值,那麼它多是安全的,由於沒有別的辦法能夠跨越這個限制。
對於下溢一樣的道理。若是一個uint別改變後小於0,那麼將會致使它下溢而且被設置成爲最大值(2^256)。
對於較小數字的類型好比uint八、uint1六、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; } }
當有更高競價時,它將試圖退款給曾經最高競價人,若是退款失敗則會拋出異常。這意味着,惡意投標人能夠成爲當前最高競價人,同時確保對其地址的任何退款始終失敗。這樣就能夠阻止任何人調用「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攻擊已經沒法實施* (因爲以太坊限制了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; } }
這種方法有兩個主要的缺點:
用戶必須始終查找當前合約地址,不然任何未執行此操做的人均可能會使用舊版本的合約
在你替換了合約後你須要仔細考慮如何處理原合約中的數據
另一種方法是設計一個用來轉發調用請求和數據到最新版的合約:
Example 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:
(地址:https://bounty.ethereum.org/)
獎勵的價值將根據影響的嚴重程度而變化。 獎勵輕微的「無害」錯誤從0.05 BTC開始。 主要錯誤,例如致使協商一致的問題,將得到最多5個BTC的獎勵。 在很是嚴重的漏洞的狀況下,更高的獎勵是可能的(高達25 BTC)。
當發佈涉及大量資金或重要任務的合約時,必須包含適當的文檔。有關安全性的文檔包括:
規範和發佈計劃
規格說明文檔,圖表,狀態機,模型和其餘文檔,幫助審覈人員和社區瞭解系統打算作什麼。
許多bug從規格中就能找到,並且它們的修復成本最低。
發佈計劃所涉及到的參考前文列出的詳細信息和完成日期。
狀態
當前代碼被部署到哪裏
編譯器版本,使用的標誌以及用於驗證部署的字節碼的步驟與源代碼匹配
將用於不一樣階段的編譯器版本和標誌
部署代碼的當前狀態(包括未決問題,性能統計信息等)
已知問題
合約的主要風險。例如, 你可能會丟掉全部的錢,黑客可能會經過投票支持某些結果
全部已知的錯誤/限制
潛在的攻擊和解決辦法
潛在的利益衝突(例如,籌集的Ether將歸入本身的腰包,像Slock.it與DAO同樣)
歷史記錄
測試(包括使用統計,發現的錯誤,測試時間)
已審覈代碼的人員(及其關鍵反饋)
程序
發現錯誤的行動計劃(例如緊急狀況選項,公衆通知程序等)
若是出現問題,就能夠降級程序(例如,資金擁有者在被攻擊以前的剩餘資金佔如今剩餘資金的比例)
負責任的披露政策(例如,在哪裏報告發現的bug,任何bug賞金計劃的規則)
在失敗的狀況下的追索權(例如,保險,罰款基金,無追索權)
聯繫信息
發現問題後和誰聯繫
程序員姓名和/或其餘重要參與方的名稱
能夠詢問問題的論壇/聊天室
Oyente - 根據這篇文章分析Ethereum代碼以找到常見的漏洞。
(地址:https://github.com/melonproject/oyente
http://www.comp.nus.edu.sg/~loiluu/papers/oyente.pdf)
solidity-coverage - Solidity代碼覆蓋率測試
(地址:https://github.com/sc-forks/solidity-coverage)
Solgraph - 生成一個DOT圖,顯示了Solidity合約的功能控制流程,並highlight了潛在的安全漏洞。
(地址:https://github.com/raineorshine/solgraph)
Linters經過約束代碼風格和排版來提升代碼質量,使代碼更容易閱讀和查看。
Solium - 另外一種Solidity linting。
Solint - 幫助你實施代碼一致性約定來避免你合約中的錯誤的Solidity linting
Solcheck - 用JS寫的Solidity linter,(實現上)深受eslint的影響。
編輯器安全警告:編輯器將很快可以實現醒常見的安全錯誤,而不只僅是編譯錯誤。 Solidity瀏覽器即將推出這些功能。
新的可以被編譯成EVM字節碼的函數式編程語言: 像Solidity這種函數式編程語言相比面向過程編程語言可以保證功能的不變性和編譯時間檢查。經過肯定性行爲來減小出現錯誤的風險。(更多相關信息請參閱: Curry-Howard 一致性和線性邏輯)
本文來源:GitHub
2 contributors:AlexXiong9七、 tolak
本文翻譯自:https://github.com/ConsenSys/smart-contract-best-practices。
爲了使語句表達更加貼切,個別地方未按照原文逐字逐句翻譯,若有出入請以原文爲準。
如下是咱們的社區介紹,歡迎各類合做、交流、學習:)