摘要:瞬态存储让重入锁变快,也让错误更隐蔽
EIP-1153 引入 `TSTORE` / `TLOAD` 后,EVM 合约多了一层只在同一交易生命周期内存在的瞬态存储(Transient Storage)。它很适合做重入锁、临时授权、批处理缓存和跨内部调用的 scratch space,但也改变了审计假设:传统 `nonReentrant` 往往关注持久化 storage 中的全局锁,而 Yul / EVM bytecode 级合约可以绕过 Solidity 的变量布局、直接读写任意 slot,甚至在嵌套外部调用后保留错误的临时状态。本文的核心结论是:汇编层重入不是“旧重入漏洞换个语法”,而是由控制流、存储命名空间、瞬态生命周期和清理路径共同组成的状态机问题。防御侧必须把 `SSTORE/SLOAD`、`TSTORE/TLOAD`、外部 `CALL`、`DELEGATECALL` 和 Yul slot 计算放在同一张控制流图里分析。
问题边界:讨论防御模型,不提供攻击载荷
本文讨论三类风险。第一,Yul 或 inline assembly 直接操作 storage slot,可能与 Solidity 编译器生成的布局、代理合约 slot、库合约 slot 或手写 namespace 重叠。第二,EIP-1153 的瞬态存储在同一交易内跨嵌套子调用可见,若锁、限额、临时余额或授权标记清理不完整,会形成瞬态状态毒化(Transient State Poisoning)。第三,外部未知调用后的 `JUMPDEST` 与控制流恢复路径可能绕过开发者以为必定执行的清理分支。
这里的攻击者模型是防御分析所需的抽象:攻击者能触发合约的公开入口、控制某个回调合约、影响调用顺序,或让一个正常函数在同一交易中被重复进入。本文不提供可复用 payload、目标选择方法或真实合约利用步骤。重点是把审计人员和协议工程师需要检查的状态边界讲清楚:哪些值在交易内持久,哪些值在函数返回后仍可见,哪些 slot 可能被低级代码重用,哪些外部调用会把控制权交给未知代码。
EIP-1153 的关键语义:瞬态不是内存,也不是普通存储
`TSTORE` / `TLOAD` 的设计目标是提供低成本、交易级生命周期的键值存储。它和 memory 不同:memory 随调用上下文存在,不能被同一交易内另一个调用帧自然读取;瞬态存储则绑定到合约地址,在同一交易中可被该地址的后续调用读取。它和普通 storage 也不同:交易结束后瞬态数据会被丢弃,不进入状态树,不产生长期状态负担。
这个语义很适合写重入锁:
```text enter: require(TLOAD(lockSlot) == 0) TSTORE(lockSlot, 1) ... exit: TSTORE(lockSlot, 0) ```
真正的风险不在这段简化逻辑,而在所有非正常路径:外部 `CALL` 之前是否已经设置锁;外部调用失败、revert、catch、assembly return、早退分支是否清理;`DELEGATECALL` 是否让库代码在调用者地址的瞬态命名空间内读写;同一交易内第二个入口是否复用了同一 `lockSlot`;多个锁是否被一个 slot 编码混用。瞬态存储的生命周期短,但可见范围比很多开发者直觉更宽。
可以把瞬态锁的安全性写成不变量:
`forall external_edge e: lock(owner, domain) == ENTERED => no_sensitive_state_delta_after_reentry(e)`
这里 `owner` 是合约地址或 delegatecall 上下文,`domain` 是函数、资产、订单或路径的锁域。若所有函数共用一个全局锁,安全性强但并发差;若每个资产或订单使用细粒度锁,体验更好但 slot 计算和清理复杂度上升。安全设计不是简单“用 transient storage 替代 storage lock”,而是明确锁域、生命周期和异常路径。
还要注意 revert 语义。瞬态写入和普通 storage 一样会受到调用帧回滚影响,但“哪些帧回滚、哪些帧继续执行”取决于外部调用的错误处理方式。若上层捕获失败并继续执行,开发者以为已经失败的中间状态可能仍在上层逻辑中影响后续分支。审计时要把 `try/catch`、低级 `call` 返回值、assembly 手写错误处理和 batch 执行器的“单步失败继续”策略一起看,而不是只问某个内部函数是否 revert。
另一个容易误判的点是 gas 优化。EIP-1153 降低了临时锁成本,团队会更愿意把锁从粗粒度拆成细粒度,甚至把临时余额、路径阶段、授权缓存都放入 transient storage。成本下降会带来模式扩散:过去因为 `SSTORE` 成本高而不会写的临时状态,现在可能在每个路由步骤都出现。审计复杂度因此上升,因为每个 transient key 都可能成为同一交易内的隐式输入。
Yul 汇编层的 slot overlapping:编译器不再替你兜底
Solidity 的 storage layout 对状态变量、mapping、dynamic array 有确定规则;EIP-7201 进一步讨论了 namespaced storage 的布局约定。问题在于 Yul 可以绕过这些约定:`sstore(slot, value)` 和 `tstore(slot, value)` 接受的是运行时计算出的键。只要 slot 计算来自手写哈希、偏移、mask、指针复用或 ABI 解码结果,就可能出现 slot overlapping。
防御侧可以把每个存储访问抽象为:
`Access = (kind, op, slot_expr, value_expr, context)`
`kind` 是 persistent 或 transient;`op` 是 read/write;`slot_expr` 是 slot 的符号表达式;`context` 包括函数、调用深度、是否处于 delegatecall、是否在外部未知调用前后。slot overlapping 的判定不是简单字符串相等,而是约束可满足性问题:两个表达式在某些输入下是否可能映射到同一个 slot。
```text for each write a in storage_accesses: for each read_or_write b after external_call: if may_alias(a.slot_expr, b.slot_expr) and domains_conflict(a, b): report SlotOverlapAfterExternalCall ```
静态分析工具如 Slither、Mythril 可以提供 CFG、调用图、数据流和符号执行基础,但汇编级代码经常需要额外规则:识别 `keccak256(ptr, len)` 构造的 mapping slot;追踪 `mstore` 写入的内存片段;把 `add(base, offset)`、`and(mask)`、`shl/shr` 归一化;区分 Solidity 编译器布局和手写 namespace。没有这些归一化,审计报告很容易漏掉“看起来不同、实际可能别名”的 slot。
控制流图:重入风险藏在外部调用后的恢复边
高级语言里,开发者往往按函数体顺序理解逻辑;EVM 实际执行的是 opcode 控制流。`JUMPDEST` 标记合法跳转目标,外部 `CALL`、`STATICCALL`、`DELEGATECALL` 会把控制权交给未知代码,然后再回到当前帧继续执行。只要外部目标能在返回前触发同一合约入口,重入分析就必须把“外部调用边”视为潜在递归边。
一个防御型 CFG 状态机可以写成:
```text state = Clean on TSTORE(lock, 1): state = Locked on external_call_unknown while state == Locked: mark ReentryWindow on sensitive_sstore after ReentryWindow: require lock_domain_covers_write on function_exit: require all_transient_locks_cleared ```
这不是 exploit 流程,而是审计状态机。关键是把外部调用后的所有 `JUMPDEST` 恢复路径都纳入检查:成功返回、失败返回、assembly-level `return`、`revert`、条件跳转、错误处理分支和事件记录分支。很多“看起来一定会清理”的锁,其实只覆盖了主路径;而 Yul 中的早退和手写跳转会让清理逻辑被绕过。
`DELEGATECALL` 还会放大问题。被调用库代码使用的是调用者的 storage 和 transient storage 命名空间。若库把某个固定 slot 当作临时锁,而调用者也把同一 slot 当作余额、订单状态或另一个锁域,就会出现跨模块污染。对代理合约和插件式路由器来说,审计必须把实现合约、库、代理存储布局和 transient slot 约定一起看。
在 bytecode 层,slot overlapping 还可能来自内存指针污染。很多 Yul 代码会先用 `mstore` 组装哈希输入,再执行 `keccak256(ptr, len)` 计算 mapping slot。如果 `ptr` 指向的内存区域被前序 ABI 解码、返回数据复制或 assembly scratch space 复用,slot 计算就可能依赖开发者没有意识到的字节。防御不是禁止所有内存复用,而是要求用于 slot 计算的内存片段被完整覆盖、长度固定、域分离明确,并且不从外部 returndata 直接继承未校验数据。
瞬态状态毒化:同一交易内的“脏锁”和“脏缓存”
瞬态状态毒化指的是:一个临时值本应只在某个逻辑阶段有效,却在同一交易内被后续嵌套调用或第二个入口误读。它不一定表现为传统余额重入;也可能表现为临时授权未清理、资产路径限额被复用、订单执行阶段标记残留、手续费折扣缓存污染或退款状态被错误继承。
可以把同一交易内的调用序列抽象为:
`Tx = [call_1, subcall_1.1, subcall_1.2, call_2, ...]`
若 `call_1` 写入 `TSTORE(k, v)`,且 `subcall_1.2` 或 `call_2` 在未满足同一业务前置条件时读取 `TLOAD(k)`,就存在毒化风险。防御重点不是禁止所有跨调用读取,而是给每个 transient key 加业务域:`key = H("allswap.lock", chainId, contract, functionSelector, asset, orderId)`。域越清晰,误复用越少;域越细,清理和 gas 设计越复杂。
检测信号包括:同一 transient key 被多个 public/external 函数读写;`TSTORE(k, nonzero)` 后没有支配所有退出路径的清理;外部 unknown call 之后发生敏感 `SSTORE`;delegatecall 目标包含固定 transient slot;assembly 中存在从 calldata 或 memory 指针直接派生 slot 的写入;一个 slot 同时被用作 lock、cache 和阶段标记。
防御策略:锁域、清理路径和审计规则要一起设计
第一,锁域要显式。全局锁适合保护单资产金库或不可并发的结算函数;细粒度锁适合订单、路径、资产维度的路由器。不要在注释里约定 slot 含义,应把 slot 常量、namespace 哈希和用途放在可审计位置。对于代理和库,slot 约定应作为接口的一部分,而不是实现细节。
第二,清理路径要被 CFG 验证。每个 `TSTORE(k, nonzero)` 都应该有一个支配所有正常退出路径的 `TSTORE(k, 0)`,并且要明确 revert 时状态如何回滚。审计时不只看源代码缩进,还要看编译后的控制流是否存在早退、跳转或 assembly return。
第三,外部调用前后要分层。Checks-Effects-Interactions 仍然有价值,但对 transient storage 不够完整。更准确的规则是:外部 unknown call 前设置的临时状态,必须被认为会被同一交易内的重入入口观察到;外部 call 后的敏感持久化写入,必须证明其锁域覆盖所有可重入路径。
第四,静态分析要扩展到汇编层。仅搜索 `call.value` 或 Solidity 级 `nonReentrant` 不够。审计工具需要在 CFG 中标注 `TLOAD/TSTORE/SLOAD/SSTORE/CALL/DELEGATECALL/JUMPDEST`,再用符号表达式追踪 slot alias。人工审计则要重点看 inline assembly、代理升级、库调用、fallback/receive、ERC-777/721/1155 回调、跨链消息执行器和批处理路由器。
第五,运行时监控要关注异常模式。生产环境可以记录同一交易内的入口重复、失败回滚、异常 gas 消耗、订单状态多次变更、退款状态先写后清、以及 transient guard 命中的比例。监控不应泄露用户隐私或内部策略,但要能帮助发现“重入未成功利用但已经触发边界”的早期信号。
第六,测试要覆盖同一交易内的组合调用,而不是只测单函数。很多 transient storage 风险在单次调用里看不出来,只有当 batch executor、multicall、ERC 回调、退款入口和代理升级路径组合时才暴露。防御测试可以构造“良性回调合约”来模拟重入边界,但测试目标应是验证锁域和状态机,不是编写攻击载荷。重点断言包括:所有退出路径锁归零;不同订单不会共享 transient key;外部调用失败后持久状态保持一致;delegatecall 模块不能写入未授权 namespace。
第七,把可升级性纳入威胁模型。代理合约升级后,旧实现的 transient slot 约定可能仍被前端、路由器或库假设使用。若新实现复用同一 slot 表示不同含义,同一交易内的模块组合会出现难以复现的污染。升级审计应比较新旧实现的 `TLOAD/TSTORE` key 空间,就像比较 storage layout 一样严肃。
审计输出也要避免只给“存在重入风险”这种泛结论。更有用的报告应列出:触发窗口在外部调用前还是后;污染的是 persistent slot 还是 transient key;是否跨 delegatecall namespace;是否影响资产释放、授权、退款或费用;修复是改锁域、补清理路径、拆分函数,还是删除不必要的 assembly。只有把风险和状态对象绑定,工程团队才能决定优先级。
修复优先级可以按四档处理。第一档是外部调用后可重复释放资金,必须立即暂停或限制入口;第二档是 transient key 误复用导致订单阶段错判,需要修复锁域和事件语义;第三档是 slot alias 只在不可达路径上成立,可以补测试和静态断言;第四档是工具无法证明安全但人工确认域隔离充分,应记录不变量并加入回归测试。这样的分级比单纯高危/中危更适合复杂路由合约。
对 AllSwap 的影响:跨链路由器最怕“状态看似清理,资金路径未清理”
AllSwap 这类无托管跨链交换会涉及报价、锁定、执行、失败退款和多资产路径。若某个 EVM 合约用 Yul 或 transient storage 优化结算,风险不只是合约自身被重入;更实际的问题是路由状态被污染后,后续路径误以为某笔订单已经进入安全阶段、某个资产限额已经扣减、某个退款条件已经成立。
路由风控可以把合约状态分成三层:`persistentSettlementState` 表示链上长期状态;`transientExecutionState` 表示同一交易内的阶段标记;`offchainRouteState` 表示报价和风控引擎看到的路径状态。安全边界要求三者不能互相伪装。一个 transient lock 命中,不应被离线系统当作订单完成;一个持久化状态更新,也不应在外部回调前暴露可重复释放的资产路径。
一个简化评分可以写成:
`contractRisk = assemblyWriteRisk + transientAliasRisk + externalCallbackRisk + refundStateComplexity`
若某条路径依赖复杂 Yul 路由器、ERC 回调、代理合约和多次外部 call,风控应提高审计等级、降低单笔限额或要求更严格的退款状态机。对用户来说,好的体验不是看到更多底层术语,而是在异常时能够清楚知道资金处于锁定、执行、回滚还是退款阶段。
具体到跨链执行,状态机可以把一次兑换拆成 `quoted -> locked -> executing -> callbackWindow -> settled/refunding`。`callbackWindow` 是最容易被忽视的阶段:合约已经进入外部调用,某些 transient 标记已经设置,但最终持久化状态还没有完全闭合。若此时另一个入口读取了临时标记,离线路由系统可能误判路径进度。AllSwap 这类系统应要求链上事件只描述已经闭合的持久状态,临时执行阶段只用于内部保护,不进入可被外部业务消费的完成信号。
审计优先级也应和资金路径绑定。普通辅助合约里一个 transient cache 错误可能只导致交易失败;路由器、金库、退款执行器、跨链消息处理器中的相同错误可能影响资金释放。安全排期不应只看代码行数,而应看该合约是否位于资产转移的最后一跳、是否接受 ERC 回调、是否支持 multicall、是否用 Yul 绕过类型系统、是否通过 delegatecall 组合模块。
未解决问题:EIP-1153 之后,审计工具还在追赶语义
第一,transient storage 的命名空间规范还不如普通 storage 成熟。团队可以用哈希 namespace,但跨库、代理和插件的约定仍依赖工程纪律。
第二,静态分析对 Yul slot alias 的误报和漏报都会很高。太保守会让报告不可用,太乐观会漏掉低级别别名。更好的符号归一化和路径敏感分析仍然需要投入。
第三,`DELEGATECALL` 与 transient storage 的组合会让“谁拥有临时状态”变得不直观。代理、模块化账户和路由插件越多,这个边界越难审计。
第四,运行时监控难以区分正常批处理和异常重入边界。单看同一交易多入口并不足够,需要结合状态变更、外部回调和失败路径。
第五,安全教育还停留在高层 `nonReentrant`。EIP-1153 让更快的锁成为可能,但也要求工程师理解交易级生命周期、汇编控制流和 slot namespace,而不是只套一个 modifier。
参考资料
[1] EIP-1153: Transient storage opcodes, https://eips.ethereum.org/EIPS/eip-1153
[2] Solidity documentation, Transient Storage, https://docs.soliditylang.org/en/latest/contracts.html#transient-storage
[3] Solidity documentation, Inline Assembly, https://docs.soliditylang.org/en/latest/assembly.html
[4] Solidity documentation, Security Considerations: Reentrancy, https://docs.soliditylang.org/en/latest/security-considerations.html#reentrancy
[5] Solidity internals, Layout of State Variables in Storage, https://docs.soliditylang.org/en/latest/internals/layout_in_storage.html
[6] OpenZeppelin Contracts, ReentrancyGuardTransient, https://docs.openzeppelin.com/contracts/5.x/api/utils#ReentrancyGuardTransient
[7] EIP-2200: Structured Definitions for Net Gas Metering, https://eips.ethereum.org/EIPS/eip-2200
[8] EIP-7201: Namespaced Storage Layout, https://eips.ethereum.org/EIPS/eip-7201
[9] Slither static analyzer, https://github.com/crytic/slither
[10] Mythril symbolic execution tool, https://github.com/ConsensysDiligence/mythril
[11] Ethereum Yellow Paper, https://ethereum.github.io/yellowpaper/paper.pdf
[12] evm.codes Cancun opcode reference, https://www.evm.codes/?fork=cancun
常见问题
EIP-1153 瞬态存储会让重入更安全吗?
它能让交易级重入锁更便宜,但不会自动更安全。若锁域、slot 命名空间或清理路径设计错误,TSTORE/TLOAD 反而会让同一交易内的临时状态污染更难发现。
Yul 汇编为什么会增加 slot overlapping 风险?
Yul 可以直接用运行时表达式读写 slot,绕过 Solidity 变量布局的可读性约束。若手写哈希、偏移或 delegatecall 命名空间不清晰,不同逻辑可能在某些输入下映射到同一 slot。
传统 nonReentrant 还能防御 EIP-1153 风险吗?
它仍有价值,但不够完整。审计必须确认锁覆盖所有外部回调、异常退出、delegatecall 和 assembly return 路径,并区分持久化 storage 锁与 transient storage 锁。
跨链路由合约为什么要关注瞬态状态毒化?
跨链路由常有报价、执行、退款和多资产路径。如果临时授权、阶段标记或退款状态在同一交易内被误读,后续路径可能错误释放或错误回滚资金。
参考资料
- EIP-1153: Transient storage opcodes
- Solidity documentation: Transient Storage
- Solidity documentation: Inline Assembly
- Solidity documentation: Reentrancy
- Solidity internals: Storage Layout
- OpenZeppelin ReentrancyGuardTransient
- EIP-2200: Net Gas Metering
- EIP-7201: Namespaced Storage Layout
- Slither static analyzer
- Mythril symbolic execution tool
- Ethereum Yellow Paper
- evm.codes Cancun opcode reference


