自 Uniswap v4 主網上線後,Hook 機制成為 DeFi 最受關注的創新之一。Base 鏈上的 memecoin 發射平台 Flaunch 利用 Hook 實現固定預售價格與自動清算上線機制;流動性協議 Bunni v2 用 Hook 構建可程式化流動性與再抵押模型;今年 SATO、uPEG(Unipeg)、Slonks 等圍繞 Hook 玩法的代幣也在短期內創下數十倍漲幅。
在 Hook 生態繁榮的另一面,針對 Hook 實現缺陷的攻擊也在顯著上升。本文將從 Uniswap v4 的 Hook 機制入手,逐步分析其核心調用棧,幫助項目方理解其中可能存在的漏洞。
Uniswap v4 Hook 安全
1. 簡介
Uniswap v4 相對於 v3 最顯著的架構變化是引入了 Hook(鉤子)機制:允許開發者將自定義合約掛載至流動性池的生命週期事件上,在 swap、增加或減少流動性、初始化等節點注入任意邏輯。
v4 的幾個關鍵變更如下:
- 單例模式:所有池子的狀態由單一的 PoolManager 合約集中管理,不再為每個池子部署獨立合約
- 快速會計:交易過程中的中間餘額變動僅在 transient storage 中記賬,僅在交易結束時統一結算
- Hook 機制:每個池子可綁定一個 Hook 合約,PoolManager 會在關鍵節點(beforeInitialize、beforeSwap、afterAddLiquidity 等)回調該合約
- Hook 不可更換:一旦池子初始化完成,綁定的 Hook 地址永久固定(池子綁定的 Hook 地址不可修改,但 Hook 合約自身是否可升級取決於其實現方式)

在 v3 時期,開發者只需信任 Uniswap 協議本身;而在 v4 時期,每個池子的安全性取決於其綁定的 Hook。Hook 將 AMM 從一個固定的金融原語,拓展為可程式化的金融基礎設施,但安全模型也從「協議級」碎片化至「池子級」。
2. Hook 架構
2.1 PoolManager 與 unlock/callback 模型
v4 的核心合約是單例的 PoolManager。任何對池子狀態的變更操作(swap、增加或減少流動性)都必須先調用 PoolManager.unlock(),取得一次性回調權限後,在 unlockCallback() 中完成具體動作。整個流程結束時,PoolManager 會驗證賬本是否平衡:

當 NonzeroDeltaCount != 0 時,直接 revert,這是 v4 flash accounting 的核心約束。任何 Hook 在執行過程中可暫時使帳目失衡,但在交易結束前必須自行 settle,否則整筆交易會回滾。
每個池子由 PoolKey 結構唯一標識,其中包含 hooks 字段:

PoolId 由 keccak256(PoolKey) 計算得出,因此不同的 hooks 地址會產生不同的池子。這同時意味著,PoolManager 不會校驗某個 Hook 地址是否曾被用於其他池子,同一個 Hook 合約可以被多個池子同時綁定。

2.2 Hook 權限位編碼在地址中
v4 的一個反直覺設計是:Hook 的權限並非由合約內部的某個變數決定,而是由 Hook 合約的部署地址決定。
PoolManager 透過檢查 Hook 地址的低 14 個比特位,來判斷該 Hook 是否需要在某個生命週期點被調用:

例如 BEFORE_SWAP_FLAG = 1 << 7。若 Hook 地址的第 7 位為 1,PoolManager 會在 swap 前調用該 Hook 的 beforeSwap();否則,即使 Hook 合約實現了 beforeSwap(),也永遠不會被 PoolManager 調用。
這意味著在部署 Hook 時,必須透過 CREATE2 + salt 計算出地址,構造出一個低位與目標權限完全一致的地址。Uniswap 官方提供了 HookMiner 工具用於此目的:

當存取權限位與函數實作不一致時,會產生兩類問題:
(1) 已實作某個 hook 函數,但地址未編碼對應權限位——PoolManager 永遠不會調用該函數,邏輯形同虛設
(2) 地址編碼了某個權限位,但 hook 未實現對應函數——PoolManager 在回調時可能發生 revert,導致 DOS 實現或返回值校驗失敗,進而使相關操作無法執行。
這同時也是 Hook 升級的天然障礙:若 Hook 可透過代理升級,部署地址在升級時保持不變,因此升級後只能修改既有 hook 函數的實現,而不能新增 hook 類型。要預留未來擴展能力,必須在初始部署時將所有可能用到的權限位預先挖出。
2.3 BaseHook 與一個被普遍忽視的存取控制陷阱
Uniswap v4 側邊提供的 BaseHook 抽象合約,開發者可繼承它來實現自定義 Hook。BaseHook 的一個重要作用是為 unlockCallback() 函數提供 onlyPoolManager 修飾符:

但是——這裡存在一個非常容易被忽視的設計陷阱——早期版本的 BaseHook 僅為 unlockCallback 添加了 onlyPoolManager,對其他 hook 回調函數(beforeSwap、afterSwap、beforeAddLiquidity 等)沒有任何保護。這些函數的訪問控制必須由 Hook 開發者自行顯式添加。
3. Hook 生命週期程式碼閱讀
以一次 exact-input swap 為例,下面分析從用戶發起交易到結算的完整調用棧。
3.1 池子初始化與 Hook 綁定
任何人都可以調用 PoolManager.initialize() 創建新池子:

isValidHookAddress 僅檢查地址權限位與 fee 字段的相容性,不檢查 Hook 是否已在其他池子中被使用,也不檢查該 Hook 是否「願意」接受此 PoolKey。若 Hook 在設計時未在 beforeInitialize 中加入白名單或單池綁定邏輯,任何人都可以構造一個使用相同 Hook 但 token pair 任意的新池子,並觸發 Hook 後續的所有回調。
3.2 beforeSwap 與 BeforeSwapDelta
swap 流程的入口是 PoolManager.swap(),它在執行核心 swap 邏輯前會呼叫 Hooks.beforeSwap():

beforeSwap 的返回值是一個三元組(bytes4、BeforeSwapDelta、uint24):
- bytes4:必須等於 IHooks.beforeSwap.selector,否則 PoolManager 會直接 revert
- BeforeSwapDelta:鉤子在本次 swap 中對 specified token 和 unspecified token 的德爾塔值調整
- uint24:動態 LP 費率覆蓋值(僅當池子開啟動態費率時生效)
BeforeSwapDelta 是 int256 的別名,高 128 位是 specified token 的 德爾塔值(即用戶指定數量的那個 token),低 128 位是 unspecified token 的 德爾塔值:

需要注意,BeforeSwapDelta 的語義是:Hook 收取費用時應返回正值,Hook 退還代幣時應返回負值。開發者很容易將符號搞反;同時,specified 與 unspecified 的對應關係取決於 params.zeroForOne 與 amountSpecified 的正負,寫法稍有不慎便會導致 token 錯位。
PoolManager 會將 beforeSwap 傳回的 specifiedDelta 直接累加到 amountToSwap 上:

這一行隱含了一個關鍵語義:Hook 可以截留 swap 金額。當 hookDeltaSpecified 等於 -params.amountSpecified 時,amountToSwap 直接歸零,相當於 Hook 完全接管了這筆 swap——這就是所謂的 Async Hook 或 Custom Curve Hook。
Async Hook 是 v4 中風險最高的設計模式:它本質上是用 Hook 自身的邏輯取代了 Uniswap 的 swap 邏輯。如果 Hook 存在漏洞或本身就是惡意的,用戶資金將不再受 Uniswap 原生定價邏輯的約束,而主要依賴 Hook 自身實現的正確性。
3.3 德爾塔值結算與 NonzeroDeltaCount
beforeSwap 和 afterSwap 傳回的 delta 不會立即觸發轉賬,而是被記錄到 PoolManager 的內部賬本中:

當任何 token 的累計德爾塔值從零變為非零時,NonzeroDeltaCount 增加;當其變回零時,NonzeroDeltaCount 減少。如 2.1 所述,若在 unlock() 結束時 NonzeroDeltaCount != 0,則整個交易將被撤銷。
Hook 透過 settle()(轉賬至 PoolManager)和 take()(從 PoolManager 取出)兩個動作平衡自己的 德爾塔值:

這套機制帶來的安全語義是清晰的:所有人都必須最終結清賬目。但它僅保證了「賬目守恆」,並未保證「賬目正確」。如果 Hook 在 beforeSwap 中返回了一個惡意構造的 delta,PoolManager 會忠實地根據該 delta 記賬,只要最終被 settle 平掉,交易即視為成功——即使這意味著 Hook 可以透過偽造業務狀態,讓系統錯誤地認為攻擊者擁有某些資產權益,而 PoolManager 無法識別此類業務層面的錯誤。
此前 Cork Protocol 的安全事件正是由於其 Hook 存在漏洞,而在遭受攻擊前,它已通過四家審計公司的審計。事後復盤我們發現:
在四家審計中,有三家的範圍不包含 CorkHook 合約
唯一審計了 CorkHook 的一家機構識別出部分程式碼問題並提交了改進建議,但未完全涵蓋存取控制問題
- 另一家審計方在其報告中明確建議:「an interesting follow-up engagement would be to prove the invariants for the CorkHook functions that are being invoked by different components verified within the scope of this engagement」。這項建議從事後復盤的角度來看具有很強的針對性。
這暴露了 v4 Hook 時代的一個新審計盲區:協議複雜度的爆炸性增長使得範圍界定本身成為一項安全決策。Hook 與協議其他合約的交互鏈路極長,單獨審計 Hook 合約不足以發現跨合約的組合性問題;反之,審計周邊合約而將 Hook 排除在範圍之外,則會忽略 v4 時代最大的攻擊面。
4. 反思
將協議機制與 Cork 攻擊復盤並列來看,可以歸納出 v4 Hook 安全模型的幾個核心要點:
(1) 如果 Hook 回調函數依賴 PoolManager 提供的呼叫上下文,應明確限制僅由 PoolManager 呼叫。BaseHook 不會為開發者處理此事,這是 v4 與一般合約審計經驗最易衝突的設計陷阱
(2) Hook 與池子的綁定關係不受 PoolManager 限制。開發者必須自行在 beforeInitialize 中實現池子白名單或單池綁定。
(3) Hook 地址的權限位必須與函數實現嚴格一致。計算出的地址應當預先包含未來可能擴展的所有權限位
(4) Async / Custom Curve Hook 本質上是完全自定義的 swap 實現。它沒有任何 Uniswap 協議層面的保護,必須按照「完全自治的金融合約」標準進行審計
(5) 德爾塔值會計的「守恆」不等於「正確」。NonzeroDeltaCount == 0 只能保證賬本最終平衡,不能保證賬本的內容未被惡意操縱
(6) 跨市場的代幣類型混淆是 v4 時代的新型攻擊面。當協議允許用戶創建市場時,必須對代幣進行語義驗證,不能僅依賴介面檢查。
每個 Hook 都是一個獨立的信任域,每個池子的安全性由其綁定的 Hook 決定。因此,Hook 的安全審計複雜度不再只是「審一份代碼」,而是「審一個完整的子協議」——這一變化對項目方和審計方都意味著方法論上的升級。

