第三章:重入攻击(Reentrancy)——原理剖析、经典DAO攻击案例、防护方案

重入攻击,这名字听着挺学术,说白了就是——你刚把钱打给别人,别人还没等你更新账本,又跑回来找你要钱

我最早接触这个漏洞,是在一次内部代码审计中。当时看到一个合约的提现函数,先转账、后更新余额。我心里咯噔一下:这不就是等着被薅羊毛吗?果不其然,测试网上一跑,攻击合约直接递归调用,把池子抽干了。

好,咱们今天就把这个经典漏洞彻底讲透。

3.1 原理剖析:到底什么是重入攻击?

重入攻击的核心,在于外部调用与状态更新的顺序搞反了

正常的逻辑应该是:
先扣钱(更新余额)→ 再给东西(转账)。
但很多新手写合约,习惯写成:
先给东西(转账)→ 再扣钱(更新余额)。

你想想看,如果接收方是一个攻击合约,它收到钱的那一刻,就会触发一个fallback函数。这个函数里,攻击者可以再次调用你的提现函数。而你的合约此时还没更新余额,所以攻击者还能再提一次。如此循环,直到你的合约被掏空。

嗯,这里要注意:重入攻击不是一次攻击,而是一连串的攻击。每一次递归调用,都在你的合约状态更新之前发生。

关键点:

  • 攻击条件:外部调用(如call.value())发生在状态更新之前
  • 攻击载体:接收方的fallbackreceive函数
  • 攻击结果:合约余额被多次提取,远超应得金额

3.2 经典DAO攻击案例:史上最贵的漏洞

说到重入攻击,就绕不开2016年的The DAO事件。那次攻击直接导致以太坊硬分叉,损失约360万ETH。按今天的价格算,嗯,几百亿美金吧。

我当时还在读白皮书,看到这个案例时整个人都震惊了——一个如此简单的逻辑错误,居然能撼动整个区块链世界。

咱们来看一个简化版的DAO合约:

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

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

    function withdraw(uint _amount) public {
        require(balances[msg.sender] >= _amount);
        
        // 漏洞在这里:先转账
        msg.sender.call{value: _amount}("");
        
        // 后更新状态
        balances[msg.sender] -= _amount;
    }
}

攻击合约长这样:

contract Attack {
    SimpleDAO public target;
    uint public attackCount;

    constructor(address _target) {
        target = SimpleDAO(_target);
    }

    // 攻击入口
    function attack() public payable {
        target.deposit{value: 1 ether}();
        target.withdraw(1 ether);
    }

    // fallback函数:每次收到ETH就重入
    receive() external payable {
        if (address(target).balance >= 1 ether) {
            attackCount++;
            target.withdraw(1 ether);
        }
    }
}

攻击流程是这样的:

  1. 攻击者先存入1个ETH,获得1个ETH的余额
  2. 调用withdraw(1 ether),合约检查余额通过
  3. 合约向攻击合约转账1 ETH,触发receive()
  4. 攻击合约的receive()再次调用withdraw(1 ether)
  5. 此时攻击者的余额还是1 ETH(因为还没更新),检查再次通过
  6. 重复步骤3-5,直到目标合约余额不足

为什么会这样?因为状态更新被放在了转账之后。每次递归调用时,攻击者的余额都还是初始值。

我曾经在审计中见过类似的代码:一个NFT市场合约,先给买家转NFT,再扣钱。结果攻击者利用ERC721的onERC721Received回调,实现了重入攻击。所以记住:任何外部调用都可能成为重入的入口,不仅仅是ETH转账。

3.3 防护方案:检查-生效-交互模式(Checks-Effects-Interactions)

这个模式,我个人习惯叫它「CEI模式」。它是Solidity社区公认的防重入标准做法。说白了就是三个步骤,顺序不能乱:

步骤 做什么 示例
1. 检查(Checks) 验证所有前置条件 require(balances[msg.sender] >= _amount)
2. 生效(Effects) 更新合约内部状态 balances[msg.sender] -= _amount
3. 交互(Interactions) 执行外部调用 msg.sender.call{value: _amount}("")

修复后的代码:

// 安全的合约:遵循CEI模式
contract SafeDAO {
    mapping(address => uint) public balances;

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

    function withdraw(uint _amount) public {
        // 1. 检查
        require(balances[msg.sender] >= _amount, "余额不足");
        
        // 2. 生效:先更新状态
        balances[msg.sender] -= _amount;
        
        // 3. 交互:再转账
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
    }
}

你看,只是把balances[msg.sender] -= _amount移到了转账之前,整个逻辑就安全了。攻击者再递归调用时,余额已经扣掉了,require直接拦住。

我建议:除了CEI模式,还可以考虑以下防护措施:

  • 重入锁(Reentrancy Guard):OpenZeppelin提供了ReentrancyGuard,用nonReentrant修饰符防止函数重入。适合处理复杂逻辑,或者你实在不确定顺序对不对的情况。
  • 限制外部调用:能用transfer(2300 gas限制)就别用call。但注意,transfer在2023年后已被部分团队弃用,因为gas成本变化可能导致失败。
  • 避免跨函数重入:有时候重入发生在不同函数之间。比如函数A调用了外部合约,外部合约又调用了函数B。这种情况CEI模式可能不够,需要加锁。

3.4 避坑指南:我踩过的那些坑

我曾经审计过一个DeFi项目,开发者说「我用了CEI模式,肯定安全」。但我一看代码,发现他在一个循环里调用了外部合约。循环中的每次迭代都涉及外部调用,而状态更新只在循环结束后才做。嗯,这其实是一种变相的重入风险——虽然单次调用没问题,但多次调用之间状态不一致。

还有一次,我看到有人用require(balances[msg.sender] >= _amount)做检查,但balances的更新却放在了另一个内部函数里。结果那个内部函数被其他路径绕过了。所以记住:检查、生效、交互必须在同一个函数内,按顺序写在一起,不要分散到不同函数里。

总结一下防重入的核心原则:

  • 先更新状态,后发起外部调用——这是铁律
  • 不确定时,加重入锁
  • 审计时重点关注所有.call().delegatecall().send().transfer()的位置
  • 不要相信任何外部合约的回调

好了,重入攻击这块就讲到这里。下一章咱们聊聊访问控制漏洞——说白了就是「谁有权干什么」的问题。这个坑,比重入还常见。