ReentrancyGuard.sol 通过 nonReentrant modifier 以 uint256 的 1/2 状态切换上锁,利用 EIP-2200 的 dirty write 机制将每笔交易锁开销控制在 5100 gas,为 DeFi 协议的高风险函数提供轻量级重入防护。
版本说明:[solmate]:main分支 [commit: 89365b8],[forge-std]:v1.15.0
源码链接:https://github.com/RevelationOfTuring/solmate/blob/main/src/utils/ReentrancyGuard.sol
ReentrancyGuard 是 solmate 的抽象合约,提供 nonReentrant modifier 防止函数在执行期间被重入调用。整个合约仅 1 个状态变量 + 1 个 modifier,是 solmate 极简设计哲学的典型体现。
解决的核心问题:重入攻击(Reentrancy Attack)。当合约在状态更新之前调用外部合约时,外部合约可以回调原合约的函数,利用尚未更新的旧状态反复执行操作(如多次提款)。
重入攻击流程:
用户合约 受害合约
│ │
├─── 调用 withdraw() ──────────>│
│ ├── 检查余额 ✓(余额充足)
│ ├── 发送 ETH ──→ 用户合约.receive()
│ ┌── 收到 ETH │ │
│ ├── 再次调用 withdraw() ──→│ │
│ │ ├── 检查余额 ✓(状态还没更新!)
│ │ ├── 再次发送 ETH... │
│ │ │ ...无限循环... │
│ │ ├── 最终:余额清零 │
│ ├── 更新余额 ← 太晚了!
The DAO 事件:2016 年以太坊上最著名的重入攻击。攻击者利用 The DAO 合约中 withdraw 函数先发送 ETH 后更新余额的漏洞,递归调用提走了约 360 万 ETH(当时价值约 6000 万美元),直接导致了以太坊硬分叉(ETH / ETC 分裂)。
| 适合 | 不适合 |
|---|---|
涉及 ETH 转账的函数(call/transfer/send) |
纯计算函数(pure/view,不修改状态) |
| 调用外部合约后修改自身状态的函数 | 已严格遵循 Checks-Effects-Interactions 模式且无需额外保护的函数 |
DeFi 协议中的 withdraw/swap/liquidate 等高风险函数 |
对 gas 极致敏感且已有其他重入保护机制的场景 |
| 跨合约调用链中需要全局重入保护的入口函数 | 仅在合约内部调用(internal)的函数 |
ReentrancyGuard (abstract contract)
│
├── Storage
│ └── locked : uint256 (private) ← 重入锁状态(1=未锁定, 2=已锁定)
│
└── Modifier
└── nonReentrant() (virtual) ← 防重入修饰符
特点:无 Events、无 Constructor、无 Functions——极简到只有一个状态变量和一个 modifier。标记为 abstract,必须被继承使用。
uint256 private locked = 1;
作用:重入锁状态变量。
| 值 | 含义 |
|---|---|
1 |
未锁定(可进入) |
2 |
已锁定(函数执行中,禁止重入) |
设计决策 —— 为什么用 1/2 而非 0/1?
EVM 的 SSTORE 指令根据值的变化模式有不同的 gas 消耗(EIP-2200):
| 操作 | gas 消耗 | 说明 |
|---|---|---|
| 0 → 非0 | 20000 gas | cold write:从零值变为非零值 |
| 非0 → 非0(首次写) | 5000 gas | warm write:同一交易中首次修改该 slot |
| 任意值变更(已写过) | 100 gas | dirty write:同一交易中该 slot 已被写过,后续写入仅收 100 gas |
如果用 0/1 方案:
0 → 1(上锁)= 20000 gas,1 → 0(解锁)= 100 gas(dirty write)= 合计 20100 gas0 → 非0,永远触发 20000 gasbool 类型同理,底层也是 0/1用 1/2 方案:
1 → 2(上锁)= 5000 gas,2 → 1(解锁)= 100 gas(dirty write)= 合计 5100 gas非0 → 非0,永远只收 5000 gas设计决策 —— 为什么是 private 而非 internal?
locked,只应通过 nonReentrant modifier 间接操作modifier nonReentrant() virtual {
// 检查是否未锁定,若已锁定则 revert "REENTRANCY"
require(locked == 1, "REENTRANCY");
// 上锁,标记为"执行中"
locked = 2;
// 执行被修饰的函数体
_;
// 解锁,恢复为"可进入"状态
locked = 1;
}
作用:防重入修饰符,被此修饰符保护的函数在执行期间不能被重入调用。
重入防护原理:
正常调用(无重入):
调用者 ─> nonReentrant 函数
│
├─ locked: 1 -> 2(上锁)
├─ 执行函数体
├─ locked: 2 -> 1(解锁)
└─ 返回 ✓
重入攻击(被阻止):
攻击者 ─> nonReentrant 函数
│
├─ locked: 1 -> 2(上锁)
├─ 执行函数体
│ ├─ 调用外部合约(如发送 ETH)
│ │ └─ 外部合约试图回调 nonReentrant 函数
│ │ └─ require(locked == 1) -> locked == 2 -> revert("REENTRANCY") ✗
├─ locked: 2 -> 1(解锁)
└─ 返回 ✓
设计决策:
require + 字符串 vs custom error:solmate 选择 require(locked == 1, "REENTRANCY"),带有错误消息字符串。虽然比无消息的 require 多一点 gas,但便于调试。相比 OZ 的 custom error,字符串在 0.8.0 就可用,兼容性更好virtual 关键字:允许子合约 override 此 modifier 以扩展行为_;(函数体)中发生 revert,EVM 会回滚整个交易的状态变更,locked 自动恢复为 1,不会出现"锁死"问题┌──────────────────────────────────────────────────────────┐
│ nonReentrant modifier 流程 │
│ │
│ 外部调用 → 进入 modifier │
│ │ │
│ ▼ │
│ require(locked == 1, "REENTRANCY") │
│ │ │
│ ├── locked != 1 ──> revert("REENTRANCY") │
│ │ │
│ ▼ locked == 1 │
│ locked = 2 (上锁,SSTORE: 1 -> 2,5000 gas) │
│ │ │
│ ▼ │
│ _; 执行函数体 │
│ │ │
│ ├── 函数体 revert ──> 整个交易回滚,locked 恢复为 1 │
│ │ │
│ ▼ 函数体成功 │
│ locked = 1 (解锁,SSTORE: 2→1,100 gas dirty write) │
│ │ │
│ ▼ │
│ 返回 │
└──────────────────────────────────────────────────────────┘
_reentrancyGuardEntered() 查询函数——如果不需要,就不加1/2 替代 0/1,避免 cold write 的 20000 gas 开销uint256 替代 bool,使 1/2 方案成为可能dirty write:EIP-2200 引入了 original value / current value 机制——同一交易中首次写某 slot 按 warm write 计费(5000 gas),后续再写同一个 slot 只收 100 gas,因为该 slot 已被修改过("脏"状态)。
locked 标记为 private,子合约不能直接操作锁状态nonReentrant 标记为 virtual,为扩展留出空间但不强制_; 之后,确保函数体执行完毕后才释放锁| 风险 | 说明 | 建议 |
|---|---|---|
| 跨函数重入 | nonReentrant 保护的是单个函数调用栈,但如果合约有多个修改同一状态的函数且只有部分加了 nonReentrant,攻击者可通过未保护的函数重入 |
所有涉及外部调用 + 状态修改的函数都应加 nonReentrant |
| 跨合约重入 | 如果合约 A 和合约 B 各自有独立的 ReentrancyGuard,A 调用 B 时 B 的锁不影响 A 的锁 |
对于多合约协议,考虑使用共享的全局重入锁 |
| read-only 重入 | 攻击者在重入时调用 view 函数读取尚未更新的状态(如 Curve 的 read-only reentrancy 漏洞) |
view 函数如果被外部协议依赖,也需考虑保护(OZ 提供 nonReentrantView) |
| gas 开销 | 每次调用增加约 5100 gas(两次 SSTORE) | 仅在确实需要保护的函数上使用,纯内部逻辑无需加 |
| 不能替代 CEI 模式 | nonReentrant 是额外的安全网,不应作为不遵循 Checks-Effects-Interactions 模式的借口 |
始终优先遵循 CEI 模式,nonReentrant 作为二级保护 |
Checks-Effects-Interactions(CEI)模式:
function withdraw(uint256 amount) external nonReentrant {
// 1. Checks — 校验条件
require(balances[msg.sender] >= amount, "INSUFFICIENT");
// 2. Effects — 先更新状态
unchecked {
balances[msg.sender] -= amount;
}
// 3. Interactions — 最后才做外部调用
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "TRANSFER_FAILED");
}
// nonReentrant + CEI = 双重保护
| 特性 | solmate ReentrancyGuard | OpenZeppelin ReentrancyGuard (v5.x) |
|---|---|---|
| 锁状态值 | 1 / 2 |
1(NOT_ENTERED) / 2(ENTERED) |
| 存储方式 | 普通 uint256 private |
ERC-7201 命名空间化 storage slot |
| 错误处理 | require(locked == 1, "REENTRANCY") |
revert ReentrancyGuardReentrantCall() (custom error) |
| modifier | nonReentrant |
nonReentrant + nonReentrantView |
| 查询锁状态 | 不支持 | _reentrancyGuardEntered() 返回 bool |
| modifier 可覆写 | virtual |
不可覆写(分拆为 private 函数) |
| constructor | 无(声明时初始化 = 1) |
有(在 constructor 中初始化) |
| 代码量 | ~20 行 | ~70 行 |
| Transient Storage 版本 | 无 | ReentrancyGuardTransient(v6.0 将成为默认) |
| 代理合约兼容 | 兼容(无 constructor 依赖,声明时初始化) | 兼容(ERC-7201 命名空间避免 slot 冲突) |
Transient Storage(EIP-1153)展望:
传统 SSTORE(solmate / OZ 当前方案):
1 -> 2 -> 1 切换:首次 SSTORE 5000 gas + dirty write 100 gas = 5100 gas
状态持久写入 storage,交易结束后仍保留(虽然值恢复为 1)
Transient Storage(OZ ReentrancyGuardTransient):
TSTORE/TLOAD:每次约 100 gas × 2 = 200 gas
状态仅在当前交易内有效,交易结束后自动清零
可以用 0/1 方案(无需 1/2 优化,因为 TSTORE 不区分 0->非0 和 非0->非0)
需要 EVM Cancun 升级支持(2024 年 3 月已上线以太坊主网)
如何选择:
追求极简、最小继承开销、最少代码量?
→ 选 solmate ReentrancyGuard
需要 read-only 重入保护、custom error、代理合约命名空间隔离?
→ 选 OpenZeppelin ReentrancyGuard
部署在支持 Cancun 的链上,追求极致 gas 优化?
→ 选 OpenZeppelin ReentrancyGuardTransient
// SPDX-License-Identifier: MIT
pragma solidity >=0.8.0;
import {ReentrancyGuard} from "solmate/utils/ReentrancyGuard.sol";
/*
* @title SimpleVault — 使用 ReentrancyGuard 的简易金库
* @notice 展示 nonReentrant 配合 CEI 模式的典型用法
*/
contract SimpleVault is ReentrancyGuard {
mapping(address => uint256) public balances;
// 存款:无需 nonReentrant(不调用外部合约)
function deposit() external payable {
balances[msg.sender] += msg.value;
}
// 提款:nonReentrant + CEI 双重保护
function withdraw(uint256 amount) external nonReentrant {
// Checks — 校验
require(balances[msg.sender] >= amount, "INSUFFICIENT");
// Effects — 先更新状态(即使被重入,余额已扣减)
unchecked {
balances[msg.sender] -= amount;
}
// Interactions — 最后外部调用
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "TRANSFER_FAILED");
}
// 紧急提款:同样需要 nonReentrant
function emergencyWithdraw() 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 会发生什么?
有漏洞的合约(不遵循 CEI,先发 ETH 后更新余额):
contract VulnerableVault {
mapping(address => uint256) public balances;
function deposit() external payable {
balances[msg.sender] += msg.value;
}
function withdraw(uint256 amount) external {
require(balances[msg.sender] >= amount, "INSUFFICIENT");
// ✗ 先发 ETH(Interaction 在 Effect 之前)
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "TRANSFER_FAILED");
// ✗ 后更新余额(此时重入已发生,太晚了)
balances[msg.sender] -= amount;
}
}
攻击合约:
contract Attacker {
VulnerableVault vault;
constructor(VulnerableVault _vault) { vault = _vault; }
function attack() external payable {
vault.deposit{value: msg.value}();
vault.withdraw(msg.value);
}
receive() external payable {
if (address(vault).balance >= msg.value) {
vault.withdraw(msg.value); // ← 重入!
}
}
}
攻击流程(假设 vault 中已有其他用户存入的 10 ETH):
1. attack() 存入 1 ETH(vault 总余额 11 ETH,攻击者记账余额 1 ETH)
2. attack() 调用 withdraw(1 ETH)
3. vault 检查攻击者记账余额 = 1 ETH ≥ 1 ETH ✓
4. vault 发送 1 ETH → Attacker.receive()(记账余额尚未更新,仍为 1 ETH!)
5. receive() 检查 vault 合约 ETH 余额 = 10 ETH ≥ 1 ETH → 再次调用 withdraw(1 ETH)
6. vault 检查攻击者记账余额 = 1 ETH ≥ 1 ETH ✓(还是旧值!)
7. vault 再次发送 1 ETH → Attacker.receive()
8. ...重复直到 vault 合约 ETH 余额不足 1 ETH
9. 最终:攻击者用 1 ETH 提走了 11 ETH
如果 VulnerableVault.withdraw 加了 nonReentrant:
步骤 5 中,locked == 2 → require 失败 → revert ✓
Mock 合约:https://github.com/RevelationOfTuring/foundry-solmate/blob/main/src/utils/MockReentrancyGuard.sol
全部 Foundry 测试合约:https://github.com/RevelationOfTuring/foundry-solmate/blob/main/test/utils/ReentrancyGuard.t.sol
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!
