重入攻击:原理剖析、经典DAO攻击案例与防御策略

大家好,我是你们的老朋友。今天咱们来聊聊智能合约安全里最经典、也最致命的一个漏洞——重入攻击

说实话,我入行智能合约安全的第一课,就是研究DAO攻击。那会儿我还在想,一个简单的转账函数,怎么就能把几千万美元给转走了?后来自己动手复现了一遍,才真正明白——合约的执行顺序,有时候比逻辑本身更重要

一、重入攻击的原理剖析

重入攻击,说白了就是:合约A调用了合约B,合约B在回调合约A的时候,合约A的状态还没更新完

你想想看,如果合约A先转账给合约B,再更新自己的余额。那合约B在收到转账时,就可以再次调用合约A的转账函数。这时候合约A的余额还没扣呢,它以为你还有钱,于是又转了一次。如此循环,直到合约A的余额被掏空。

为什么会这样?因为以太坊的调用是同步的。合约A调用合约B时,合约B的代码会立即执行。如果合约B的fallback函数里又调用了合约A,那就形成了递归调用。

我举个例子,大家感受一下:

// 有漏洞的合约
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;
    }
}

这段代码的问题在哪?先转账,后更新余额。攻击者可以在自己的合约里写一个fallback函数,每次收到以太坊就再次调用withdraw。因为余额还没扣,所以每次都能通过require检查。

核心要点:重入攻击的本质是状态更新滞后。合约在外部调用完成之后才更新内部状态,给了攻击者可乘之机。

二、经典DAO攻击案例

说到重入攻击,就不得不提2016年的DAO事件。那是我入行前两年发生的事,但至今仍是每个安全工程师的必修课。

DAO合约实现了一个拆分功能(splitDAO),允许用户提取自己的以太坊。它的逻辑大致是这样的:

  1. 检查用户是否有足够的余额
  2. 向用户发送以太坊
  3. 更新用户的余额

嗯,你猜到了——又是先转账,后更新

攻击者写了一个攻击合约,在fallback函数里递归调用splitDAO。每次调用都触发一次转账,而余额始终没变。就这样,攻击者从DAO合约里转走了大约360万个以太坊。按当时的币价算,价值超过6000万美元。

我记得第一次看到攻击交易记录时,整个人都愣住了。一个简单的递归调用,居然能造成这么大的损失。从那以后,我写合约时都会反复检查:状态更新是不是在外部调用之前完成的?

警告:DAO攻击的教训告诉我们,不要相信任何外部合约。你的合约被调用时,调用者可能是恶意的。它可以在你的执行过程中再次调用你。

三、防御策略

好了,原理和案例都讲完了。接下来咱们聊聊怎么防。我个人习惯用两种方法:检查-效应-交互模式重入锁

3.1 检查-效应-交互模式

这个模式的名字有点长,但核心思想很简单:先检查条件,再更新状态,最后才做外部调用

说白了就是:把状态更新挪到转账之前。

// 安全的合约
contract SafeBank {
    mapping(address => uint) public balances;

    function withdraw(uint _amount) public {
        // 第一步:检查
        require(balances[msg.sender] >= _amount, "余额不足");
        
        // 第二步:效应(更新状态)
        balances[msg.sender] -= _amount;
        
        // 第三步:交互(外部调用)
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
    }
}

你看,现在即使攻击者在fallback里再次调用withdraw,require检查也会失败——因为余额已经被扣掉了。

我在项目中遇到过好几次这样的情况:开发同学觉得「先转账再更新」和「先更新再转账」没什么区别。其实区别大了去了。你想想看,如果转账失败了呢?先更新再转账,转账失败时余额已经扣了,用户就亏了。所以正确的做法是:先更新状态,再转账。如果转账失败,用revert回滚状态

小技巧:如果你用的是Solidity 0.8.0以上版本,可以用address.transfer()address.send()。它们只转发2300 gas,不够攻击者做复杂的递归调用。但要注意,这只能防住gas消耗大的攻击,不是万能的。

3.2 重入锁

重入锁是另一种常见的防御手段。它的原理很简单:用一个布尔变量标记合约是否正在执行中。如果正在执行,就拒绝新的调用

OpenZeppelin提供了一个现成的ReentrancyGuard合约,咱们可以直接用:

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

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

    function withdraw(uint _amount) public nonReentrant {
        require(balances[msg.sender] >= _amount, "余额不足");
        
        (bool success, ) = msg.sender.call{value: _amount}("");
        require(success, "转账失败");
        
        balances[msg.sender] -= _amount;
    }
}

注意看,这里用了nonReentrant修饰符。它的作用就是:在函数执行期间锁定合约,防止递归调用

我曾经在一个审计项目里看到,开发同学自己写了一个重入锁,但锁的变量是public的。攻击者可以直接读取锁的状态,然后绕过检查。嗯,这里要注意:重入锁的变量一定要是private的,而且不能有任何外部函数可以修改它

四、两种防御策略的对比

防御策略 优点 缺点 适用场景
检查-效应-交互模式 简单、gas消耗低、不需要额外状态 需要开发者严格遵守顺序,容易遗漏 所有涉及外部调用的函数
重入锁 防御彻底、不依赖代码顺序 增加gas消耗、可能与其他合约交互冲突 复杂合约、跨合约调用场景

我个人建议:两种方法一起用。检查-效应-交互模式作为第一道防线,重入锁作为第二道防线。这样即使你忘了更新状态顺序,重入锁也能兜底。

最佳实践:

  • 所有外部调用(call、delegatecall、transfer、send)都放在函数最后
  • 使用OpenZeppelin的ReentrancyGuard,不要自己造轮子
  • 在测试中模拟递归调用,确保防御生效

五、避坑指南

最后,分享几个我踩过的坑:

  • 我曾经以为只有转账函数才有重入风险。其实任何外部调用都可能被重入,包括调用其他合约的函数。所以只要你的合约调用了外部合约,就要考虑重入问题。
  • 我曾经在构造函数里用了重入锁。构造函数里不能用nonReentrant,因为合约还没部署完。这个坑让我debug了一下午。
  • 我曾经见过有人用modifier来更新状态。比如在modifier里扣余额,然后在函数体里转账。这样虽然顺序对了,但可读性很差,容易出错。我建议把状态更新写在函数体里,保持逻辑清晰。

好了,关于重入攻击的内容就讲到这里。下一章咱们聊聊访问控制漏洞——为什么你的管理员权限可能被任何人调用。到时候见!