摘要:瞬態存儲讓重入鎖變快,也讓錯誤更隱蔽

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 鎖。

跨鏈路由合約為什麼要關注瞬態狀態毒化?

跨鏈路由常有報價、執行、退款和多資產路徑。如果臨時授權、階段標記或退款狀態在同一交易內被誤讀,後續路徑可能錯誤釋放或錯誤回滾資金。

參考資料