深入剖析Solmate库 #11:ReentrancyGuard.sol

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,必须被继承使用。

四、源码逐行解析

4.1 Storage

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 gas1 → 0(解锁)= 100 gas(dirty write)= 合计 20100 gas
  • 因为每笔交易结束后 slot 恢复为 0,下一笔交易又是 0 → 非0永远触发 20000 gas
  • bool 类型同理,底层也是 0/1

1/2 方案:

  • 每笔交易:1 → 2(上锁)= 5000 gas2 → 1(解锁)= 100 gas(dirty write)= 合计 5100 gas
  • 因为每笔交易结束后 slot 恢复为 1(非零),下一笔交易仍是 非0 → 非0永远只收 5000 gas
  • 每笔交易节省 15000 gas

设计决策 —— 为什么是 private 而非 internal

  • 子合约不应该直接读写 locked,只应通过 nonReentrant modifier 间接操作
  • 防止子合约意外或恶意修改锁状态

4.2 Modifier — nonReentrant

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 安全:若 _;(函数体)中发生 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)    │
│       │                                                  │
│       ▼                                                  │
│  返回                                                     │
└──────────────────────────────────────────────────────────┘

六、设计思想

6.1 极简主义

  • 整个合约仅 20 行代码(含原始注释)
  • 1 个状态变量 + 1 个 modifier,没有任何多余的组件
  • 不提供 _reentrancyGuardEntered() 查询函数——如果不需要,就不加

6.2 Gas 优化

  • 1/2 替代 0/1,避免 cold write 的 20000 gas 开销
  • uint256 替代 bool,使 1/2 方案成为可能
  • 每次调用 nonReentrant 函数的额外 gas 成本:约 5100 gas(两次 SSTORE:1→2 首次写入 5000 gas + 2→1 dirty write 100 gas)

dirty write:EIP-2200 引入了 original value / current value 机制——同一交易中首次写某 slot 按 warm write 计费(5000 gas),后续再写同一个 slot 只收 100 gas,因为该 slot 已被修改过("脏"状态)。

6.3 防御性设计

  • 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 = 双重保护

八、与 OpenZeppelin ReentrancyGuard 对比

特性 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

点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论