3、重入攻击(Reentrancy):攻击原理、经典DAO攻击案例、防御手段

重入攻击,说白了就是合约在调用外部合约时,被对方“反咬一口”。

我刚开始学Solidity时,觉得这玩意儿挺神奇的——明明代码逻辑没问题,怎么钱就被掏空了呢?后来踩过坑才明白,问题出在“状态更新”和“外部调用”的顺序上。

3.1 攻击原理:为什么会被“重入”?

先看一个简单的提款合约:

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

    function withdraw() public {
        uint amount = balances[msg.sender];
        // 先转账
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);
        // 后更新状态
        balances[msg.sender] = 0;
    }
}

你发现问题了吗?转账在前,状态更新在后

如果 msg.sender 是一个恶意合约,它的 receive()fallback() 函数里,可以再次调用 withdraw()。这时候 balances[msg.sender] 还没清零,所以第二次提款依然能成功。

嗯,这就是重入攻击的核心——利用状态滞后,反复提取同一笔资金

关键点:攻击者通过递归调用,在每次状态更新前反复执行提款逻辑,直到合约余额被掏空。

为什么会这样?因为EVM是单线程的,但外部调用会暂停当前合约的执行,把控制权交给被调用的合约。如果被调用合约不“老实”,它就能在你更新状态之前,再次调用你的函数。

3.2 经典DAO攻击案例:以太坊历史上的“至暗时刻”

2016年6月,The DAO被攻击,损失约360万ETH。这件事直接导致了以太坊硬分叉,分裂成ETH和ETC。

我当时还在学Solidity,看到这个新闻时整个人都懵了——一个智能合约居然能因为代码逻辑漏洞,被抽走几亿美元?

DAO合约的提款逻辑大致是这样的:

// 简化版DAO合约(有漏洞)
contract DAO {
    mapping(address => uint) public credit;

    function withdraw(uint amount) public {
        if (credit[msg.sender] >= amount) {
            // 先转账
            msg.sender.call{value: amount}("");
            // 后扣减
            credit[msg.sender] -= amount;
        }
    }
}

攻击者部署了一个恶意合约,在 receive() 里递归调用 withdraw()。每次调用时,credit[msg.sender] 都没变,所以可以反复提取。

结果呢?攻击者只用了几个区块,就把DAO合约里的ETH几乎全提走了。

教训:永远不要假设外部合约是“诚实”的。任何外部调用都可能成为攻击入口。

3.3 防御手段:怎么防住重入攻击?

防御重入攻击,主要有两种成熟方案。我个人习惯两种都用,形成纵深防御。

3.3.1 检查-效果-交互模式(Checks-Effects-Interactions)

这是最基础的防御思路。核心原则就一句话:先检查条件,再更新状态,最后做外部交互

// 安全的提款函数
function withdrawSafe() public {
    // 1. 检查(Checks)
    uint amount = balances[msg.sender];
    require(amount > 0, "余额不足");

    // 2. 效果(Effects)——先更新状态
    balances[msg.sender] = 0;

    // 3. 交互(Interactions)——最后转账
    (bool success, ) = msg.sender.call{value: amount}("");
    require(success);
}

你看,状态更新在转账之前。即使攻击者重入,balances[msg.sender] 已经是0了,第二次提款会直接失败。

我在项目中遇到过有人把状态更新写在转账之后,理由是“为了代码可读性”。嗯,这种“可读性”代价太大了——一次重入攻击就能让合约归零。

3.3.2 重入锁(Reentrancy Guard)

重入锁是另一种常用手段。说白了就是加一把“门闩”,防止函数被重复进入。

// 使用OpenZeppelin的ReentrancyGuard
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";

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

    function withdraw() public nonReentrant {
        uint amount = balances[msg.sender];
        require(amount > 0);

        balances[msg.sender] = 0;

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

nonReentrant 修饰符会在函数入口处设置一个锁变量,函数执行完再解锁。如果重入发生,锁还没解开,第二次调用会被直接拒绝。

小技巧:重入锁虽然好用,但别滥用。它会影响合约的互操作性——比如某些跨合约调用场景,重入锁可能会误伤正常逻辑。

3.4 两种防御手段对比

防御手段 原理 优点 缺点
检查-效果-交互 先更新状态,后外部调用 零额外Gas,逻辑清晰 需要开发者严格遵守顺序
重入锁 用锁变量防止重入 防御彻底,不易遗漏 增加Gas成本,可能影响互操作性

我个人建议:优先使用检查-效果-交互模式,这是最轻量、最优雅的方案。如果合约逻辑复杂,或者涉及多步外部调用,再叠加重入锁作为保险。

3.5 核心逻辑流程图

下面这张图展示了重入攻击的完整流程,以及防御手段如何阻断攻击路径:

重入攻击流程与防御 攻击者合约 受害者合约 ① withdraw() 转账(call) 状态更新(延迟) 转账ETH ② 重入 withdraw() 防御手段 ✅ 检查-效果-交互 ✅ 重入锁

3.6 避坑指南

我曾经审计过一个DeFi项目,开发者用了重入锁,但只加在了提款函数上。结果呢?攻击者通过另一个函数——比如 claimRewards()——间接调用了提款逻辑,绕过了锁。

所以记住:重入锁要加在所有可能被外部调用的函数上,不只是提款函数。

另外,calldelegatecall 也要小心。前者会触发重入,后者虽然不会改变调用者的状态,但可能被用来操纵存储布局。嗯,这是另一个话题了,我们后面再聊。

一句话总结:先更新状态,后外部调用;拿不准就加重入锁。别给攻击者留任何“反咬一口”的机会。


公众号:蓝海资料掘金营,微信deep3321