← 全部笔记

合约重入的三种形态与防护机制

进阶 合约安全

外部调用会暂时移交控制权,因此合约需要在调用前明确哪些状态已经更新、哪些入口可以访问共享数据。 单函数、跨函数与只读重入分别突出调用顺序、共享状态保护和中间态读取的一致性问题。 本文结合 The DAO 的历史背景,说明 CEI、重入锁与临时存储的作用及边界。

一、机制:调用栈上的控制权转移

EVM 中合约之间的调用是同步的:合约 A 通过 call 调用 B,A 的执行被挂起, B 的代码在同一笔交易的嵌套调用帧中继续运行,B 返回后 A 才恢复。 外部代码可能回调 A 的入口,因此交出控制权前,应使可被访问的状态满足约定的不变量。

以下用 Checks-Effects-Interactions(检查—效果—交互,CEI)说明防护顺序。概念示意,非可部署代码:

处理领取请求(用户, 数量):
    检查:验证权限、数量与可用额度
    效果:扣减用户额度,同步更新相关台账
    确认:可被其他入口读取的状态满足不变量
    交互:执行外部支付并检查返回结果
    若支付失败:回滚本次操作的全部状态变更

这里的重点是先完成与本次交互相关的状态更新,再执行外部调用,并明确失败时的处理方式。 实际实现还需检查共享状态、回调入口与跨合约依赖;CEI 是设计原则,不是对所有重入形态的通用安全证明。

关于 2300 gas:调用的 gas 限额 Solidity 的 transfer 与 send 向接收方提供 2300 gas 的调用额度,这不是退款费。 可执行操作受 EVM 规则和具体路径影响,不能将该限额视为重入防护。 低层 call 可指定 gas;未显式指定时仍受 EIP-150 等规则约束,并非字面上转发全部剩余 gas。 防护应依靠状态一致性与适当的锁,而不是依赖某个固定 gas 额度。

二、单函数重入:The DAO 的复盘

2016 年 6 月的 The DAO 事件是理解重入风险的重要历史案例:外部交互与内部状态更新的顺序问题, 使社区更加重视回调期间的状态一致性。随后以太坊围绕事件处置发生分歧,并经历硬分叉。 这里关注的是调用顺序带来的工程教训,而非事件的复现过程。

Checks-Effects-Interactions(检查—效果—交互)要求在外部交互前完成相关状态更新, 使回调看到自洽的状态。它通常有助于缓解单函数重入,但仍需逐项验证不变量与失败路径, 不能仅凭代码顺序就判定整个合约安全。

三、跨函数重入:共享状态的保护边界

当转账、领取、份额换算等入口依赖同一组台账时,只检查单个函数的顺序是不够的。 是否存在跨函数重入风险,取决于回调时哪些入口可达,以及它们依赖的不变量是否成立, 而不是仅取决于某个函数有没有加锁。

共享状态域的保护检查
检查对象需要确认的边界
用户额度与总台账列出所有读写入口,明确额度与总量之间的不变量。
转账与领取入口检查回调可达性;需要互斥的入口应使用同一状态域的锁。
估值与查询入口明确能否读取更新中的台账,必要时检查写锁或使用已提交快照。
外部依赖与失败路径核对跨合约读取的假设,以及失败后的回滚与锁释放。

例如,转账只扣减调用者自己的额度,并不会因为与领取函数共享台账就自动获得他人的额度。 共享状态本身不是漏洞证明;审查应关注具体的授权约束、更新顺序和不变量。 锁应围绕需要互斥的状态域设计,并兼顾合法的组合调用。

四、只读重入:中间态读取的一致性风险

只读重入强调的是读取发生时,相关状态可能尚未完成一致性更新。 读取函数自身不写状态,并不代表调用链没有写操作,也不代表依赖该返回值的结算一定可靠。

以份额估值为例,封装合约(vault)可能通过底层池的 get_virtual_price() 读取虚拟价格。 如果该价格依赖的储备与总份额尚未同步更新,分别读取到的数值即使各自有效,其比值也未必代表可用于结算的状态。 设计时应明确快照的提交边界,使这些关联字段来自同一个一致状态。

只给写入口设置 nonReentrant 不会自动阻止查询中间态,但查询可以检查已有的写锁, 在更新尚未完成时拒绝读取。view 函数可以读锁状态,不应为此写入进入标记; 跨合约读取则需要数据提供方暴露可检查的状态或提供一致的快照接口。

五、防御手段各自覆盖了什么

常见缓解措施与三种重入形态的对应关系
措施单函数跨函数只读
Checks-Effects-Interactions相关不变量成立时可缓解需检查全部共享入口需另查快照一致性
nonReentrant 互斥锁可阻止受锁入口嵌套进入需覆盖相同状态域需读入口配合检查写锁
transfer / send 的 2300 gas不应作为防护依据不应作为防护依据不保证读取一致性
Pull over Push(改为领取制)可隔离支付,仍需 CEI / 锁仍需共享状态审查不直接解决读取一致性
读入口检查写锁 / 一致快照不替代写路径保护需协调读写边界在正确覆盖相关状态时可缓解

「领取制」把支付从主要业务流程中分离,让用户单独领取,可减少业务路径之间的耦合。 但领取函数仍然包含外部调用,因此依然需要 CEI、适当的锁与失败处理, 也需要检查它与其他入口共享的状态。

transient storage:把锁的成本降下来

坎昆升级(2024 年 3 月)引入的 TSTORE / TLOAD 指令(EIP-1153)提供了一类只在交易内有效、 交易结束后自动归零的存储槽。相较于使用持久存储的重入锁,临时存储通常可以降低锁的读写成本。 但锁仍需在受保护函数正常退出时显式清除,否则同一笔交易内稍后的合法调用也可能被阻止; 交易结束自动归零不能替代函数退出时的释放。异常回滚路径及目标链的指令支持也需单独验证。

六、上线前的检查清单

  1. 列出所有外部调用点(call、delegatecall、ERC-20 transfer、ERC-721 safeTransferFrom 引发的 onERC721Received),逐个确认调用时已完成的写入。
  2. 按共享状态域识别需要互斥的入口,统一锁的范围,并检查合法的组合调用。
  3. 检查跨合约估值与公开查询入口,确认读取的是一致快照,或能在写锁持有期间拒绝读取。
  4. 评估领取制是否适合业务;采用后仍需验证领取路径的 CEI、锁与失败处理。
  5. 验证锁在函数退出时释放,并测试同一笔交易内的多次合法调用。
  6. 形式化验证的结论取决于模型、假设与待证明性质;应把相关入口、跨合约依赖和状态不变量纳入验证范围。

重入是外部调用模型允许的一种执行行为,是否形成漏洞取决于它是否破坏协议的不变量。 防护的核心是明确控制权转移时的状态边界,并验证相关入口与读取方遵循同一套一致性约束。

  1. Satoshi Nakamoto 之后最早的系统性讨论见 The DAO 事件的社区复盘(2016)。
  2. Consensys Diligence, Reading Uniswap's BatchFlash Reentrancy Route, 2018 —— 跨函数重入的早期案例。
  3. Samczun, An Analysis of Curve Finance's Reentrancy Vulnerability, 2023 —— 只读重入的公开披露。
  4. EIP-1153, Create transient storage opcodes, 随 Cancun 于 2024 年激活。