Solidity智能合约的重入攻击

Posted mutourend

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Solidity智能合约的重入攻击相关的知识,希望对你有一定的参考价值。

1. 何为重入攻击?

如果一个计算机程序或子程序在执行过程中被中断,然后在前一次调用完成之前可被安全地再次调用,则称之为可重入。
中断的原因可为:

  • 内部动作,如jump 或 call;
  • 外部动作,如interrupt 或 signal。

一旦重入调用完成后,之前的调用就将恢复执行。使用某些代码执行此操作将导致重入攻击。

智能合约在其正常执行过程中可能会通过:

  • function call
  • 或 simply transfer Ether

来调用其它合约。
这些合约可:

  • 自己调用其它合约
  • call back to the contract that called them
  • call back to any other in the call stack

这样的合约可称为是可重入的。
重入本身不是问题,但当其会引起状态不一致时,就有问题了。

当合约的相关不变量为true时,可认为state是一致的。如ERC20中,其主要不变量为:所有的balance之和不会超过已知的总供应量。

通常情况下,函数假设其开始运行时合约状态是一致的,函数运行后的状态也仍然是保持一致的。而在函数执行过程中,只要没人发现,其状态是可能是不一致的。此时,就有可能存在重入攻击的问题。
因此,不仅应要求执行后的状态一致,还应要求在每一个可能的重入点都保持状态一致。

2. 重入攻击举例

以下代码存在重入攻击问题:

function withdraw(uint _amount) public {
  if (amount <= balances[msg.sender]) {
    msg.sender.call.value(_amount)();
    balances[msg.sender] -= _amount;
  }
}

重入攻击的点为:

  • 先发送了ether,然后才更新sender的balance
  • 当通过call.value()来调用代码时,执行的代码会被赋予所有可用的gas。

在这里插入图片描述
将一直执行直到没有ether或gas。

详细可参看 重入攻击概述,借助Remix工具来实操理解重入攻击。

3. 重入攻击的分类

重入攻击可分为:

  • single function 重入攻击
function withdraw(uint _amount) public {
  if (amount <= balances[msg.sender]) {
    msg.sender.call.value(_amount)();
    balances[msg.sender] -= _amount;
  }
}
  • cross-function 重入攻击
function transfer(address to, uint amount) external {
    if (balances[msg.sender] >= amount) {
        balances[to] += amount;
        balances[msg.sender] -= amount;
    }
}
function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(msg.sender.call.value(amount)());
    balances[msg.sender] = 0;
}

此时,与single function 重入攻击类似,withdraw将调用the attacker’s fallback function。不同之处在于fallback函数将调用transfer函数而不是递归调用withdraw函数。由于在该调用之前没有将sender的balance设为0,transfer函数将transfer a balance that has already been spent。

4. 重入攻击的破坏力

最有影响的是2016年的The DAO攻击,造成了3600个Ether的损失,使得以太坊进行了硬分叉。详细可参见:

5. 如何避免重入攻击

推荐的做法有:

  • 采用Checks-Effects-Interactions模式
function withdraw(uint _amount) public {
  if (amount <= balances[msg.sender]) { //Checks
    balances[msg.sender] -= _amount; //Effects
    msg.sender.call.value(_amount)(); //Interactions
  }
}
  • 使用更安全的transfer或send函数(send为transfer的底层实现)来发送ether,应transfer和send函数的gas仅有2300,这点gas仅够捕获一个event,所以将无法进行可重入攻击。
function withdraw(uint _amount) public {
  if (amount <= balances[msg.sender]) {
    msg.sender.transfer(_amount)();
    balances[msg.sender] -= _amount;
  }
}
  • 使用互斥锁:添加一个在代码执行过程中锁定合约的状态变量,可防止重入调用
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;
    }
 }
  • 使用OpenZeppelin官方的ReentrancyGuard合约中的nonReentrant modifier:
    若在合约的执行过程中无法确保不变量成立,应尽量避免调用其它(untrusted)合约。如果不得不调用,可使用reentrancy guard来避免reentrancy问题。
    在函数中增加nonReentrant modifier可保证其不可重入,任何对该函数的重入操作都将以revert the call的方式来拒绝
    在这里插入图片描述
    当合约中有多个函数时,由于modifier的粒度在单个函数,若想完全避免重入,应对每个函数都添加nonReentrant modifier。否则,仍然可以通过其他函数来重入然后发起重入攻击,若该函数可能破坏不变量。
    在这里插入图片描述
    在为函数添加nonReentrant modifier时,应注意,合约中的public变量将自动有一个getter函数来读取其值,没有办法为该getter函数添加modifier。大多数情况下,这不会引起重入问题,但是仍然值得注意,因为其它合约可通过getter函数观察到由于broken invariants导致的不一致状态。
// 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;
    }
}
  • 采用pull payment模式 来替代 push fund to a receiver。OpenZeppelin提供了PullPayment合约。其提供了_asyncTransfer函数,与transfer类似。然而,它不会将资金发送给接收者,而是将其转移到托管合约中。此外,PullPayment还为接收者提供了一个公共功能来提取(pull)他们的支付:withdrawPayments

附录-Solidity fallback函数

fallback函数为合约中的一种特殊函数,其具有如下特性:

  • It is called when a non-existent function is called on the contract.
  • It is required to be marked external.
  • It has no name. (Solidity合约中最多仅能有一个unnamed function。)
  • It has no arguments
  • It can not return any thing.
  • It can be defined one per contract.
  • If not marked payable, it will throw exception if contract receives plain ether without data.( is executed whenever a contract would receive plain Ether, without any data, in this case it must be payable.)

具体举例为:

pragma solidity ^0.5.0;

contract Test {
   uint public x ;
   function() external { x = 1; }    
}
contract Sink {
   function() external payable { }
}
contract Caller {
   function callTest(Test test) public returns (bool) {
      (bool success,) = address(test).call(abi.encodeWithSignature("nonExistingFunction()"));
      require(success);
      // test.x is now 1

      address payable testPayable = address(uint160(address(test)));

      // Sending ether to Test contract,
      // the transfer will fail, i.e. this returns false here.
      return (testPayable.send(2 ether));
   }
   function callSink(Sink sink) public returns (bool) {
      address payable sinkPayable = address(sink);
      return (sinkPayable.send(2 ether));
   }
}

参考资料

[1] Protect Your Solidity Smart Contracts From Reentrancy Attacks
[2] Reentrancy Attack in a Smart contract
[3] Reentrancy After Istanbul
[4] 重入攻击概述
[5] Solidity fallback函数

以上是关于Solidity智能合约的重入攻击的主要内容,如果未能解决你的问题,请参考以下文章

智能合约常见攻击方式

区块链 智能合约安全 重入攻击(re-entrancy attack)DAO incident

Solidity地址支付方法SendTransfer和Call的使用

solidity智能合约中tx.origin的正确使用场景

区块链Solidity智能合约与Solidity介绍

第一行代码:以太坊-使用Solidity语言开发和测试智能合约