本文深入分析了 Uniswap V4 Hooks 的安全性问题,通过分类和分析公开审计报告中的发现,重点关注与自定义 V4 Hooks 相关的安全隐患,特别是那些涉及资金托管和关键应用状态管理的 Hooks。文章探讨了 Hooks 权限配置、回调机制、未处理的 Revert 错误、动态费用管理以及自定义账户处理等方面可能出现的漏洞,并提供了相应的安全建议和启发。
Giovanni Di Siena
对 Uniswap v4 hooks 安全性的深入分析。提高安全实现的门槛; 发现攻击向量和已知的智能合约漏洞。
在之前为 Cyfrin 博客撰写的文章(并在 BlockThreat 新闻通讯 中专题报道)的基础上,这篇文章涵盖了 Uniswap v4 的主要架构变化和技术创新,对来自公共审计报告的、专门与自定义 v4 hooks 相关的发现进行了分类和分析。作为背景和框架,Uniswap Labs 提供的资源,例如 Hook 权限的已知影响,以及 Composable Security 提供的这个清单 提供了有用的参考点。
如 BlockSec 的威胁模型 “玫瑰中的荆棘:探索 Uniswap v4 新型 Hook 机制中的安全风险”所示,hooks 可以是“良性的但易受攻击的”或“故意恶意的”。本文侧重于前者,但类别之间存在重叠,同样重要的是要考虑后者。与其他问题需要在与未经验证或以其他方式未经审查的 hooks 交互时加以考虑,例如可升级性风险、私钥泄露和中心化问题。
同样重要的是要注意,潜在的攻击向量可能来自存在于 hook 本身中的、已知的智能合约漏洞。虽然会包含一些特别有趣的例子,但本文主要关注与 Uniswap v4 特有的利用和问题,因为它们与自定义 hooks 托管资金和管理关键应用程序状态有关**。
让我们深入探讨一下。
Hook 权限编码在 hook 地址的最低有效位中,并与 flags 进行比较,以确定它是否应该实现相应的功能:
function hasPermission(IHooks self, uint160 flag) internal pure returns (bool) {
return uint160(address(self)) & flag != 0;
}
如果一个 hook 缺少给定功能所需的权限,它实际上会表现为一个空操作。但是,当 hook 地址编码了某个权限,但没有实现相应的功能时,执行将回退(假设不包含回退逻辑)。
v4 外围设备 BaseHook 抽象合约 旨在通过验证已部署的 hook 地址是否与 hook 的预期权限一致来避免部署这样的 hook。OpenZeppelin 实现 提供了类似且更扩展的功能。
启发式:hook 是否继承了抽象的 BaseHook 实现?如果不是,是否有检查以避免部署编码了特定权限但未实现相应功能的 hook?
如上所述,如果一个 hook 缺少给定功能所需的权限,那么它的执行将被跳过。这与授予权限但未实现必要功能的情况相反。在这里,未能为已实现的功能编码必要的权限很可能导致意外和不期望的行为,因为关键逻辑被省略了。
在这个例子中,协议费用打算在 afterSwap() hook 中收取;但是,缺少 AFTER_SWAP_RETURNS_DELTA_FLAG 权限会导致一旦启用协议费用,就会拒绝服务 (DoS) 所有 swaps。
合约创建代码以及盐和部署者地址决定了 hook 地址,因此不同的构造函数参数和部署者地址会导致不同的 hook 地址。同样,使用基础 hook 实现将有助于验证派生的 hook 地址的权限是否按预期编码:
/// @notice 实用函数,旨在在 hook 构造函数中使用,以确保
/// 部署的 hooks 地址导致调用预期的 hooks
/// @param permissions 打算调用的 hooks
/// @dev permissions param 是内存,因为该函数将从构造函数中调用
function validateHookPermissions(IHooks self, Permissions memory permissions) internal pure {
if (
permissions.beforeInitialize != self.hasPermission(BEFORE_INITIALIZE_FLAG)
|| permissions.afterInitialize != self.hasPermission(AFTER_INITIALIZE_FLAG)
|| permissions.beforeAddLiquidity != self.hasPermission(BEFORE_ADD_LIQUIDITY_FLAG)
|| permissions.afterAddLiquidity != self.hasPermission(AFTER_ADD_LIQUIDITY_FLAG)
|| permissions.beforeRemoveLiquidity != self.hasPermission(BEFORE_REMOVE_LIQUIDITY_FLAG)
|| permissions.afterRemoveLiquidity != self.hasPermission(AFTER_REMOVE_LIQUIDITY_FLAG)
|| permissions.beforeSwap != self.hasPermission(BEFORE_SWAP_FLAG)
|| permissions.afterSwap != self.hasPermission(AFTER_SWAP_FLAG)
|| permissions.beforeDonate != self.hasPermission(BEFORE_DONATE_FLAG)
|| permissions.afterDonate != self.hasPermission(AFTER_DONATE_FLAG)
|| permissions.beforeSwapReturnDelta != self.hasPermission(BEFORE_SWAP_RETURNS_DELTA_FLAG)
|| permissions.afterSwapReturnDelta != self.hasPermission(AFTER_SWAP_RETURNS_DELTA_FLAG)
|| permissions.afterAddLiquidityReturnDelta != self.hasPermission(AFTER_ADD_LIQUIDITY_RETURNS_DELTA_FLAG)
|| permissions.afterRemoveLiquidityReturnDelta
!= self.hasPermission(AFTER_REMOVE_LIQUIDITY_RETURNS_DELTA_FLAG)
) {
HookAddressNotValid.selector.revertWith(address(self));
}
}
启发式:hook 是否继承了抽象的 BaseHook 实现?如果不是,是否有检查以避免部署实现了特定功能但未编码相应权限的 hook?
当专注于一个特定的 hook 实现时,很容易忘记 Uniswap v4 本身仍然存在的入口点。即使所有 hook 权限都为预期公开的功能正确编码,仍然可能存在应该已经实现但未实现的功能(并相应地设置权限)的情况。这个问题可能采取多种形式,具体取决于上下文,但最终出现是因为应用程序的业务逻辑可能规定某些操作只能通过 hook 或相关合约执行。
例如,构建者可能打算将流动性修改限制为仅通过 hook 发生。但是,如果被忽略,仍然可以直接通过 PoolManager 合约来执行。在这种情况下,除非实现了相关的流动性修改 hooks 来回退,否则可能会绕过预期 hook 逻辑的执行。
在这个例子中,一个旨在惩罚 afterRemoveLiquidity() 函数中及时 (JIT) 提供流动性的 hook 的预期行为可以绕过,方法是增加流动性以收集所有累积的 swap 费用。这会重置费用状态并绕过惩罚机制。在这种情况下,解决方案是将 hook 权限扩展到额外跟踪在 beforeAddLiquidity() 函数中流动性增加期间发生的费用收集。
一个更简单的例子是当业务逻辑确定不允许捐赠时。如果不激活 beforeDonate() hook 以始终在捐赠时回退,则调用 PoolManager::donate 将成功增加费用增长。
启发式:打算从 hook 发起的操作,或以其他方式改变其他已实现 hook 函数的行为的操作,可以直接在底层 Uniswap v4 合约上执行吗?是否实现了必要的 hooks 来控制执行?
Uniswap v4 不限制谁可以创建新的流动性池,或者在新流动性池中使用哪个 hook 地址。如果一个 hook 不限于一个特定的池或一组池,攻击者可以部署一个恶意的池,其中包含虚假的 tokens,并通过重入或操纵内部会计等攻击向量来使用/滥用该 hook。
与所有不安全的外部调用和调用者提供的输入一样,根据上下文,验证不足可能会危及 hook 合约。因此,为特定池设计的 hooks 应在初始化期间验证池密钥中指定的 token 对。一般来说,hook 函数也应该只授予对特定 Uniswap v4 池的授权访问,这些池旨在在给定的 hook 合约中使用和交互。
Certora 对 Doppler 协议的 2024 年审查和报告发现了一个关键的(C-01),其中详细介绍了这种漏洞的一种变体。由于对 hook 和货币地址的输入验证不足,受害者合约旨在协调所有生态系统 hooks 和相关合约,可以通过指定恶意池密钥来耗尽。
更多例子:[C-02, H-01, M-02, L-12, 5, 6, 7]。
启发式:是否验证了池密钥和相关的货币地址,以确保仅授予对预期使用 hook 并与之交互的特定 Uniswap v4 池子集的授权访问?
与其他回调类型(如闪贷提供商调用的回调)类似,v4 unlockCallback() 和其他 hook 函数回调通常应该只能由单例 PoolManager 合约调用。这些细节可能因给定协议设计的具体情况而略有不同,在这种情况下,仔细的访问控制管理甚至更为重要。
如上所述,可以通过继承其中一个 BaseHook 实现以及 SafeCallback 基础合约 并重写 _unlockCallback() 来实现保护。
/// @dev 我们通过在 onlyPoolManager 检查后公开一个虚拟函数来强制执行 onlyPoolManager 修饰符。
function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) {
return _unlockCallback(data);
}
/// @dev 由子合约实现,以安全地保证逻辑仅由 PoolManager 执行
function _unlockCallback(bytes calldata data) internal virtual returns (bytes memory);
也许这个攻击向量最引人注目的例子是它被用作杠杆,在 $1200 万美元的 Cork 协议攻击 中,攻击者使用恶意 hook 数据调用了 beforeSwap() 函数。
其他例子包括:
启发式:hook 是否继承了抽象的 BaseHook 实现?hook 或相关合约是否继承了抽象的 SafeCallback?如果不是,是否有检查以防止非特权调用者调用 unlockCallback() 和其他 hook 函数?
hook 回调返回的数据的长度至少应足以包含 4 字节的选择器,编码为 32 字节:
// 长度必须至少为 32 才能包含选择器。检查预期的选择器和返回的选择器是否匹配。
if (result.length < 32 || result.parseSelector() != data.parseSelector()) {
InvalidHookResponse.selector.revertWith();
}
对于使用 Hooks::callHookWithReturnDelta 调用并期望解析返回增量的 hook 函数,数据的长度应为 正好 64 字节以包含编码的选择器加上 32 字节的增量:
// 需要 64 字节的长度才能返回 bytes4 和 32 字节的增量
if (result.length != 64) InvalidHookResponse.selector.revertWith();
return result.parseReturnDelta();
此外,Hooks::beforeSwap 期望一个 3 字节的费用覆盖,并 强制执行 96 字节的长度:
// 需要 96 字节的长度才能返回 bytes4,一个 32 字节的增量和一个 LP 费用
if (result.length != 96) InvalidHookResponse.selector.revertWith();
如果返回数据的长度与预期长度不匹配,执行将回退。这可能是由于编码了不正确的类型或任何其他原因。
启发式:hook 是否继承了抽象的 BaseHook 实现?如果不是,hook 函数签名和返回数据长度是否与 Hooks 库期望的一致?
某些功能是在 hook 中实现以在操作之前执行还是之后执行可能并不重要,而且在某些情况下,这可能完全没问题。但是,hook 开发人员和审计人员应该清楚关于这种逻辑放置的假设,因为这会产生一整类非常微妙的错误。
考虑一些自定义激励逻辑的例子。也许,该逻辑打算在完全移除流动性后清理用于此类计算的关键状态。如果在 beforeRemoveLiquidity() hook 中执行此操作,而不是在 afterRemoveLiquidity() 中执行,则流动性将在执行时保持不变,并且状态将永远不会更新。因此,将为不再存在的流动性头寸计算激励。虽然事后看来这似乎很明显,但随着复杂性的增加,很难发现这一点。
这也以相反的方式发生。在这个例子中,afterAddLiquidity() hook 中存在的 tick 初始化逻辑应该放在 beforeAddLiquidity() 中。因为,在核心流动性修改逻辑运行后执行意味着 tick 在执行返回到 hook 后就已经初始化了一次;因此,条件逻辑永远不会触发。
更多例子:[1]。
启发式:hook 逻辑是否取决于是在核心 Uniswap v4 逻辑之前还是之后执行?流动性、tick 初始化状态等是否会因选择在 hook 之前或之后执行而有所不同?错误是否会导致关键逻辑以不同、不正确的方式执行,或完全省略?
在审查 hooks 时,非常重要的是注意 hook 业务逻辑中以及基于流动性修改/swaps 的潜在回退来源。
PoolManager::sync 具有 onlyWhenUnlocked 修饰符,以确保只能在调用 PoolManager::unlock后才能同步货币和储备的瞬态存储。如果一个 hook 函数尝试在解锁 PoolManager 之前同步货币储备,执行将回退。相反,此逻辑应移动到解锁回调并在任何自定义会计之前执行。
启发式:对 sync() 的调用是否总是在之前的 unlock() 之后执行?
💡 在最新版本的 PoolManager 合约中,情况不再如此——现在可以在不先解锁单例的情况下调用 sync()。
闪速会计和解锁机制的关键不变性是,所有增量必须在从解锁回调返回执行到 PoolManager 之前结算。诸如 swap tokens 或修改流动性之类的操作会产生增量,这些增量表示池子欠或欠的 token 余额的净变化,并累积在瞬态存储中。如果在执行结束时有任何未结算的增量,集成合约可能需要使用 take() 函数提取欠用户的资产,或者存入欠池子的资产,然后调用 settle() 函数。
在某些情况下,可能存在小的 “dust” 余额,这些余额阻止帐户增量被完全结算,因此应使用 clear() 函数来明确地将此计入池子。这是作为防止调用者资金损失的保护措施实施的。但是,如果未正确处理这种情况,dust 余额因此可用作 DoS 攻击向量,如 Guardian Audits 对 Gamma 的 UniV4 限价单的审查中的 H-01 示例中所见。
以类似的方式,同一审查 L-13 详细介绍了 DoS 场景,其中 tokens 被捐赠,但在瞬态存储中未与调用 sync() 同步,导致在尝试结算操作的增量时回退。
启发式:是否存在可能累积 dust 的任何场景,如果是,是否已明确清除?在执行修改帐户增量的操作之前,是否总是在瞬态存储中同步货币和储备?
在 beforeRemoveLiquidity() 或 afterRemoveLiquidity() hook 中的未处理的回退可能导致流动性提供者 (LP) 资金被永久锁定,除非有某种方法可以摆脱这种 DoS 状态。这种情况也可能阻止费用被收集,因为与 Uniswap v3 不同,费用增长的增量需要在每次流动性修改时结算。在增加流动性期间的回退问题稍微少一些,因为这不会导致资金被锁定,但仍然会对协议造成严重破坏。
其他例子包括 H-8,其中初始 LP 由于协议特定的初始化而无法提款,此外 M-6 指出,未使用的 tokens 不会退还用于此初始流动性提供。
启发式:流动性修改 hooks 中是否存在任何可能回退的调用?是否明确处理了这些潜在的问题调用以防止意外回退?
必须小心处理外围逻辑,如自定义激励和重新平衡,以避免 DoS 和因未处理的回退而可能损失资金。此类回退的严重程度取决于禁用了哪些功能以及逻辑的正常功能是否可以恢复。虽然无法添加流动性并针对受影响的池子进行 swap 代表核心功能丧失,但影响最大的情况是资金被永久锁定的情况。例如,无法从池子中移除现有流动性或提取合约托管的其他 tokens。
虽然不是很关键,但某些回退来源可能永远不允许预期的功能运行,其中不正确的访问控制阻止外部集成正确执行。如果 hook 不可升级,则需要重新部署并进行修复,从而对协议及其用户造成严重破坏。
有时,逻辑对于操作的一次调用似乎是合理的,但在后续调用中会失败,如这个例子,其中块顶部的 swap 税被错误地应用于所有其他 swaps 并导致 DoS。在用于计算此类税费/价格等的数学中也可能存在边缘情况,这可能导致某些调用子集失败。
Pashov Audit Group 对 Bunni V2 的审查将 H-05 确定为一个 DoS 示例,原因是未能考虑多个池 ID。对于在给定区块中与给定 hook 关联的多个池,某些操作(如重新平衡)可能是可能的;但是,逻辑可能是这样的,即此被忽略,如果未使用不同的 nonce,则将功能限制为仅单个池。
在示例 [ 1, 2, 3, 4] 中,无需许可的奖励分配和范围创建可能会被滥用以触发由于溢出和过度 gas 使用而导致的回退。这种 DoS 向量也在 重新平衡 和 激励 逻辑中得到证明,原因是链之间的差异。
更多例子:[1]。
启发式:hook 函数实现中的自定义逻辑中是否存在任何可能回退的调用?是否存在任何可能回退的外围会计机制,可能是由于过度 gas 使用或下溢/溢出?是否明确处理了这些潜在的问题案例以防止意外回退?
如果池密钥使用 LPFeeLibrary.DYNAMIC_FEE_FLAG 来表示支持,Uniswap v4 池可以支持超出典型静态选项的动态 swap 费用。这在 Hooks::beforeSwap 中查询:
// 想要覆盖缓存费用的动态费用池,返回带有覆盖标志的有效费用。如果设置了覆盖标志但返回了无效费用,则事务将回退。否则将使用当前的 LP 费用
if (key.fee.isDynamicFee()) lpFeeOverride = result.parseFee();
可以通过以下两种方式更新动态费用:
PoolManager::updateDynamicLPFee。
fee | LPFeeLibrary.OVERRIDE_FEE_FLAG。
动态费用池初始化时默认费用为 0%,应在 afterInitialize() hook 中调用 updateDynamicLPFee() 函数来覆盖。
每次 swap 覆盖允许动态费用在每次 swap 时更改,从而避免重复调用 PoolManager,并由 Pool::swap 处理:
// 如果 beforeSwap hook 返回了有效的费用覆盖,则将其用作 LP 费用,否则从存储加载
// lpFee、swapFee 和 protocolFee 都在 pips 中
{
uint24 lpFee = params.lpFeeOverride.isOverride()
? params.lpFeeOverride.removeOverrideFlagAndValidate()
: slot0Start.lpFee();
swapFee = protocolFee == 0 ? lpFee : uint16(protocolFee).calculateSwapFee(lpFee);
}
如果省略了 LPFeeLibrary.DYNAMIC_FEE_FLAG 或 LPFeeLibrary.OVERRIDE_FEE_FLAG 中的任何一个,则不会应用预期的费用覆盖,并将使用默认费用。
即使正确指定了动态费用,会计逻辑也可能实现不正确,从而导致分配给预期接收者的费用计算错误或不可恢复的余额被锁定在 hook 中。 以下是一些补充例子:[ C-02, M-03, M-4, M-11, 5, 6, 7, 8].
启发式:Hook是否旨在支持动态兑换费用,如果支持,pool key信号是否支持? 功能是否暴露了足够的访问控制权限,以直接在PoolManager上更新动态费用? afterInitialize() hook中是否覆盖了默认的动态费用? 是否从应用了LP费用覆盖标志的beforeSwap() hook中正确返回了动态费用?
根据协议的配置方式,在某些情况下,特权调用者可以获得设置和调整动态兑换费用的权利。 例如,Bunni v2实现了拍卖管理的AMM机制,以运行抗审查的链上拍卖,获得临时设置兑换费率并从兑换中获得累计费用的权利。
TOB-BUNNI-11详细描述了一种情况,即恶意管理者可以在避免套利的同时操纵TWAP(时间加权平均价格)。 如果oracle被用于确定外部借贷协议中的资产价格,那么管理者可以通过借入高估值的抵押品来利用这一点。
H-02 演示了恶意管理者如何以牺牲协议为代价来获取费用。
更多例子:[ H-02].
启发式:动态兑换费用是否可以手动设置或由协议管理员以外的其他参与者调整? 是否应用了合理的限制来防止操纵和给其他费用接收者带来损失?
自定义会计是 Uniswap v4 的一项强大功能。与此同时,它很容易使整个协议的流动性面临风险。与普通的 hook 不同,那些利用自定义会计的 hook 会控制底层流动性,并且这些 hook 的业务逻辑中的任何错误,或存储或处理 ERC-6909 认领 token 的其他相关合约中的任何错误,都可能具有灾难性后果。必须确保此会计核算万无一失。即使它看起来实施得很好,开发者和审计人员也应该对所有价值来源和去向保持高度警惕,如下文将演示的那样。
输入验证不足可能导致多种类型的错误,并且通常是一些最高严重性漏洞利用的前兆。 在自定义会计的上下文中,更复杂的 hook 协议架构可能会受到调用者使用任意输入调用某些函数而滥用或破坏其会计。
考虑TOB-BUNNI-6,其中任何人都可以调用再平衡订单的前/后 hook 来触发合法订单履行之外的再平衡逻辑,从而导致资金从中央 BunniHub 合约中提取。TOB-BUNNI-7 详细介绍了一个与再平衡订单履行机制类似的 issue 。在这种情况下,对订单履行输入的验证不足允许再平衡订单前/后 hook 的非对称执行,这违反了预期的对称执行。
更多例子:[ TOB-BUNNI-8].
启发式:是否充分验证了接触自定义会计逻辑的所有函数的输入? 是否应该需要特定的许可地址? 是否存在允许绕过或以其他方式违反预期(可能是对称的)执行的输入组合?
与典型的智能合约会计一样,可能存在指定用于不同目的和受益所有者的 token 余额。 资金隔离不正确是一个常见的问题,其中潜在的多个实体之一可以执行某些操作,以访问他们不应有权访问的资产/金额。
此类问题的严重程度取决于具体的业务逻辑。 例如,也许未经授权的访问仅限于提升的管理控制,允许攻击者在更高严重性的事件中提取价值。 自定义 Uniswap v4 会计也是如此。 递归 LP token、费用、捐赠和其他激励余额通常是容易被忽视的此类问题的来源。
另一方面,高影响问题,例如C-05 演示了原始 ERC-20 和 ERC-6909 会计之间的不匹配如何被滥用来耗尽底层 pool 货币。 在这里,漏洞利用的前提是利用存储在目标合约中的一个 pool 的 LP token 作为租金支付,作为递归恶意 pool 的货币。
此处可以找到一个滥用递归 LP token 耗尽奖励余额的类似示例。 在这种情况下,增量会在提供流动性时丢失给PoolManager。 由包装的流动性 token 封装的底层 token 化激励可以被盗取,方法是利用闪电会计进行同步,代表PoolManager进行认领,结算,最后拿走提取的 token。
Pashov Audit Group 在其 Bunni V2 审查中发现H-04,展示了费用或捐赠机制积累的少量余额如何导致 hook 由于未结清或未同步的少量增量问题而恢复,如上所述。
启发式:是否存在具有多个会计名称的资产或地址? 是否有 ERC-20 和 ERC-6909 会计的混合? 如果是这样,会计是否协同工作? 是否明确考虑了费用、捐赠或任何其他保留资产? 是否应该支持递归 LP token,并且在作为流动性提供时是否可以窃取任何底层收益?
自定义会计解锁的设计空间的一个关键方面是覆盖底层集中流动性模型的兑换逻辑的能力。 虽然这一开创性的功能允许在原始 Uniswap v4 hook 架构之上构建创新的 AMM 设计,但它极大地增加了攻击面,并增加了可能完全破坏核心功能的细微算术错误的潜力。
考虑 TOB-BUNNI-15/16/17/18 和 M-21,其中可能:
另一个示例 M-13 详细介绍了交换错误地使用指定的兑换价格限制而不是计算下一步兑换的价格目标的 DoS 向量。
启发式:hook 是否覆盖了底层集中流动性模型? 兑换是否按预期运行? AMM 不变量是否可以被打破?
自定义会计的另一个方面,与修改兑换逻辑有关,是 hook 覆盖增量的能力,增量代表 pool、hook 和调用者之间的净 token 移动。 如果这些增量的计算或应用不正确,可能会导致隐式但严重的会计差异,从而以协议或其用户的利益为代价泄漏价值,具体取决于监督的性质。
一个常见的错误是误解swapDelta、hookDelta或callerDelta的符号约定。 如果在beforeSwap()中低估了指定 token 的调用者增量,hook 可能会少付给其用户。 而如果在afterSwap()中高估了 hook 增量,它将多收取用户的费用。 在最坏的情况下,如果 token 以错误的方向传输,则可以从 pool 中提取价值,从而损害 hook。
请注意,核心Hooks::beforeSwap逻辑会阻止通过应用 hook 增量来更改兑换的语义:
// 根据 hook 的返回值更新兑换金额,并检查兑换类型是否未更改(精确输入/输出)
if (hookDeltaSpecified != 0) {
bool exactInput = amountToSwap < 0;
amountToSwap += hookDeltaSpecified;
if (exactInput ? amountToSwap > 0 : amountToSwap < 0) {
HookDeltaExceedsSwapAmount.selector.revertWith();
}
}
换句话说,明确禁止精确输入兑换变为精确输出兑换,反之亦然。 但是,如果 afterSwap() 返回具有错误符号的 hookDelta,则付款的大小和方向可能会颠倒。
启发式:净值流的方向是否与最初的意图一致? 用户是否会被少收/多收费用? 是否可以直接从 hook 中提取价值? 当应减去/减少增量时,增量是否会被添加/增加,反之亦然?
如简介中所述,hook 可能会遭受众所周知的智能合约漏洞。 这包括通过在更复杂的 hook 协议架构中链接多个较低严重性问题而可能产生的重入。 这正是 Bunni v2 中实时关键漏洞所发生的事情,Cyfrin 报告称,其中协议 TVL 总量处于风险之中。
简而言之,通过deployBunniToken(),可以部署没有验证的恶意 hook,随后可以用于解锁全局重入保护。 由于 Bunni pool 的 hook 不受限制为规范的BunniHook实现,并且鉴于unlockForRebalance()仅需要给定 pool 的 hook 作为调用者,因此任何人都可以创建恶意 hook,直接调用此函数来解锁保护核心BunniHub合约的重入保护。
这与在对重新抵押金库进行不安全外部调用之前缓存 pool 状态相结合,攻击者可以自由地将这些金库指定为任意恶意地址,这意味着来自hookHandleSwap()和withdraw()中的重入实际上可以递归地提取所有 pool 中计算的原始余额和金库储备。
C-03 详细介绍了此问题的一个类似但影响较小的变体,其中可能会滥用在 pre/post hook 之间的中间状态下执行存款。 这也是引入有问题的函数的调查结果,并允许将一系列较低严重性的漏洞组合成一个完整的耗尽关键漏洞。 与1.97 亿美元的 Euler 漏洞类似,这突出了全面覆盖缓解审查的挑战,以及认识到细微的、看似琐碎的逻辑变更的潜在下游影响的重要性。
即使无法直接在目标合约上进行重入,由于缺少重入保护,只读重入仍可能影响依赖于报价器功能的集成合约,如 L-13 中所述。
启发式:规范 hook 或其任何相关合约是否执行不安全的外部调用? 这是否会受到可能指定恶意地址的调用者定义的输入的影响? 是否对所有关键功能应用了重入保护? 它是否可以被绕过,也许是通过恶意的 hook 实现?
另一个众所周知但棘手的智能合约漏洞是由于舍入造成的精度损失。 虽然相对容易识别发生此行为的实例,但要找到可用于攻击的特定场景要困难得多。
在840 万美元的 Bunni v2 漏洞中,不幸的是,上述披露不足以防止资金的灾难性损失。 从根本上说,此漏洞是由向下舍入造成的,这允许攻击者构建原子流动性增加。 总之,漏洞利用步骤包括:
虽然这些点看起来很简单,但这是针对复杂协议的复杂漏洞。 可悲的是,攻击者精心制作输入以实现这一目标的精确度。 完整的事后分析报告可以在此处找到,更详细的分析可以在此处找到。 强烈建议阅读这两篇文章,以了解攻击的完整复杂性和细微差别。
启发式:是否有任何 hook 逻辑容易受到舍入误差的影响? 如果是这样,很明显它可能如何被滥用吗? 如果不是,请考虑哪些其他计算可能依赖于它(例如,份额价格、流动性等)以及执行如何在极端情况下进行。
根据给定 pool 的 tick 间隔,在最小/最大可用 tick 和 Uniswap v3 数学定义的实际最小/最大 tick 之间存在一小部分 tick 空间,应小心避免。 例如,假设 tick 间隔为 60,则可用 tick 范围为[-887220, 887220],并且平方根价格 tick 不应进入范围 [-887272, -887220) 或 (887220, 887272]。
当流动性头寸的 tick 不是 pool 的 tick 间隔的精确倍数时,Uniswap 将恢复。 如果 hook 未正确验证,这可能会导致 DoS [ 1, 2] 和/或 无法使用的头寸。
function flipTick(mapping(int16 => uint256) storage self, int24 tick, int24 tickSpacing) internal {
// Equivalent to the following Solidity:
// if (tick % tickSpacing != 0) revert TickMisaligned(tick, tickSpacing);
// (int16 wordPos, uint8 bitPos) = position(tick / tickSpacing);
// uint256 mask = 1 << bitPos;
// self[wordPos] ^= mask;
assembly ("memory-safe") {
tick := signextend(2, tick)
tickSpacing := signextend(2, tickSpacing)
// ensure that the tick is spaced // 确保 tick 已间隔
if smod(tick, tickSpacing) {
let fmp := mload(0x40)
mstore(fmp, 0xd4d8f3e6) // selector for TickMisaligned(int24,int24)
mstore(add(fmp, 0x20), tick)
mstore(add(fmp, 0x40), tickSpacing)
revert(add(fmp, 0x1c), 0x44)
}
...
}
更多例子:[ 1].
启发式:调用者指定的 tick 是否经过验证并与 tick 间隔对齐? 是否根据最小和最大可用 tick 验证了范围边缘情况?
如果未阻止平方根价格的范围外初始化,并且没有明确处理针对零流动性的兑换,则价格可以被自由操纵并移出预期 tick 范围。 即使 pool 中有少量非零流动性,tick 操作到极端值也可能导致各种问题。
在某些情况下,这可能会通过套利导致诚实用户的损失,如 C-03 中所示。 在其他情况下,对这些结束范围的兑换和单边流动性的增加可能导致核心功能的完全 DoS。
对于实施额外激励措施的协议,应注意在没有 pool 流动性的情况下避免执行计算和存入 token。
启发式:tick/范围是否可以被推到极端值或以其他方式被操纵? 它们是否针对给定 tick 间隔的最小/最大可用 tick 进行了验证? 当流动性耗尽时,是否有任何其他机制会表现不正确?
Uniswap v3 的集中流动性 tick 穿越逻辑定义了流动性被认为在范围内和活动中的位置(即,价格在上限/下限 tick 之间)。 活动流动性被兑换所利用并赚取费用,因此当价格穿过 tick 边界时,也会触发费用增长状态更新,流动性头寸变为活动/非活动状态。
对活动流动性的错误计算可能会产生灾难性后果,多个例子证明了这一点。 值得注意的是,KyberSwap Elastic 关键漏洞披露、5600 万美元的 KyberSwap Elastic 漏洞(此处解释得非常好)和 440 万美元的 Raydium 漏洞。 这可能是由于在兑换期间穿过 tick 时的舍入或不正确的状态更新、修改流动性(例如,导致双重计算)或攻击者可以利用的其他差异。
在此示例中,活动流动性计算似乎按预期运行。 但是,在零换一的兑换中,如果当前 tick 是流动性范围上限处的 tick 间隔的精确倍数,则会跳过边界 tick 的影响,并且流动性会被错误地计算。 因此,净流动性远小于预期,从而夸大了计算出的费用增长,并允许所有奖励被这种有意或甚至无意中的恶意头寸盗取。
启发式:tick 穿越是否处理正确? 是否存在逻辑中断的边缘情况,例如精确地在 tick/单词边界上开始/结束? 这些边界条件是否有足够的测试?
与 Uniswap v3 不同,Uniswap v4 支持通过自定义Currency类型抽象的原生 ETH。 虽然这成功地提供了更好的用户和开发者体验,但它并非没有危险。 对原生 token 的不充分或以其他方式不正确的处理可能会导致一些与处理原生 token 支持相关的问题 (并且应该预料到)。
第一个也是可能影响最大的问题是,原生 token 转移是 潜在重入的来源。 在处理Currency类型和 ERC-6909 表示时,这可能很容易被忽视,但识别所有可以重新进入的不安全外部调用的来源仍然非常重要。
第二个更微妙的错误是缺少receive()函数。 这可能导致 DoS,具体取决于 hook 及其相关合约打算如何以原生 token 的形式转移和接收价值。 H-04 演示了当目标合约无法接收从路由合约重定向的少量余额时,会如何发生这种情况。
在最坏的情况下,如果实现了原生 token 处理,但没有充分验证msg.value,则原生 token 余额可能会被锁定,如 L-22 中所示。 这也可能导致自定义兑换增量会计中断,如 M-19 中所示。
请注意,没有必要对currency1执行验证,因为原生 token 只能是token0,因为货币按地址排序,而address(0)用于原生货币 [ 1, 2]。
启发式:hook 是否支持原生 ETH? 如果是这样,它是否可以用于重新进入任何关键功能? 期望接收原生 token 的合约是否实现了receive()函数? 如果没有,是否考虑过路由器集成等边缘情况? 是否充分验证了msg.value?
即时流动性支持创新的、资本效率高的协议设计(例如 Euler),但也可以恶意地操纵定价或奖励机制。 例如,暂时增加流动性头寸以获取费用、激励或更改其他核心协议状态,然后在立即提取之前,如依赖于抢先交易自定义流动性和奖励逻辑的 M-1/2。
TOB-BUNNI-9 描述的场景显示了 hook 提取队列逻辑中的错误如何启用可用于操纵 pool 再平衡订单计算的 JIT 流动性提供,从而可能使其处于比以前更糟糕的状态。
更多例子:[ 1].
启发式:是否可以提供 JIT 流动性? 它是否代表净正向流动,或者它是否可能是恶意的,是否应该阻止/阻止它?
虽然 Uniswap 的 V4Router 像所有其他 router 实现一样,是一个 peripheral 合约,但必须注意确保:
C-03 是前者的一个例子,而 C-04/H-07 是后者的例子。请注意,这些错误也可能发生在 hook 逻辑本身中。
启发式:是否指定了正确的接收者地址?它不一定总是 msg.sender。amount0/token0 是否意外地代替了 amount1/token1,反之亦然?
以下是与 Uniswap v4 hook 没有直接关系,而是与自定义 LP 费用处理和其他激励逻辑的实现相关的各种其他有趣发现的列表 [ 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27]。
这里的主要结论是:对为自定义目的而重新实现的 Uniswap v3 数学保持警惕,尤其是在考虑交换数学、tick 穿越和费用增长计算时。对于更复杂的 hook,完整的端到端测试还应验证多个集成合约之间的 token 流动,以确保没有可能导致资金卡住的缺失的逻辑/转移。
Hook 是一项非常强大的创新。它们的设计,以及其他 Uniswap v4 协议创新(闪电会计、Singleton 架构、PoolManager 等)大大降低了 AMM 实验的门槛。然而,它们同样提高了健全和安全实施的标准,尤其是在自定义底层集中流动性机制时。正如本文希望展示的那样,hook 的总体攻击面很大且相当细致。如果你觉得它有帮助,非常感谢放大和反馈!
- 原文链接: cyfrin.io/blog/uniswap-v...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!