Uniswap v4 Hook解析:架构设计、常见漏洞与防护实践

현재 언어 번역이 없어 원문을 표시합니다.
自 Uniswap v4 主网上线后,Hook 机制成为 DeFi 最受关注的创新之一。Base 链上的 memecoin 发射平台 Flaunch 利用 Hook 实现固定预售价格与自动清算上线机制;流动性协议 Bunni v2 用 Hook 构建可编程流动性与再抵押模型;今年SATO、uPEG(Unipeg)、Slonks 等围绕 Hook 玩法的代币也在短期内创下数十倍涨幅。

自 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 的几个关键变更如下:

- Singleton 模式:所有池子的状态由单一的PoolManager 合约集中管理,不再为每个池子部署独立合约

- Flash accounting:交易过程中的中间余额变化只在 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 periphery 旧版本提供的 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:Hook 在本次 swap 中对 specified token 和 unspecified token 的 delta 调整

- uint24:动态 LP 费率覆盖值(仅当池子开启动态费率时生效)

BeforeSwapDelta 是int256 的别名,高 128 位是 specified token 的 delta(即用户指定数量的那个 token),低 128 位是 unspecified token 的 delta:

图片

需要注意,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 Delta 结算与 NonzeroDeltaCount

beforeSwap 和afterSwap 返回的 delta 不会立即触发转账,而是被记录到 PoolManager 的内部账本中:

图片

每当一笔 token 的累计 delta 从零变为非零,NonzeroDeltaCount 自增;变回零时自减。如 2.1 所述,unlock() 结束时若NonzeroDeltaCount != 0,整笔交易 revert。

Hook 通过 settle()(向 PoolManager 转账)和 take()(从 PoolManager 取出)两个动作平衡自己的 delta:

图片

这套机制带来的安全语义是清晰的:所有人最终都必须把账平掉。但它只保证了”账目守恒”,并不保证”账目正确”。如果 Hook 在 beforeSwap 中返回了一个被恶意构造的 delta,PoolManager 会忠实地按这个 delta 记账,只要最终被 settle 平掉,交易就成功——即使这意味着Hook 可以通过伪造业务状态,使系统错误地认为攻击者拥有某些资产权益,而 PoolManager 无法识别这种业务层面的错误。

此前 Cork Protocol 安全事件便是因为其Hook存在漏洞,而在被攻击前它已经经过了四家审计公司的审计。事后复盘我们发现:

- 四家审计中有三家的 scope 不包含 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 时代一个新的审计盲区:协议复杂度的爆炸式增长导致 scope 划定本身成为一个安全决策。Hook 与协议其他合约的交互链路非常长,单独审 Hook 合约不足以发现跨合约的组合性问题;反过来,审周边合约而把 Hook 放在 scope 外,则会漏掉 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) Delta 会计的”守恒”不等于”正确”。NonzeroDeltaCount == 0 只能保证账本最终平衡,不能保证账本的内容未被恶意操纵

(6) 跨市场的代币类型混淆是 v4 时代的新型攻击面。当协议允许用户创建市场时,对代币的语义校验是必须的,不能仅依赖接口检查

每个 Hook 都是一个独立的信任域,每个池子的安全性由其绑定的 Hook 决定。Hook 安全审计的复杂度因此也不再是”审一份代码”,而是”审一个完整的子协议”——这一变化对项目方和审计方都意味着方法论上的升级。

查看原文

공유하기:

작성자: Beosin

이 글은 PANews 입주 칼럼니스트의 관점으로, PANews의 입장을 대표하지 않으며 법적 책임을 지지 않습니다.

글 및 관점은 투자 조언을 구성하지 않습니다

이미지 출처: Beosin. 권리 침해가 있을 경우 저자에게 삭제를 요청해 주세요.

PANews 공식 계정을 팔로우하고 함께 상승장과 하락장을 헤쳐나가세요
PANews APP
CSOP SK하이닉스 2배 레버리지 ETF, 오늘 16.55% 상승 마감… 연간 상승률 10배 초과
PANews 속보