3. 重入攻击防御:从原理到实战

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

我记得刚入行那会儿,第一次在测试网上复现 The DAO 攻击,看着余额被一点点掏空,后背直冒冷汗。嗯,这玩意儿要是发生在主网上,后果不堪设想。

3.1 重入攻击原理

先看一个最经典的漏洞合约:

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

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

        // 第1步:先转账
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success);

        // 第2步:后更新余额
        balances[msg.sender] = 0;
    }
}

问题出在哪?顺序错了

合约先转账,再更新余额。攻击者可以在 receive()fallback() 函数里再次调用 withdraw()。这时候余额还没清零,所以又能提一次。如此循环,直到把合约掏空。

攻击流程拆解:

  1. 攻击者存款 1 ETH
  2. 调用 withdraw(),合约检查余额为 1 ETH
  3. 合约转账 1 ETH 给攻击者
  4. 攻击者的 receive() 被触发,再次调用 withdraw()
  5. 此时 balances[攻击者] 还是 1 ETH(还没更新)
  6. 合约又转出 1 ETH...
  7. 循环直到 gas 耗尽或合约余额归零

3.2 The DAO 事件:血的教训

2016 年,The DAO 被重入攻击,损失约 360 万 ETH。按当时价格算,超过 6000 万美元。

为什么会这样?说白了就是 splitDAO() 函数在转账后才更新用户余额。攻击者利用递归调用,把本该只能取一次的钱,取了 N 次。

我在分析那次攻击的交易记录时,发现攻击者其实只用了 7 次递归调用就掏空了合约。你想想看,如果当时用了 Checks-Effects-Interactions 模式,这 6000 万美金根本不会丢。

对比项 The DAO 合约 安全合约
转账顺序 先转账,后更新状态 先更新状态,后转账
防重入机制 使用防重入锁
结果 被掏空 安全

3.3 防御方案一:Checks-Effects-Interactions 模式

这是最基础、最推荐的防御方式。核心思想就一句话:先检查,再更新状态,最后才跟外部交互

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

    function withdraw() public {
        // Checks:检查条件
        uint amount = balances[msg.sender];
        require(amount > 0);

        // Effects:更新状态(先改余额!)
        balances[msg.sender] = 0;

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

你看,顺序一变,问题就解决了。攻击者就算在 receive() 里再调用 withdraw(),余额已经是 0,require 直接拦住。

我的习惯:写任何涉及转账的函数,我都会先问自己一句:「状态更新了吗?」。养成肌肉记忆,比啥都强。

3.4 防御方案二:防重入锁(Reentrancy Guard)

有时候业务逻辑复杂,光靠 CEI 模式不够用。比如你要在转账前后做一些额外操作,这时候就需要一把「锁」。

contract GuardedBank {
    bool private locked = false;

    modifier noReentrant() {
        require(!locked, "Reentrant call detected!");
        locked = true;
        _;
        locked = false;
    }

    mapping(address => uint) public balances;

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

        // 这里可以做其他操作
        emit BeforeWithdraw(msg.sender, amount);

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

        emit AfterWithdraw(msg.sender, amount);
    }
}

锁的原理很简单:进入函数时上锁,退出时解锁。如果攻击者想递归调用,一进来就被 require 拦住。

我曾经踩过的坑:用锁的时候,一定要确保所有可能被递归调用的函数都用了同一个锁。有一次我忘了给某个 internal 函数加锁,结果攻击者绕过了外部函数,直接调内部函数完成了重入。嗯,那次审计报告写得我脸都红了。

3.5 OpenZeppelin 的 ReentrancyGuard

实际开发中,我建议直接用 OpenZeppelin 的 ReentrancyGuard。人家经过无数次审计,比自己手写的靠谱。

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

contract MyBank 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);
    }
}

只需要继承 ReentrancyGuard,给函数加上 nonReentrant 修饰符就行。省心又安全。

3.6 两种方案的对比与选择

方案 优点 缺点 适用场景
CEI 模式 无额外 gas 开销,逻辑清晰 对复杂业务不够灵活 简单的提款、转账函数
防重入锁 通用性强,适合复杂逻辑 增加 gas 成本,可能被忘记解锁 多步操作、跨合约调用

我的建议:能上 CEI 模式就上 CEI 模式。实在不行,再加锁。两个一起用,也不是不行——我就经常这么干,双重保险。

3.7 避坑指南

  • 不要相信外部合约:任何 calldelegatecallsendtransfer 都可能成为攻击入口
  • 注意跨函数重入:攻击者不一定只重入同一个函数,可能 A 函数调 B 函数,B 函数又调 A 函数
  • 小心构造函数:构造函数里不要调用外部合约,那时候合约还没初始化完
  • 锁的粒度:别一把锁锁死所有函数,该并行的还是要并行

最后说一句:重入攻击是智能合约最经典的漏洞之一,但也是最容易防御的。只要养成「先更新状态,后转账」的习惯,再加上一把锁,基本上就能防住 99% 的情况。

剩下的 1%?嗯,那就要靠审计和测试了。下节课我们聊聊怎么用工具自动检测重入漏洞。