Appearance
L1-11: Reentrancy(重入攻击)
1. 问题
创建一个存在重入漏洞的合约,然后构建攻击者合约演示如何利用该漏洞,最后使用 CEI(Checks-Effects-Interactions)模式和 OpenZeppelin ReentrancyGuard 修复漏洞。
核心问题是:当一个合约在更新自身状态之前向外部地址发送 ETH 时,接收方的 receive() 或 fallback() 函数可以重新调用原合约的提款函数,由于状态尚未更新(余额未扣除),攻击者可以反复提取资金直到合约余额耗尽。
2. 原因
重入攻击是智能合约历史上最具破坏性的漏洞类别。2016 年 The DAO 黑客事件导致 360 万 ETH(当时价值约 6000 万美元)被盗,直接引发了以太坊的硬分叉。至今(2026 年),重入漏洞仍在 DeFi 协议中频繁出现——2023 年 Curve/ Vyper 漏洞、2024 年多个借贷协议的重入事件都证明了这一点。
理解重入攻击的机制是每个 Solidity 开发者的必修课。三个层面:第一,为什么状态更新必须在外部调用之前(CEI 原则);第二,识别所有可能触发外部调用的操作(ETH 转账、ERC20 transfer、safeTransfer、跨合约调用);第三,理解重入未必来自恶意合约——任何接收 ETH 的合约都可能有意或无意地触发回调。
3. 方案
架构设计
项目包含三个核心合约:
- VulnerableVault(漏洞合约):先转账后更新状态——典型的反 CEI 模式
- ReentrancyAttacker(攻击者合约):通过
receive()回调反复调用withdraw() - SecureVault(安全合约):使用 CEI 模式 +
nonReentrantmodifier
漏洞合约(演示攻击面)
solidity
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 漏洞模式:Interactions 在 Effects 之前
function withdraw() external {
uint256 amount = balances[msg.sender];
require(amount > 0, "Nothing to withdraw");
require(address(this).balance >= amount, "Insufficient contract balance");
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
balances[msg.sender] = 0; // 状态更新在外部调用之后!
}
}
攻击者合约
solidity
contract ReentrancyAttacker {
VulnerableVault public vault;
constructor(address _vault) {
vault = VulnerableVault(_vault);
}
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw();
}
receive() external payable {
if (address(vault).balance >= 1 ether) {
vault.withdraw(); // 重入!此时 balances[attacker] 尚未被设为 0
}
}
}
安全合约(两种防护方案)
方案 A:CEI 模式
solidity
contract SecureVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw() external {
uint256 amount = balances[msg.sender]; // Checks
require(amount > 0, "Nothing to withdraw");
balances[msg.sender] = 0; // Effects(先更新!)
(bool sent, ) = msg.sender.call{value: amount}(""); // Interactions
require(sent, "Transfer failed");
}
}
方案 B:ReentrancyGuard
solidity
import {ReentrancyGuard} from "@openzeppelin/contracts/utils/ReentrancyGuard.sol";
contract SecureVaultWithGuard 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, "Nothing to withdraw");
balances[msg.sender] = 0;
(bool sent, ) = msg.sender.call{value: amount}("");
require(sent, "Transfer failed");
}
}
4. 遭遇的陷阱
4.1 对 transfer() 的盲目信任
许多开发者认为 address.transfer() 是安全的,因为它只转发 2300 gas——不足以执行重入。但实际上,EIP-1884 后某些操作码 gas 成本增加,2300 gas 的假设不再可靠。此外,如果合约使用 call{value}("")(推荐方式),就必须自己处理重入防护。
4.2 跨函数重入
即使 withdraw() 本身有 CEI 保护,如果 withdraw() 调用了另一个未受保护的函数,那个函数又可能被重入。重入不一定是递归调用同一个函数——它可以跨任意公开函数。
4.3 ERC20 代币的重入
不仅 ETH 转账可被重入。任何 ERC20 transfer 或 safeTransfer 都可能触发接收方的回调(特别是 ERC777 和某些 ERC20 变体)。当合约向未知代币发送资金时,同样需要 CEI 保护。
4.4 nonReentrant 只能防同一函数的重入
OpenZeppelin 的 nonReentrant modifier 只在同一 modifier 范围内生效。如果有两个函数各用各的 nonReentrant,它们之间可以互相重入。真实攻击中常利用此点进行跨函数重入。
5. 陷阱的原因
5.1
以太坊虚拟机在执行 CALL 操作码时会转移控制权给目标合约。如果目标合约的代码触发了一个回到原合约的调用,这个新调用会在原调用的剩余上下文(包括未更新的存储)中执行。Solidity 编译器不会自动检测这种模式。
5.2
跨函数重入之所以危险,是因为开发者通常只关注单个函数的正确性,而非全局状态一致性。当 Contract A 调用 Contract B 时,Contract B 可能回调 Contract A 的任何公开函数——而且此时 Contract A 的状态可能处于中间态。
5.3
ERC777 代币标准引入了 tokensReceived 回调钩子,使得任何代币转账都可以触发接收方代码执行。许多对 ERC20 安全的假设在 ERC777 面前失效。这就是为什么 Uniswap V2 在添加新代币对时明确排除了 ERC777 代币。
5.4
nonReentrant 使用一个 _status 状态变量(值为 1 表示未锁定,2 表示已锁定)。两个独立的函数可以各自获取和释放锁,但它们之间的调用链不被阻止。
6. 如何解决陷阱
6.1
始终遵循 CEI 模式:先做所有检查(Checks),再更新所有状态变量(Effects),最后才进行外部调用(Interactions)。在 withdraw 中,将 balances[msg.sender] = 0 放在 call{value}("") 之前。
6.2
对所有公开函数使用 nonReentrant modifier。注意:如果函数 A 和函数 B 都应该被保护,确保它们在同一个重入锁下(使用同一个 modifier)。
6.3
处理 ERC20 转账时使用 safeTransfer 和 safeTransferFrom(来自 OpenZeppelin SafeERC20)。但在调用它们之前仍然先更新自己的状态——不要因为 safeTransfer 看起来安全就放松警惕。
6.4
在审计时,找出所有外部调用点(.call{}(), .transfer(), .send(), safeTransfer(), IERC20 的 transfer 和 transferFrom),对每个点验证:这次调用之后是否还有状态写入?如果有,是否可能被重入利用?
7. 技术要点
| 要点 | 说明 |
|---|---|
| CEI 模式 | Checks-Effects-Interactions:状态更新先于外部调用 |
| ReentrancyGuard | OpenZeppelin 的 nonReentrant modifier,使用 mutex 锁 |
| 重入检测 | 查找所有 .call{}() / transfer / send 之后的状态写入 |
| 跨函数重入 | 非递归重入,通过回调链跨越不同函数 |
| ERC777 风险 | tokensReceived 回调使代币转账也能触发重入 |
| 审计策略 | 画出控制流图,标记所有外部调用及其后状态写入 |