Solidity語言學習筆記————43、安全考量

FLy_鵬程萬里發表於2018-07-10

安全考量

儘管在通常情況下編寫一個按照預期執行的軟體很簡單, 但想要確保沒有人能夠以出乎意料的方式使用它就困難多了。

在 Solidity 中,這一點尤為重要,因為智慧合約可以用來處理通證,甚至有可能是更有價值的東西。 除此之外,智慧合約的每一次執行都是公開的,而且原始碼也通常是容易獲得的。

當然,你總是需要考慮有多大的風險: 你可以將智慧合約與公開的(當然也對惡意使用者開放)、甚至是開源的網路服務相比較。 如果你只是在某個網路服務上儲存你的購物清單,則可能不必太在意, 但如果你使用那個網路服務管理你的銀行帳戶, 那就需要特別當心了。

本節將列出一些陷阱和一般性的安全建議,但這絕對不全面。 另外,請時刻注意的是即使你的智慧合約程式碼沒有 bug, 但編譯器或者平臺本身可能存在 bug。 一個已知的編譯器安全相關的 bug 列表可以在 已知bug列表 找到, 這個列表也可以用程式讀取。 請注意其中有一個涵蓋了 Solidity 編譯器的程式碼生成器的 bug 懸賞專案。

我們的文件是開源的,請一如既往地幫助我們擴充套件這一節的內容(何況其中一些例子並不會造成損失)!

陷阱

私有資訊和隨機性

在智慧合約中你所用的一切都是公開可見的,即便是區域性變數和被標記成 private 的狀態變數也是如此。

如果不想讓礦工作弊的話,在智慧合約中使用隨機數會很棘手 (譯者注:在智慧合約中使用隨機數很難保證節點不作弊, 這是因為智慧合約中的隨機數一般要依賴計算節點的本地時間得到, 而本地時間是可以被惡意節點偽造的,因此這種方法並不安全。 通行的做法是採用 鏈外off-chain 的第三方服務,比如 Oraclize 來獲取隨機數)。

重入

任何從合約 A 到合約 B 的互動以及任何從合約 A 到合約 B 的 以太幣Ether 的轉移,都會將控制權交給合約 B。 這使得合約 B 能夠在互動結束前回撥 A 中的程式碼。 舉個例子,下面的程式碼中有一個 bug(這只是一個程式碼段,不是完整的合約):

pragma solidity ^0.4.0;

// 不要使用這個合約,其中包含一個 bug。
contract Fund {
    /// 合約中 |ether| 分成的對映。
    mapping(address => uint) shares;
    /// 提取你的分成。
    function withdraw() public {
        if (msg.sender.send(shares[msg.sender]))
            shares[msg.sender] = 0;
    }
}
這裡的問題不是很嚴重,因為有限的 gas 也作為 send 的一部分,但仍然暴露了一個缺陷: Ether 的傳輸過程中總是可以包含程式碼執行,所以接收者可以是一個回撥進入 withdraw 的合約。 這就會使其多次得到退款,從而將合約中的全部Ether 提取。 特別地,下面的合約將允許一個攻擊者多次得到退款,因為它使用了 call ,預設傳送所有剩餘的 gas。
pragma solidity ^0.4.0;

// 不要使用這個合約,其中包含一個 bug。
contract Fund {
    /// 合約中 |ether| 分成的對映。
    mapping(address => uint) shares;
    /// 提取你的分成。
    function withdraw() public {
        if (msg.sender.call.value(shares[msg.sender])())
            shares[msg.sender] = 0;
    }
}
為了避免重入,你可以使用下面撰寫的“檢查-生效-互動”(Checks-Effects-Interactions)模式:
pragma solidity ^0.4.11;

contract Fund {
    /// 合約中 |ether| 分成的對映。
    mapping(address => uint) shares;
    /// 提取你的分成。
    function withdraw() public {
        var share = shares[msg.sender];
        shares[msg.sender] = 0;
        msg.sender.transfer(share);
    }
}

請注意重入不僅是 Ether 傳輸的其中一個影響,還包括任何對另一個合約的函式呼叫。 更進一步說,你也不得不考慮多合約的情況。 一個被呼叫的合約可以修改你所依賴的另一個合約的狀態。

gas 限制和迴圈

必須謹慎使用沒有固定迭代次數的迴圈,例如依賴於 storage 值的迴圈: 由於區塊 gas 有限,交易只能消耗一定數量的 gas。 無論是明確指出的還是正常執行過程中的,迴圈中的數次迭代操作所消耗的 gas 都有可能超出區塊的 gas 限制,從而導致整個合約在某個時刻驟然停止。 這可能不適用於只被用來從區塊鏈中讀取資料的 constant 函式。 儘管如此,這些函式仍然可能會被其它合約當作 鏈上on-chain 操作的一部分來呼叫,並使那些操作驟然停止。 請在合約程式碼的說明文件中明確說明這些情況。

傳送和接收Ether

  • 目前無論是合約還是“外部賬戶”都不能阻止有人給它們傳送 以太幣Ether。 合約可以對一個正常的轉賬做出反應並拒絕它,但還有些方法可以不通過建立訊息來傳送 以太幣Ether。 其中一種方法就是單純地向合約地址“挖礦”,另一種方法就是使用 selfdestruct(x) 。
  • 如果一個合約收到了 以太幣Ether (且沒有函式被呼叫),就會執行 fallback 函式。 如果沒有 fallback 函式,那麼 以太幣Ether 會被拒收(同時會丟擲異常)。 在 fallback 函式執行過程中,合約只能依靠此時可用的“gas 津貼”(2300 gas)來執行。 這筆津貼並不足以用來完成任何方式的 儲存storage 訪問。 為了確保你的合約可以通過這種方式收到 以太幣Ether,請你核對 fallback 函式所需的 gas 數量 (在 Remix 的“詳細”章節會舉例說明)。
  • 有一種方法可以通過使用 addr.call.value(x)() 向接收合約傳送更多的 gas。 這本質上跟 addr.transfer(x) 是一樣的, 只不過前者傳送所有剩餘的 gas,並且使得接收者有能力執行更加昂貴的操作 (它只會返回一個錯誤程式碼,而且也不會自動傳播這個錯誤)。 這可能包括回撥傳送合約或者你想不到的其它狀態改變的情況。 因此這種方法無論是給誠實使用者還是惡意行為者都提供了極大的靈活性。
  • 如果你想要使用 address.transfer 傳送 以太幣Ether ,你需要注意以下幾個細節: 
    1. 如果接收者是一個合約,它會執行自己的 fallback 函式,從而可以回撥傳送 以太幣Ether 的合約。
    2. 如果呼叫的深度超過 1024,傳送 以太幣Ether 也會失敗。由於呼叫者對呼叫深度有完全的控制權,他們可以強制使這次傳送失敗; 請考慮這種可能性,或者使用 send 並且確保每次都核對它的返回值。 更好的方法是使用一種接收者可以取回 以太幣Ether 的方式編寫你的合約。
    3. 傳送 以太幣Ether 也可能因為接收方合約的執行所需的 gas 多於分配的 gas 數量而失敗 (確切地說,是使用了 require, assert, revert , throw 或者因為這個操作過於昂貴) - “gas 不夠用了”。 如果你使用 transfer 或者 send 的同時帶有返回值檢查,這就為接收者提供了在傳送合約中阻斷程式的方法。 再次說明,最佳實踐是使用 “取回”模式而不是“傳送”模式。

呼叫棧深度

外部函式呼叫隨時會失敗,因為它們超過了呼叫棧的上限 1024。 在這種情況下,Solidity 會丟擲一個異常。 惡意行為者也許能夠在與你的合約互動之前強制將呼叫棧設定成一個比較高的值。

請注意,使用 .send() 時如果超出呼叫棧 並不會 丟擲異常,而是會返回 false。 低階的函式比如 .call().callcode() 和 .delegatecall() 也都是這樣的。

tx.origin

永遠不要使用 tx.origin 做身份認證。假設你有一個如下的錢包合約:

pragma solidity ^0.4.11;

// 不要使用這個合約,其中包含一個 bug。
contract TxUserWallet {
    address owner;

    function TxUserWallet() public {
        owner = msg.sender;
    }

    function transferTo(address dest, uint amount) public {
        require(tx.origin == owner);
        dest.transfer(amount);
    }
}
現在有人欺騙你,將 以太幣Ether 傳送到了這個惡意錢包的地址:
pragma solidity ^0.4.11;

interface TxUserWallet {
    function transferTo(address dest, uint amount) public;
}

contract TxAttackWallet {
    address owner;

    function TxAttackWallet() public {
        owner = msg.sender;
    }

    function() public {
        TxUserWallet(msg.sender).transferTo(owner, msg.sender.balance);
    }
}

如果你的錢包通過核查 msg.sender 來驗證傳送方身份,你就會得到惡意錢包的地址,而不是所有者的地址。 但是通過核查 tx.origin ,得到的就會是啟動交易的原始地址,它仍然會是所有者的地址。 惡意錢包會立即將你的資金抽出。

細枝末節

  • 在 for (var i = 0; i < arrayName.length; i++) { ... } 中, i 的型別會變為 uint8 , 因為這是儲存 0 值所需的最小型別。如果陣列超過 255 個元素,則迴圈不會終止。
  • constant 關鍵字並不是編譯器強制的,另外也不是 以太坊虛擬機器Ethereum Virtual Machine(EVM) 強制的, 因此一個“宣告”為 constant 的函式可能仍然會發生狀態發生變化。
  • 不佔用完整 32 位元組的型別可能包含“髒高位”。這在當你訪問 msg.data 的時候尤為重要 —— 它帶來了延展性風險: 你既可以用原始位元組 0xff000001 也可以用 0x00000001 作為引數來呼叫函式 f(uint8 x) 以構造交易。 這兩個引數都會被正常提供給合約,並且 x 的值看起來都像是數字 1, 但 msg.data 會不一樣,所以如果你無論怎麼使用 keccak256(msg.data),你都會得到不同的結果。

推薦做法

限定 以太幣Ether 的數量

限定 儲存storage 在一個智慧合約中 以太幣Ether (或者其它通證)的數量。 如果你的原始碼、編譯器或者平臺出現了 bug,可能會導致這些資產丟失。 如果你想控制你的損失,就要限定 以太幣Ether 的數量。

保持合約簡練且模組化

保持你的合約短小精煉且易於理解。 找出無關於其它合約或庫的功能。 有關原始碼質量可以採用的一般建議: 限制區域性變數的數量以及函式的長度等等。 將實現的函式文件化,這樣別人看到程式碼的時候就可以理解你的意圖,並判斷程式碼是否按照正確的意圖實現。

使用“檢查-生效-互動”(Checks-Effects-Interactions)模式

大多數函式會首先做一些檢查工作(例如誰呼叫了函式,引數是否在取值範圍之內,它們是否傳送了足夠的 以太幣Ether ,使用者是否具有通證等等)。 這些檢查工作應該首先被完成。

第二步,如果所有檢查都通過了,應該接著進行會影響當前合約狀態變數的那些處理。 與其它合約的互動應該是任何函式的最後一步。

早期合約延遲了一些效果的產生,為了等待外部函式呼叫以非錯誤狀態返回。 由於上文所述的重入問題,這通常會導致嚴重的後果。

請注意,對已知合約的呼叫反過來也可能導致對未知合約的呼叫,所以最好是一直保持使用這個模式編寫程式碼。

包含故障-安全(Fail-Safe)模式

儘管將系統完全去中心化可以省去許多中間環節,但包含某種故障-安全模式仍然是好的做法,尤其是對於新的程式碼來說:

你可以在你的智慧合約中增加一個函式實現某種程度上的自檢查,比如“ 以太幣Ether 是否會洩露?”, “通證的總和是否與合約的餘額相等?”等等。 請記住,你不能使用太多的 gas,所以可能需要通過 鏈外off-chain 計算來輔助。

如果自檢查沒有通過,合約就會自動切換到某種“故障安全”模式, 例如,關閉大部分功能,將控制權交給某個固定的可信第三方,或者將合約轉換成一個簡單的“退回我的錢”合約。

形式化驗證

使用形式化驗證可以執行自動化的數學證明,保證原始碼符合特定的正式規範。 規範仍然是正式的(就像原始碼一樣),但通常要簡單得多。

請注意形式化驗證本身只能幫助你理解你做的(規範)和你怎麼做(實際的實現)的之間的差別。 你仍然需要檢查這個規範是否是想要的,而且沒有漏掉由它產生的任何非計劃內的效果。


相關文章