Back to Blog

致命集成:钩子中因危险交互引发的漏洞

Code Auditing
November 20, 2023
9 min read

正如我们在上一篇文章中所强调的,Awesome Uniswap v4 Hooks 代码库[1]中超过 30% 的项目存在漏洞。值得注意的是,我们此处所指的漏洞特指与 Uniswap v4 交互相关的安全问题。因此,在本文中,我们将从以下两个角度对安全的 Hook 交互逻辑进行深入审视:

  • 缺陷访问控制
  • 不当输入验证

对于每个类别,我们将首先分析漏洞,并通过提供相应的概念验证(PoC)来演示其潜在的利用方式。随后,我们将讨论潜在的缓解策略。

缺陷访问控制

通常,与 Uniswap v4 Hook 相关的交互可以根据 Hook 是否充当锁定者(即在 PoolManager 中获取锁以执行池操作)来进行分类。两种主要的交互场景需要适当的访问控制:

  • Hook-PoolManager 交互:这涉及官方回调函数与 PoolManager 之间的交互。回调函数包括八个池操作回调(即 initializemodifyPositionswapdonate)以及锁回调(即 lockAcquired)。
  • Hook 内部 交互:这涉及 Hook 合约内部(作为锁定者)发生的交互。

Hook-PoolManager 交互相对简单。在此场景中,Hook 纯粹作为 Hook 使用,接受八个池操作回调。Hook 中的逻辑不影响相关池,这意味着 Hook 与池之间没有资金流动。回调函数提供的参数用于修改必要的存储或作为重要的函数参数。关键考量在于回调参数是否可以被操控

Hook 内部交互则相对复杂。实际上,许多 Hook 原型的功能不止于充当纯粹的 Hook。一些开发者允许 Hook 为其用户提供资金管理功能。这些功能可能并未在 Hook 合约中实现,但在此上下文中,我们仍可将其统一视为 Hook。在这些情况下,Hook 接受用户资金并执行池操作,例如流动性管理或交换。这意味着合约必须从 PoolManager 获取锁,从而使 Hook 成为锁定者。 Uniswap 基金会已考虑到这种情况,并在其 Hook 模板中集成了相关功能。具体而言,BaseHook 模板提供了 lockAcquired 函数作为锁回调,如下所示:

    function lockAcquired(bytes calldata data) external virtual poolManagerOnly
returns (bytes memory) {
        (bool success, bytes memory returnData) = address(this).call(data);
        if (success) return returnData;
        if (returnData.length == 0) revert LockFailure();
        // if the call failed, bubble up the reason
        /// @solidity memory-safe-assembly
        assembly {
            revert(add(returnData, 32), mload(returnData))
        }
    }

为了执行自定义逻辑,lockAcquired 接受 data 字节并使用该 data 对自身进行低级调用。data 取决于 Hook 的业务逻辑,可能被用户操控,从而因 lockAcquired 触发的Hook 内部交互而引发安全问题。请注意,Hook 的设计非常灵活,我们无法涵盖此情况下所有可能的场景。我们此处的主要关注点在于Hook 获取锁及其后续内部交互。深入探讨其他潜在的业务逻辑将使本次讨论过于复杂。

在上述两种场景中,首要任务是解决任何可能导致漏洞利用的缺陷访问控制,因为这些函数具有明确的交互实体。在后续子章节中,我们将依次检查每种场景,并讨论确保更安全交互逻辑所必要的访问控制。

漏洞分析

访问控制是许多项目中高效且直接的安全解决方案。如果一个函数被设计为由特定实体调用,则应纳入访问控制机制。最广为人知的访问控制示例是 OpenZeppelin 库的 Ownable 合约,该合约要求特权函数只能由合约所有者调用。显然,我们上述讨论的两种场景都是适合此类控制的案例。

Hook-PoolManager 交互:为确保与 PoolManager 的安全交互,Hook 应对这些回调函数强制执行必要的访问控制。具体而言,这些回调应仅由 PoolManager 调用,而不允许其他任何账户调用。若未建立此类控制,这些敏感接口可能面临被恶意行为者利用的风险。

除八个池操作回调外,锁回调(即 lockAcquired)在从 PoolManager 获取锁后执行自定义逻辑,同样需要解决这一问题。

Hook 内部交互:参与 Hook 内部交互的函数也被设计为由特定调用者调用。如前所述,此场景包含两个阶段。首先,锁定者的 lockAcquired 函数由 PoolManager 调用,这表明该函数应要求 msg.sender 为 PoolManager。其次,Hook 相应地分发函数调用。基于 BaseHook 的设计,这通过对 Hook 自身的低级调用来实现。这意味着这些函数必须定义为 external,并限制调用者必须为 Hook 的地址。

Awesome Uniswap v4 Hooks 代码库中列出的示例之一,即 止损订单 为例[2]:

止损订单直接集成到 Uniswap V4 池中,通过 afterSwap() Hook 在链上发布并执行,无需外部机器人或参与者来保证执行。

让我们来检查其 afterSwap 回调函数:

图1:止损订单的 afterSwap 函数
图1:止损订单的 afterSwap 函数

显然,上述函数被设计用于执行敏感操作。然而,由于存在缺陷的访问控制,恶意行为者可以通过操控参数(例如 keyparams)来利用此漏洞,从而导致意外行为。例如,afterSwap 回调可能在假设交换已在 PoolManager 中完成的前提下运行。随后,它可能会发起操作以记录重要的状态信息,例如当前价格或已收取的交换费用。然而,如果 afterSwap 不严格限制其调用来源为 PoolManager,恶意行为者可以伪造 params 参数,导致记录的状态出现偏差。

漏洞利用与 PoC

为简单起见,我们将使用一个基本的 PoC 来说明此访问控制问题。通常,Hook 的 beforeInitialize 接受 PoolKey 类型的参数,该参数的 hooks 字段中必须包含此 Hook 地址(因为 PoolManager 将使用此字段来确定要调用的 Hook 地址)。

下方截图提供了一个 PoC,演示了对具有缺陷访问控制的 Hook 的利用,如 DiamondHookPoC 所示[3]。 在对 beforeInitialize 回调函数缺乏访问限制的情况下,恶意行为者可以向此函数传入任意的 poolKey。该 Hook 不会验证此 poolKey 的 Hook 是否与当前 Hook 地址匹配。

图2:PoolKey.hooks 可被设置为零地址 beforeInitialize_poolKey_no_hooks_validation.webp

值得注意的是,此场景中的漏洞利用可能不会造成 Hook 的直接财务损失,但它仍然深刻揭示了 Hook 的状态如何通过未受保护的回调函数被操控。

如何缓解

为确保 Hook-PoolManager 交互的安全性,Hook 回调和锁回调都应将其可访问性严格限制为仅 PoolManager

幸运的是,Uniswap v4 通过其 v4-periphery 代码库[4]中的 BaseHook 提供了最佳实践。 BaseHook 提供了 poolManagerOnly 修饰符,以严格约束调用来源为 PoolManager

    /// @dev Only the pool manager may call this function
    modifier poolManagerOnly() {
        if (msg.sender != address(poolManager)) revert NotPoolManager();
        _;
    }

此修饰符可有效用于对敏感的 Hook 和锁回调强制执行适当的访问控制。

另一方面,Hook 内部交互的存在要求通过 BaseHook 规定的 lockAcquired 回调调用的任何重要状态更改函数不应被任意调用。

为满足此要求,BaseHook 提供了一个 selfOnly 修饰符。该修饰符将被声明函数的可访问性限制为 Hook 自身,禁止外部合约直接调用这些敏感函数以达到恶意目的。

    /// @dev Only this address may call this function
    modifier selfOnly() {
        if (msg.sender != address(this)) revert NotSelf();
        _;
    }

总而言之,通过继承 BaseHook,自定义 Hook 可以利用这些内置的访问控制修饰符和回调来强制执行适当的访问控制。

不当输入验证

v4-periphery[4] 中的 BaseHook 为更安全的交互逻辑提供了解决方案,Hook 的开发者可以加以利用。然而,我们持续观察到不当使用的情况,这为现有 Hook 中的攻击向量开辟了新的可能性。

默认情况下,Hook 允许任何池通过 PoolManager 中的 initialize 函数进行注册。然而,如果 Hook 未能验证注册池中的底层资产,恶意用户可能会注册一个包含伪造代币的池,从而通过代币的 transfer 函数重入 Hook。

这一漏洞较为隐蔽,因为 Hook 本身可能不会执行恶意逻辑。然而,当 Hook 调用 PoolManager 时,PoolManager 与恶意池底层资产之间的交互可能通过 PoolManager 中的 take 函数将控制流移交给攻击者。

    /// @inheritdoc IPoolManager
    function take(Currency currency, address to, uint256 amount) external override 
noDelegateCall onlyByLocker {
        _accountDelta(currency, amount.toInt128());
        reservesOf[currency] -= amount;
        currency.transfer(to, amount);
    }

本质上,该漏洞源于对 Hook 用户计划交互的注册池缺乏适当验证。我们将通过一个具体示例深入探讨此漏洞,并讨论潜在的缓解策略。

漏洞分析

止盈 Hook[5] 是 Awesome Uniswap v4 Hooks 中列出的一个 Hook:

在此示例中,我们构建了一个允许用户设置"止盈"仓位的 Hook。例如,在 ETH/DAI 池中,如果当前 1 ETH = 1500 DAI,您可以设置止盈订单为"当 1 ETH = 2000 DAI 时卖出我所有的 ETH",该订单将自动执行。

让我们来看看此 Hook 中的 _handleSwap 函数。该函数在获取锁后执行交换以完成止盈订单。

图3:止盈 Hook 的 _handleSwap 函数[5]
图3:止盈 Hook 的 _handleSwap 函数[5]

您可能注意到,此函数没有受到任何访问控制修饰符的保护。然而,第 250 行有效地限制了访问,使得此函数只能在从 PoolManager 获取锁之后才能被调用。否则,poolManager.swap 将会失败,因为操作者将不是最新的锁定者。换句话说,在已注册池通过验证的前提下,_handleSwap 必须按特定顺序调用。遗憾的是,该 Hook 并未实施此类验证。

由于这一存在缺陷的实现,该 Hook 容易受到重入攻击。此漏洞可能允许攻击者使用用户存入的资金强制执行任意交换。

漏洞利用与 PoC

具体而言,攻击可通过以下步骤实施:

  1. 攻击者使用伪造代币注册一个恶意池,并将止盈 Hook 指定为该池的 Hook。
  2. 攻击者通过 Hook 在恶意池中下达一个止盈订单。
  3. 攻击者在恶意池中执行一次交换,触发 afterSwap 回调中的 fillOrder 以完成攻击者的止盈订单。
  4. Hook 调用 PoolManagerlock 函数以请求锁,并在 lockAcquired 回调中调用 _handleSwap 函数。
  5. _handleSwap 函数中,代币转账触发了伪造代币合约中的恶意逻辑,该逻辑重入了 _handleSwap 函数。这是可能的,因为 _handleSwap 是一个没有任何访问限制的外部函数。由于锁已被获取,攻击者可以强制 Hook 在任何池上执行任意交换,只要 Hook 持有足够的底层资产。攻击者随后可以对这些交换实施夹单攻击,以牺牲其他用户利益为代价获取利润。

以下详细图表说明了攻击的流程。

图4:攻击流程
图4:攻击流程

如前所述,Hook 本身不会调用恶意逻辑。唯一的错误在于Hook 未阻止不受信任的代币池在 PoolManager 合约中注册。间接地,伪造代币合约中的恶意逻辑通过代币转账操作被调用,这也是一种不受信任的外部调用。

如何缓解

针对不当输入验证可能引发的潜在攻击,有三种可行的缓解方案:

  • 适当的访问控制。通过利用 BaseHook 的构建模块,Hook 可以严格管理函数的可访问性,防止任意账户调用敏感函数。

  • 重入锁。在上述攻击场景中,此方案无疑可以防止恶意代币逻辑重入敏感函数。然而,在某些情况下,Hook 的设计要求 Hook 本身是可重入的。 具体而言,当 Hook 需要执行某些池操作时,它应允许 PoolManager 重入其回调以完成这些操作。重入锁可能会破坏这一预期功能。

  • 白名单方案。这需要特权管理员在 Hook 中将已批准的池列入白名单。管理员确保被列入白名单的池不会引入潜在风险。然而,其局限性在于 Hook 用户只能通过 Hook 对有限数量的管理员批准池执行操作。 尽管白名单方案提高了安全性,但它严重限制了 Hook 的功能。

在安全性与易用性之间找到完美平衡对于 Hook 而言颇具挑战。尽管我们讨论了几种缓解方案,开发者仍需在其 Hook 设计中深思熟虑地权衡取舍。目标应是在保留预期功能的同时,尽可能降低潜在风险。此外,我们的讨论仅涵盖特定于 Uniswap v4 功能交互中可能存在的漏洞。实际应用无疑会更加全面。请务必确保您理解合约中的每一行代码,并保持安全!

结论

在本文中,我们探讨了 Hook 交互逻辑中出现的漏洞,特别聚焦于两种场景:缺陷访问控制和不当输入验证。我们提供了详细的漏洞分析,展示了潜在的利用方式及其 PoC,并讨论了潜在的缓解策略。我们相信这些见解有助于 Hook 的安全开发与使用,并为未来的漏洞检测工作提供指引。

参考文献

[1] Awesome Uniswap v4 Hooks

[2] 止损订单

[3] DiamondHookPoC

[4] v4-periphery

[5] 止盈

阅读本系列其他文章

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit