作者:RickGray
作者博客:http://rickgray.me/2018/05/26/ethereum-smart-contracts-vulnerabilities-review-part2/

(注:本文分上/下兩部分完成,上篇鏈接《以太坊智能合約安全入門了解一下(上)》) 接上篇

3. Arithmetic Issues

算數問題?通常來說,在編程語言里算數問題導致的漏洞最多的就是整數溢出了,整數溢出又分為上溢和下溢。整數溢出的原理其實很簡單,這里以 8 位無符整型為例,8 位整型可表示的范圍為 [0, 255]255 在內存中存儲按位存儲的形式為(下圖左):

8 位無符整數 255 在內存中占據了 8bit 位置,若再加上 1 整體會因為進位而導致整體翻轉為 0,最后導致原有的 8bit 表示的整數變為 0.

如果是 8 位有符整型,其可表示的范圍為 [-128, 127]127 在內存中存儲按位存儲的形式為(下圖左):

在這里因為高位作為了符號位,當 127 加上 1 時,由于進位符號位變為 1(負數),因為符號位已翻轉為 1,通過還原此負數值,最終得到的 8 位有符整數為 -128

上面兩個都是整數上溢的圖例,同樣整數下溢 (uint8)0-1=(uint8)255, (int8)(-128)-1=(int8)127

withdraw(uint) 函數中首先通過 require(balances[msg.sender] - _amount > 0) 來確保賬戶有足夠的余額可以提取,隨后通過 msg.sender.transfer(_amount) 來提取 Ether,最后更新用戶余額信息。這段代碼若是一個沒有任何安全編碼經驗的人來審計,代碼的邏輯處理流程似乎看不出什么問題,但是如果是編碼經驗豐富或者說是安全研究人員來看,這里就明顯存在整數溢出繞過檢查的漏洞。

在 Solidity 中 uint 默認為 256 位無符整型,可表示范圍 [0, 2**256-1],在上面的示例代碼中通過做差的方式來判斷余額,如果傳入的 _amount 大于賬戶余額,則 balances[msg.sender] - _amount 會由于整數下溢而大于 0 繞過了條件判斷,最終提取大于用戶余額的 Ether,且更新后的余額可能會是一個極其大的數。

pragma solidity ^0.4.10;

contract MyToken {
    mapping (address => uint) balances;

    function balanceOf(address _user) returns (uint) { return balances[_user]; }
    function deposit() payable { balances[msg.sender] += msg.value; }
    function withdraw(uint _amount) {
        require(balances[msg.sender] - _amount > 0);  // 存在整數溢出
        msg.sender.transfer(_amount);
        balances[msg.sender] -= _amount;
    }
}

簡單的利用過程演示:

為了避免上面代碼造成的整數溢出,可以將條件判斷改為 require(balances[msg.sender] > _amount),這樣就不會執行算術操作進行進行邏輯判斷,一定程度上避免了整數溢出的發生。

Solidity 除了簡單的算術操作會出現整數溢出外,還有一些需要注意的編碼細節,稍不注意就可能形成整數溢出導致無法執行正常代碼流程:

  • 數組 length 為 256 位無符整型,仔細對 array.length++ 或者 array.length-- 操作進行溢出校驗;
  • 常見的循環變量 for (var i = 0; i < items.length; i++) ... 中,i 為 8 位無符整型,當 items 長度大于 256 時,可能造成 i 值溢出無法遍歷完全;

關于合約整數溢出的漏洞并不少見,可以看看最近曝光的幾起整數溢出事件:《代幣變泡沫,以太坊Hexagon溢出漏洞比狗莊還過分》《Solidity合約中的整數安全問題——SMT/BEC合約整數溢出解析》

為了防止整數溢出的發生,一方面可以在算術邏輯前后進行驗證,另一方面可以直接使用 OpenZeppelin 維護的一套智能合約函數庫中的 SafeMath 來處理算術邏輯。

4. Unchecked Return Values For Low Level Calls

未嚴格判斷不安全函數調用返回值,這類型的漏洞其實很好理解,在前面講 Reentrancy 實例的時候其實也涉及到了底層調用返回值處理驗證的問題。上篇已經總結過幾個底層調用函數的返回值和異常處理情況,這里再回顧一下 3 個底層調用 call(), delegatecall(), callcode() 和 3 個轉幣函數 call.value()(), send(), transfer()

- call()

call() 用于 Solidity 進行外部調用,例如調用外部合約函數 <address>.call(bytes4(keccak("somefunc(params)"), params)),外部調用 call() 返回一個 bool 值來表明外部調用成功與否:

- delegatecall()

除了 delegatecall() 會將外部代碼作直接作用于合約上下文以外,其他與 call() 一致,同樣也是只能獲取一個 bool 值來表示調用成功或者失敗(發生異常)。

- callcode()

callcode() 其實是 delegatecall() 之前的一個版本,兩者都是將外部代碼加載到當前上下文中進行執行,但是在 msg.sendermsg.value 的指向上卻有差異。

例如 Alice 通過 callcode() 調用了 Bob 合約里同時 delegatecall() 了 Wendy 合約中的函數,這么說可能有點抽象,看下面的代碼:

如果還是不明白 callcode()delegatecall() 的區別,可以將上述代碼在 remix-ide 里測試一下,觀察兩種調用方式在 msg.sendermsg.value 上的差異。

- call.value()()

在合約中直接發起 TX 的函數之一(相當危險),

- send()

通過 send() 函數發送 Ether 失敗時直接返回 false;這里需要注意的一點就是,send() 的目標如果是合約賬戶,則會嘗試調用它的 fallbcak() 函數,fallback() 函數中執行失敗,send() 同樣也只會返回 false。但由于只會提供 2300 Gas 給 fallback() 函數,所以可以防重入漏洞(惡意遞歸調用)。

- transfer()

transfer() 也可以發起 Ether 交易,但與 send() 不同的時,transfer() 是一個較為安全的轉幣操作,當發送失敗時會自動回滾狀態,該函數調用沒有返回值。同樣的,如果 transfer() 的目標是合約賬戶,也會調用合約的 fallback() 函數,并且只會傳遞 2300 Gas 用于 fallback() 函數執行,可以防止重入漏洞(惡意遞歸調用)。

這里以一個簡單的示例來說明嚴格驗證底層調用返回值的重要性:

function withdraw(uint256 _amount) public {
    require(balances[msg.sender] >= _amount);
    balances[msg.sender] -= _amount;
    etherLeft -= _amount;
    msg.sender.send(_amount);  // 未驗證 send() 返回值,若 msg.sender 為合約賬戶 fallback() 調用失敗,則 send() 返回 false
}

上面給出的提幣流程中使用 send() 函數進行轉賬,因為這里沒有驗證 send() 返回值,如果 msg.sender 為合約賬戶 fallback() 調用失敗,則 send() 返回 false,最終導致賬戶余額減少了,錢卻沒有拿到。

關于該類問題可以詳細了解一下 King of the Ether

5. Denial of Service - 拒絕服務

DoS 無處不在,在 Solidity 里也是,與其說是拒絕服務漏洞不如簡單的說成是 “不可恢復的惡意操作或者可控制的無限資源消耗”。簡單的說就是對以太坊合約進行 DoS 攻擊,可能導致 Ether 和 Gas 的大量消耗,更嚴重的是讓原本的合約代碼邏輯無法正常運行。

下面一個例子(代碼改自 DASP 中例子):

pragma solidity ^0.4.10;

contract PresidentOfCountry {
    address public president;
    uint256 price;

    function PresidentOfCountry(uint256 _price) {
        require(_price > 0);
        price = _price;
        president = msg.sender;
    }

    function becomePresident() payable {
        require(msg.value >= price); // must pay the price to become president
        president.transfer(price);   // we pay the previous president
        president = msg.sender;      // we crown the new president
        price = price * 2;           // we double the price to become president
    }
}

一個簡單的類似于 KingOfEther 的合約,按合約的正常邏輯任何出價高于合約當前 price 的都能成為新的 president,原有合約里的存款會返還給上一人 president,并且這里也使用了 transfer() 來進行 Ether 轉賬,看似沒有問題的邏輯,但不要忘了,以太坊中有兩類賬戶類型,如果發起 becomePresident() 調用的是個合約賬戶,并且成功獲取了 president,如果其 fallback() 函數惡意進行了類似 revert() 這樣主動跑出錯誤的操作,那么其他賬戶也就無法再正常進行 becomePresident 邏輯成為 president 了。

簡單的攻擊代碼如下:

contract Attack {
    function () { revert(); }

    function Attack(address _target) payable {
        _target.call.value(msg.value)(bytes4(keccak256("becomePresident()")));
    }
}

使用 remix-ide 模擬攻擊流程:

6. Bad Randomness - 可預測的隨機處理

偽隨機問題一直都存在于現代計算機系統中,但是在開放的區塊鏈中,像在以太坊智能合約中編寫的基于隨機數的處理邏輯感覺就有點不切實際了,由于人人都能訪問鏈上數據,合約中的存儲數據都能在鏈上查詢分析得到。如果合約代碼沒有嚴格考慮到鏈上數據公開的問題去使用隨機數,可能會被攻擊者惡意利用來進行 “作弊”。

摘自 DASP 的代碼塊:

uint256 private seed;

function play() public payable {
    require(msg.value >= 1 ether);
    iteration++;
    uint randomNumber = uint(keccak256(seed + iteration));
    if (randomNumber % 2 == 0) {
        msg.sender.transfer(this.balance);
    }
}

這里 seed 變量被標記為了私有變量,前面有說過鏈上的數據都是公開的,seed 的值可以通過掃描與該合約相關的 TX 來獲得。獲取 seed 值后,同樣的 iteration 值也是可以得到的,那么整個 uint(keccak256(seed + iteration)) 的值就是可預測的了。

就 DASP 里面提到的,還有一些合約喜歡用 block.blockhash(uint blockNumber) returns (bytes32) 來獲取一個隨機哈希,但是這里切記不能使用 block.number 也就是當前塊號來作為 blockNumber 的值,因為在官方文檔中明確寫了:

block.blockhash(uint blockNumber) returns (bytes32): hash of the given block - only works for 256 most recent blocks excluding current

意思是說 block.blockhash() 只能使用近 256 個塊的塊號來獲取 Hash 值,并且還強調了不包含當前塊,如果使用當前塊進行計算 block.blockhash(block.numbber) 其結果始終為 0x0000000.....

同樣的也不能使用 block.timestamp, now 這些可以由礦工控制的值來獲取隨機數。

一切鏈上的數據都是公開的,想要獲取一個靠譜的隨機數,使用鏈上的數據看來是比較難做到的了,這里有一個獨立的項目 Oraclize 被設計來讓 Smart Contract 與互聯網進行交互,有興趣的同學可以深入了解一下。(附上基于 Oraclize 的隨機數獲取方法 randomExample

7. Front Running - 提前交易

“提前交易”,其實在學習以太坊智能合約漏洞之前,我還并不知道這類漏洞類型或者說是攻擊手法(畢竟我對金融一竅不通)。簡單來說,“提前交易”就是某人提前獲取到交易者的具體交易信息(或者相關信息),搶在交易者完成操作之前,通過一系列手段(通常是提高報價)來搶在交易者前面完成交易。

在以太坊中所有的 TX 都需要經過確認才能完全記錄到鏈上,而每一筆 TX 都需要帶有相關手續費,而手續費的多少也決定了該筆 TX 被礦工確認的優先級,手續費高的 TX 會被優先得到確認,而每一筆待確認的 TX 在廣播到網絡之后就可以查看具體的交易詳情,一些涉及到合約調用的詳細方法和參數可以被直接獲取到。那么這里顯然就有 Front-Running 的隱患存在了,示例代碼就不舉了,直接上圖(形象一點):

etherscan.io 就能看到還未被確認的 TX,并且能給查看相關數據:

(當然了,為了防止信息明文存儲在 TX 中,可以對數據進行加密和簽名)

8. Time Manipulation

“時間篡改”(DASP 給的名字真抽象 XD),說白了一切與時間相關的漏洞都可以歸為 “Time Manipulation”。在 Solidity 中,block.timestamp (別名 now)是受到礦工確認控制的,也就是說一些合約依賴于 block.timestamp 是有被攻擊利用的風險的,當攻擊者有機會作為礦工對 TX 進行確認時,由于 block.timestamp 可以控制,一些依賴于此的合約代碼即預知結果,攻擊者可以選擇一個合適的值來到達目的。(當然了 block.timestamp 的值通常有一定的取值范圍,出塊間隔有規定 XD)

該類型我還沒有找到一個比較好的例子,所以這里就不給代碼演示了。:)

  1. Short Address Attack - 短地址攻擊 在我著手測試和復現合約漏洞類型時,短地址攻擊我始終沒有在 remix-ide 上測試成功(道理我都懂,咋就不成功呢?)。雖然漏洞沒有復現,但是漏洞原理我還是看明白了,下面就詳細地說明一下短地址攻擊的漏洞原理吧。

首先我們以外部調用 call() 為例,外部調用中 msg.data 的情況:

在 remix-ide 中部署此合約并調用 callFunc() 時,可以得到日志輸出的 msg.data 值:

0x4142c000000000000000000000000000000000000000000000000000000000000000001e

其中 0x4142c000 為外部調用的函數名簽名頭 4 個字節(bytes4(keccak256("foo(uint32,bool)"))),而后面 32 字節即為傳遞的參數值,msg.data 一共為 4 字節函數簽名加上 32 字節參數值,總共 4+32 字節。

看如下合約代碼:

pragma solidity ^0.4.10;

contract ICoin {
    address owner;
    mapping (address => uint256) public balances;

    modifier OwnerOnly() { require(msg.sender == owner); _; }

    function ICoin() { owner = msg.sender; }
    function approve(address _to, uint256 _amount) OwnerOnly { balances[_to] += _amount; }
    function transfer(address _to, uint256 _amount) {
        require(balances[msg.sender] > _amount);
        balances[msg.sender] -= _amount;
        balances[_to] += _amount;
    }
}

具體代幣功能的合約 ICoin,當 A 賬戶向 B 賬戶轉代幣時調用 transfer() 函數,例如 A 賬戶(0x14723a09acff6d2a60dcdf7aa4aff308fddc160c)向 B 賬戶(0x4b0897b0513fdc7c541b6d9d7e929c4e5364d2db)轉 8 個 ICoin,msg.data 數據為:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函數簽名
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2db  -> B 賬戶地址(前補 0 補齊 32 字節)
0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前補 0 補齊 32 字節)

那么短地址攻擊是怎么做的呢,攻擊者找到一個末尾是 00 賬戶地址,假設為 0x4b0897b0513fdc7c541b6d9d7e929c4e5364d200,那么正常情況下整個調用的 msg.data 應該為:

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函數簽名
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200  -> B 賬戶地址(注意末尾 00)
0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前補 0 補齊 32 字節)

但是如果我們將 B 地址的 00 吃掉,不進行傳遞,也就是說我們少傳遞 1 個字節變成 4+31+32

0xa9059cbb  -> bytes4(keccak256("transfer(address,uint256)")) 函數簽名
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d2  -> B 地址(31 字節)
0000000000000000000000000000000000000000000000000000000000000008  -> 0x8(前補 0 補齊 32 字節)

當上面數據進入 EVM 進行處理時,會猶豫參數對齊的問題后補 00 變為:

0xa9059cbb
0000000000000000000000004b0897b0513fdc7c541b6d9d7e929c4e5364d200
0000000000000000000000000000000000000000000000000000000000000800

也就是說,惡意構造的 msg.data 通過 EVM 解析補 0 操作,導致原本 0x8 = 8 變為了 0x800 = 2048

上述 EVM 對畸形字節的 msg.data 進行補位操作的行為其實就是短地址攻擊的原理(但這里我真的沒有復現成功,希望有成功的同學聯系我一起交流)。

短地址攻擊通常發生在接受畸形地址的地方,如交易所提幣、錢包轉賬,所以除了在編寫合約的時候需要嚴格驗證輸入數據的正確性,而且在 Off-Chain 的業務功能上也要對用戶所輸入的地址格式進行驗證,防止短地址攻擊的發生。

同時,老外有一篇介紹 [Analyzing the ERC20 Short Address Attack (https://ericrafaloff.com/analyzing-the-erc20-short-address-attack/) 原理的文章我覺得非常值得學習。

- Unknown Unknowns - 其他未知,:) 未知漏洞,沒啥好講的,為了跟 DASP 保持一致而已

III. 自我思考

前后花了 2 周多的時間去看以太坊智能合約相關知識以及本文(上/下)的完成,久違的從 0 到 1 的感覺又回來了。多的不說了,我應該也算是以太坊智能合約安全入門了吧,近期出的一些合約漏洞事件也在跟,分析和復現也是完全 OK 的,漏洞研究原理不變,變得只是方向而已。期待同更多的區塊鏈安全研究者交流和學習。

1. 以太坊中合約賬戶的私鑰在哪?可以不通過合約賬戶代碼直接操作合約賬戶中的 Ether 嗎?

StackExchange 上有相關問題的回答 “Where is the private key for a contract stored?”,但是我最終也沒有看到比較官方的答案。但可以知道的就是,合約賬戶是由部署時的合約代碼控制的,不確定是否有私鑰可以直接控制合約進行 Ether 相關操作(講道理應該是不行的)。

2. 使用 keccak256() 進行函數簽名時的坑?- 參數默認位數標注

在使用 keccak256 對帶參函數進行簽名時,需要注意要嚴格制定參數類型的位數,如:

function somefunc(uint n) { ... }

對上面函數進行簽名時,定義時參數類型為 uint,而 uint 默認為 256 位,也就是 uint256,所以在簽名時應該為 keccak256("somefunc(uint256)"),千萬不能寫成 keccak256("somefunc(uint)")

參考鏈接:

http://solidity.readthedocs.io/en/v0.4.21/units-and-global-variables.html#special-variables-and-functions
https://github.com/oraclize/ethereum-api
https://ericrafaloff.com/analyzing-the-erc20-short-address-attack/


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/607/