1. 程式人生 > >【以太坊系列-003】以太坊智慧合約 —— 最佳安全開發指南

【以太坊系列-003】以太坊智慧合約 —— 最佳安全開發指南

1基本理念

以太坊和其他複雜的區塊鏈專案都處於早期階段並且有很強的實驗性質。因此,隨著新的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 正尋求提供安全的智慧合約元件使其能夠被重用而不需要每次都重新編寫。任何合約安全性分析都必須標明重用程式碼,特別是以前沒有建立與目標智慧合同系統中處於風險中的資金相稱的信任級別的程式碼。

2安全通知

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

Ethereum Blog: The official Ethereum blog

(地址:https://blog.ethereum.org/

  • Ethereum Blog - Security only: 所有相關部落格都帶有Security標籤

https://blog.ethereum.org/category/security/

Ethereum Gitter 聊天室

(地址:https://gitter.im/ethereum/home

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

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

3關於使用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消耗。

一種被稱為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)`。

4已知的攻擊

競態*

呼叫外部契約的主要危險之一是它們可以接管控制流,並對呼叫函式意料之外的資料進行更改。 這類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)。

對於較小數字的型別比如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;
}
}

當有更高競價時,它將試圖退款給曾經最高競價人,如果退款失敗則會丟擲異常。這意味著,惡意投標人可以成為當前最高競價人,同時確保對其地址的任何退款始終失敗。這樣就可以阻止任何人呼叫“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都能被正確使用)

5軟體工程開發技巧

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

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

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

升級有問題的合約

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

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

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

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

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)。

6安全相關的檔案和程式

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

規範和釋出計劃

  • 規格說明文件,圖表,狀態機,模型和其他文件,幫助稽核人員和社群瞭解系統打算做什麼。
  • 許多bug從規格中就能找到,而且它們的修復成本最低。
  • 釋出計劃所涉及到的參考前文列出的詳細資訊和完成日期。

狀態

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

已知問題

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

歷史記錄

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

程式

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

聯絡資訊

  • 發現問題後和誰聯絡
  • 程式設計師姓名和/或其他重要參與方的名稱
  • 可以詢問問題的論壇/聊天室

7安全工具

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

 

8Linters

Linters通過約束程式碼風格和排版來提高程式碼質量,使程式碼更容易閱讀和檢視。

  • Solium - 另一種Solidity linting。

(地址:https://github.com/duaraghav8/Solium

  • Solint - 幫助你實施程式碼一致性約定來避免你合約中的錯誤的Solidity linting

(地址:https://github.com/SilentCicero/solint

  • Solcheck - 用JS寫的Solidity linter,(實現上)深受eslint的影響。

(地址:https://github.com/federicobond/solcheck

 

9將來的改進

編輯器安全警告:編輯器將很快能夠實現醒常見的安全錯誤,而不僅僅是編譯錯誤。 Solidity瀏覽器即將推出這些功能。

新的能夠被編譯成EVM位元組碼的函數語言程式設計語言: 像Solidity這種函數語言程式設計語言相比面向過程程式語言能夠保證功能的不變性和編譯時間檢查。通過確定性行為來減少出現錯誤的風險。(更多相關資訊請參閱: Curry-Howard 一致性和線性邏輯)

 

轉自:

https://blog.csdn.net/qiange520/article/details/80364262

本文翻譯自:https://github.com/ConsenSys/smart-contract-best-practices