Uniswap V4 Hooks 安全深度解析

  • solodit
  • 发布于 4小时前
  • 阅读 47

本文对 Uniswap v4 自定义 hook 的公开审计报告中的发现进行了分类和分析,重点关注与 hook 权限、hook 回调、未处理的 reverts、动态费用、自定义会计、tick 相关问题以及其他常见漏洞相关的安全风险,同时还讨论了与原生代币处理、JIT 流动性、不正确的路由器参数和自定义激励相关的其他发现,为 hook 开发者和审计人员提供参考。

之前为 Cyfrin 博客撰写的文章 的基础上(该文章曾在 BlockThreat 新闻简报 中重点介绍),该文对来自公开审计报告的调查结果进行了分类和分析,这些调查结果专门针对 v4 自定义 hook。关于背景和框架,Uniswap Labs 提供的 Hook 权限的已知影响 和 Composable Security 提供的 此清单 等资源提供了有用的参考点。

正如 此 BlockSec 威胁模型 中强调的那样,hook 可能是良性的但易受攻击,也可能是故意恶意的。本文重点关注前者,其中类别之间存在一些重叠;但是,在与未经确认或未经审查的 hook 交互时,同样重要的是要考虑后者以及可升级性风险、私钥泄露和其他中心化问题。

还值得注意的是,潜在的攻击媒介可能来自 hook 本身中存在的现有、众所周知的智能合约漏洞。虽然会包含一些特别有趣的示例,但本文主要关注特定于 Uniswap v4 的漏洞和其他问题,因为它们与自定义 hook 接管资金和管理关键应用程序状态有关。

Hook 和权限

部署未实现权限的 Hook

Hook 权限编码在 hook 地址的最低有效位中,并与 flags 进行比较,以确定它是否应该实现相应的函数:

function hasPermission(IHooks self, uint160 flag) internal pure returns (bool) {
    return uint160(address(self)) & flag != 0;
}

如果一个 hook 缺少给定函数所需的权限,那么它实际上会表现为一个空操作 (no-op);但是,如果 hook 地址编码了某个权限,但没有实现相应的函数,那么(在没有任何回退逻辑的情况下)将导致 revert。

v4 周边合约 BaseHook 抽象合约 旨在通过验证已部署的 hook 地址是否与 hook 的预期权限一致来避免部署这样的 hook。OpenZeppelin 实现 提供了类似且更广泛的功能。

启发式: 该 hook 是否继承了抽象的 BaseHook 实现? 如果没有,是否有检查来避免部署编码特定权限但未实现相应功能的 hook?

部署没有实现函数所需权限的 hook

如上所述,如果一个 hook 缺少给定函数所需的权限,那么它的执行将被跳过。与在没有实现必要功能的情况下授予权限的情况相反,未能为已实现函数编码必要的权限很可能会导致意外和不期望的行为,因为关键逻辑被省略了。

此示例 中,协议费用旨在在 afterSwap() hook 中收取;但是,缺少 AFTER_SWAP_RETURNS_DELTA_FLAG 权限会导致所有 swap 的拒绝服务 (DoS),一旦协议费用启用。

合约创建代码以及 salt 和部署者地址决定了 hook 地址,因此不同的构造函数参数和部署者地址将导致不同的 hook 地址。同样,使用其中一种基础 hook 实现将有助于验证派生的 hook 地址的权限是否按预期进行编码:

/// @notice Utility function intended to be used in hook constructors to ensure
/// the deployed hooks address causes the intended hooks to be called
/// @param permissions The hooks that are intended to be called
/// @dev permissions param is memory as the function will be called from constructors
 function validateHookPermissions(IHooks self, Permissions memory permissions) internal纯{
    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?

未实现的函数允许绕过底层 Uniswap v4 合约

当专注于特定的 hook 实现时,很容易忘记 Uniswap v4 本身仍然存在的入口点。即使为预期公开的函数正确编码了所有 hook 权限,也可能存在未实现的函数实际上应该已经实现(并且相应地设置了权限)的情况。这个问题可能采取多种形式,具体取决于上下文,但最终出现的原因是应用程序的业务逻辑可能规定某些操作只能通过 hook 或相关合约执行。

例如,可能旨在 限制流动性修改仅通过 hook 发生,但被忽略的是,仍然可以直接通过 PoolManager 合约进行操作。除非实现了相关的流动性修改 hook 以在这种情况下进行 revert,否则可能会规避预期 hook 逻辑的执行。

此示例 中,旨在惩罚 afterRemoveLiquidity() 函数中的即时 (JIT) 流动性提供的 hook 的预期行为可以通过首先增加流动性来收集 swap 产生的所有费用来绕过。这会重置费用状态,从而绕过惩罚机制。在这种情况下,解决方案是将 hook 权限扩展为额外跟踪在 beforeAddLiquidity() 函数中发生的费用收取。

另一个更简单的 实例 是在不需要允许捐赠时。如果不激活 beforeDonate() hook 以始终在捐赠时进行 revert,则对 PoolManager::donate 的调用将成功增加费用增长。

启发式: 旨在源自 hook 或以其他方式更改其他已实现 hook 函数行为的操作是否可以直接在底层 Uniswap v4 合约上执行? 是否实现了必要的 hook 以在这种情况下控制执行?

池密钥验证不足

Uniswap v4 不限制谁可以创建新的流动性池,也不限制在新流动性池中使用哪个 hook 地址。如果 hook 不限于特定的池或一组池,攻击者可以使用虚假 token 部署恶意池,并通过重入或内部帐户操作等攻击媒介来滥用 hook。

与所有不安全外部调用和调用者提供的输入一样,验证不足可能会根据上下文危及 hook 合约,因此专为特定池设计的 hook 应在初始化期间验证池密钥中指定的 token 对。通常,Hook 函数还应仅向预期使用给定 Hook 合约并与之交互的 Uniswap v4 池的特定子集授予权限访问权限。

C-01 详细描述了这种漏洞的一种变体,其中受害者合约旨在协调所有生态系统 hook 和关联合约,但由于对 hook 和货币地址的输入验证不足,可以通过指定恶意池密钥来耗尽。

更多示例:[ C-02, H-01, M-02, L-12, 5, 6, 7]。

启发式: 是否验证了池密钥和相关的货币地址,以确保仅向预期使用 hook 并与之交互的 Uniswap v4 池的特定子集授予权限访问权限?

Hook 回调

回调访问控制不足

与其他类型的回调(例如闪电贷提供商调用的回调)类似,v4 unlockCallback() 和其他 hook 函数回调通常应仅由单例 PoolManager 合约调用。这些细节可能因给定的协议设计的具体情况而略有不同,在这种情况下,对访问控制的仔细管理甚至更为重要。

如上所述,可以通过继承 BaseHook 实现之一以及 SafeCallback 基础合约 并覆盖 _unlockCallback() 来实现保护。

/// @dev We force the onlyPoolManager modifier by exposing a virtual function after the onlyPoolManager check.
function unlockCallback(bytes calldata data) external onlyPoolManager returns (bytes memory) {
    return _unlockCallback(data);
}

/// @dev to be implemented by the child contract, to safely guarantee the logic is only executed by the PoolManager
function _unlockCallback(bytes calldata data) internal virtual returns (bytes memory);

也许此攻击媒介最引人注目的例子是它被用作 1200 万美元 Cork Protocol 漏洞 中的一个杠杆,其中攻击者使用恶意 hook 数据调用了 beforeSwap() 函数。

其他示例包括:

  • H-02,其中访问控制不足允许任何地址直接调用 beforeInitialize() 来覆盖存储的池密钥。

  • C-06,其中访问控制不足允许任何地址调用 beforeSwap()afterSwap(),从而破坏了核心限价单机制。

  • v4-stoploss hook 示例,它允许任何地址使用任意参数调用 afterSwap(),再次破坏了限价单机制。

启发式: hook 是否继承抽象的 BaseHook 实现? hook 或相关的合约是否继承抽象的 SafeCallback? 如果没有,是否有检查来防止非特权调用者调用 unlockCallback() 和其他 hook 函数?

不正确的回调返回数据编码

Hook 回调返回的数据长度应至少足以包含 4 字节的选择器 编码为 32 字节

// Length must be at least 32 to contain the selector. Check expected selector and returned selector match.
if (result.length < 32 || result.parseSelector() != data.parseSelector()) {
    InvalidHookResponse.selector.revertWith();
}

对于使用 Hooks::callHookWithReturnDelta 调用并期望解析返回增量的 hook 函数,数据长度应 正好为 64 字节 以包含编码的选择器加上 32 字节的增量:

// A length of 64 bytes is required to return a bytes4, and a 32 byte delta
if (result.length != 64) InvalidHookResponse.selector.revertWith();
return result.parseReturnDelta();

Hooks::beforeSwap 另外期望一个 3 字节的费用覆盖,并且 强制长度为 96 字节

// A length of 96 bytes is required to return a bytes4, a 32 byte delta, and an LP fee
if (result.length != 96) InvalidHookResponse.selector.revertWith();

如果返回数据的长度与预期不符,无论是由于编码了不正确的类型还是其他原因,执行都会 revert。

启发式: hook 是否继承抽象的 BaseHook 实现? 如果没有,Hooks 库是否按预期设置了 hook 函数签名和返回数据长度?

Before vs after hook

某些功能是在操作之前执行的hook中实现还是在操作之后执行的hook中实现可能看起来并不重要,并且在某些情况下,这可能完全没问题。但是,hook 开发人员和审计人员应清楚地了解对这种逻辑布局所做的假设,因为这可能会导致一类非常微妙的错误。

考虑一些自定义激励逻辑的示例。假设它打算在完全移除流动性后清理用于此类计算的关键状态。如果在 beforeRemoveLiquidity() hook 而不是 afterRemoveLiquidity() 中执行此操作,那么在执行时流动性将保持不变,并且状态将永远不会更新,结果是激励将针对不再存在的流动性头寸进行计算。虽然事后看来这很明显,但随着复杂性的增加,识别起来可能具有挑战性。

这也可以在相反的情况下发生,如 此示例 所示,其中 afterAddLiquidity() hook 中存在的 tick 初始化逻辑应放置在 beforeAddLiquidity() 中。在核心流动性修改逻辑已经运行后执行意味着在执行返回到 hook 后,tick 已经被初始化一次,因此条件逻辑永远不会被触发。

更多示例:[ 1]。

启发式: hook 逻辑是否取决于它是在核心 Uniswap v4 逻辑之前还是之后执行? 流动性、tick 初始化状态等是否会因选择 before/after hook 而异? 此处的错误是否会导致关键逻辑以不同、错误的方式执行或完全被省略?

未处理的 Revert

在审查 hook 时,务必注意 hook 业务逻辑以及基于流动性修改/swap 的潜在 revert 来源。

解锁前同步

PoolManager::sync 具有 onlyWhenUnlocked 修饰符,以确保只有在已经调用了 PoolManager::unlock 之后才能同步货币和储备的瞬时存储。如果 hook 函数 尝试在解锁 PoolManager 之前同步货币储备,执行将 revert。相反,应将此逻辑移动到解锁回调并在任何自定义帐户之前执行。

启发式:sync() 的调用是否总是在之前的 unlock() 之后执行?

💡

在最新版本的 PoolManager 合约中,情况不再如此 – 现在可以在不首先解锁单例的情况下调用 sync()

来自未清除或未同步 dust 的未结算增量

闪电帐户和解锁机制的关键不变性是,必须在将执行从解锁回调返回到 PoolManager 之前结算所有增量。诸如交换 token 或修改流动性之类的操作会生成增量,这些增量表示池欠或欠的 token 余额的净变化,并累积在瞬时存储中。如果在执行结束时有任何未结算的增量,则集成合约可能需要使用 take() 函数提取欠用户的资产,或者存入欠池的资产,然后调用 settle() 函数。

在某些情况下,可能存在小的“dust”余额,阻止帐户增量完全结算,因此应使用 clear() 函数明确地将其计入池中。这被实现为防止调用者资金损失的保护措施;但是,如果未正确处理这种情况,dust 余额可能会被用作 DoS 攻击媒介,如 H-01 中所示。

以类似的方式,L-13 详细描述了一个 DoS 场景,其中 token 被捐赠但未通过调用 sync() 在瞬时存储中同步,从而在尝试结算操作的增量时导致 revert。

启发式: 是否存在可能累积 dust 的任何场景,如果是,是否已明确清除它? 在执行修改帐户增量的操作之前,货币和储备是否始终在瞬时存储中同步?

修改流动性时 Revert

beforeRemoveLiquidity()afterRemoveLiquidity() hook 中未处理的 revert 可能会导致流动性提供者 (LP) 资金被永久锁定,除非有某种方法可以摆脱此 DoS 状态。此场景也可能阻止费用被收取,因为与 Uniswap v3 不同,由于费用增长而产生的增量需要在每次流动性修改时结算。在添加流动性期间 Revert 的问题稍小,因为这不会导致资金被锁定,但仍会对协议造成严重破坏。

其他示例包括 H-8 其中的初始 LP 由于协议特定的初始化而无法提取,正如 M-6 另外指出,此初始流动性提供不会退还未使用的 token。

启发式: 流动性修改 hook 中是否存在任何可能导致 revert 的调用? 是否明确处理了这些潜在的问题调用,以防止意外的 revert?

外围 hook 逻辑中的 Revert

应谨慎对待外围逻辑(例如自定义激励和重新平衡),以避免由于未处理的 revert 导致的 DoS 和潜在的资金损失。此类 revert 的严重程度取决于禁用的功能以及是否可以恢复。虽然无法添加流动性并针对受影响的池进行交换表示核心功能丧失,但最具影响力的案例是资金被永久锁定的案例,例如由于无法从池中移除现有流动性或提取合约保管的其他 token。

虽然不重要,但某些 revert 源可能 永远不允许运行预期的功能,其中不正确的访问控制阻止了外部集成正确执行。如果 hook 不可升级,则需要使用修复程序重新部署它,这可能会对协议及其用户造成严重破坏。

有时,逻辑对于操作的某一次调用可能听起来合理,但在后续调用中可能会失败,如 此示例 中所示,块顶部 swap 税错误地应用于所有其他 swap 并导致 DoS。用于计算此类税/价格/等的 数学运算中也可能存在边缘情况,这可能导致某些调用的子集失败。

H-05 是由于未能考虑多个池 ID 导致的 DoS 的一个示例。对于给定块中与给定 hook 关联的多个池,某些操作(例如重新平衡)可能是可能的;但是,逻辑可能是这样的,即忽略了这一点,如果未使用不同的 nonce,则将功能限制为仅一个池。

在示例 [ 1, 2, 3, 4] 中,由于溢出和过多的 gas 使用,可以滥用未经许可的奖励分配和范围创建来触发 revert。由于链之间的差异,此 DoS 向量也在 重新平衡激励 逻辑中得到证明。

更多示例:[ 1]。

启发式: hook 函数实现中的自定义逻辑中是否存在任何可能 revert 的调用? 是否存在可能 revert 的任何外围帐户机制,可能是由于过多的 gas 使用或上溢/下溢? 是否明确处理了这些潜在的问题案例,以防止意外的 revert?

动态费用

动态费用帐户不正确

如果池密钥指定使用 LPFeeLibrary.DYNAMIC_FEE_FLAG 来发出支持信号,则 Uniswap v4 池可以支持超出典型静态选项的动态 swap 费用。这在 Hooks::beforeSwap 中被查询,如下所示:

// dynamic fee pools that want to override the cache fee, return a valid fee with the override flag. If override flag
// is set but an invalid fee is returned, the transaction will revert. Otherwise the current LP fee will be used
if (key.fee.isDynamicFee()) lpFeeOverride = result.parseFee();

可以通过以下任一方式更新动态费用:

  • 让 Hook 合约调用 PoolManager::updateDynamicLPFee

  • beforeSwap() hook 返回 fee | LPFeeLibrary.OVERRIDE_FEE_FLAG

动态费用池初始化时具有 0% 的默认费用,因此应在 afterInitialize() hook 中调用 updateDynamicLPFee() 函数来 覆盖此费用

每次 swap 覆盖都允许动态费用发生变化,从而避免重复调用 PoolManager,并由 Pool::swap 处理,如下所示:

// if the beforeSwap hook returned a valid fee override, use that as the LP fee, otherwise load from storage
// lpFee, swapFee, and protocolFee are all in pips
{
    uint24 lpFee = params.lpFeeOverride.isOverride()
    ? params.lpFeeOverride.removeOverrideFlagAndValidate()
    : slot0Start.lpFee();

    swapFee = protocolFee == 0 ? lpFee : uint16(protocolFee).calculateSwapFee(lpFee);
}

如果省略了 LPFeeLibrary.DYNAMIC_FEE_FLAGLPFeeLibrary.OVERRIDE_FEE_FLAG 中的任何一个,则不会应用预期的费用覆盖,并且将使用默认费用。

即使正确指定了动态费用,实际的帐户逻辑也可能实现不正确。这可能会导致错误计算分配给预期接收者的费用,甚至导致无法恢复的余额被锁定在 hook 中。

更多示例:[ C-02, M-03, M-4, M-11, 5, 6, 7, 8]。

启发式: hook 是否打算支持动态 swap 费用,如果是,池密钥是否发出支持信号? 是否公开了具有足够访问控制的功能以直接在 PoolManager 上更新动态费用? 是否在 afterInitialize() hook 中覆盖了默认动态费用? 动态费用是否已使用应用的 LP 费用覆盖标志从 beforeSwap() hook 正确返回?

滥用动态费用

根据协议的配置方式,在某些情况下,特权调用者可以获得设置和调整动态 swap 费用的权利。例如,Bunni v2 实现了 拍卖管理 AMM 机制,以运行抗审查的链上拍卖,以获得暂时设置 swap 费率并从 swap 接收应计费用的权利。 TOB-BUNNI-11详细描述了一种恶意管理者在避免套利的同时操纵 TWAP 价格的场景。如果 oracle 用于确定外部借贷协议中的资产价格,那么管理者有可能通过借入高估抵押品来进行利用。

H-02 说明了恶意管理者如何以牺牲协议为代价来获取费用。

更多示例:[ H-02]。

启发式: 动态swap费用是否可以由协议管理员以外的行为者手动设置或调整?是否应用了合理的边界来防止操纵并减少其他费用接收者的损失?

自定义会计

自定义会计是 Uniswap v4 的一个非常强大的功能,但同样也很容易使整个协议的流动性面临风险。与普通的 hooks 不同,那些利用自定义核算的 hooks 会控制底层流动性,因此这些 hooks 的业务逻辑中的任何错误,或存储或以其他方式处理 ERC-6909 声明Token的任何其他相关合约中的任何错误,都可能是灾难性的。至关重要的是,这种核算必须是严密的。即使它看起来实施得很好,开发者和审计员都应该非常警惕所有价值的来源和流向,如下所示。

输入验证不足

输入验证不足可能会导致多种类型的错误,并且通常是某些最高严重性漏洞的先兆。在自定义核算的背景下,更复杂的 hook 协议架构可能会导致其核算被滥用,或者由于调用者使用任意输入调用某些函数而被破坏。

考虑 TOB-BUNNI-6,其中任何人都可以调用 rebalance order 的 pre/post hooks,以在合法的order履行之外触发 rebalance 逻辑,从而导致资金从中央 BunniHub 合约中提取。TOB-BUNNI-7 详细说明了 rebalance order 履行机制中的类似问题,在这种情况下,order履行输入本身的验证不足,从而允许不对称执行 rebalance order 的 pre/post hooks,这违反了预期的对称执行。

更多示例:[ TOB-BUNNI-8]。

启发式: 是否充分验证了与自定义账户相关的逻辑的所有函数的输入?是否应该需要特定的具有权限的地址?是否存在允许绕过或以其他方式违反预期的(可能是对称的)执行的任何输入组合?

资金隔离不正确

与典型的智能合约核算一样,可能存在指定用于不同目的和受益所有者的token余额。资金隔离不正确是一个常见问题,其中潜在的多个实体之一可以执行某些操作,以访问他们无权访问的资产/金额。

此类问题的严重程度取决于具体的业务逻辑,在影响较小的情况下可能仅限于提升的管理控制权,但在更严重的情况下可能允许攻击者提取价值。自定义 Uniswap v4 核算也不例外。递归 LP token、费用、捐赠和其他激励余额通常是容易被忽视的此类问题的来源。

C-05 说明了如何滥用原始 ERC-20 和 ERC-6909 核算之间的不匹配来耗尽池的底层货币。在此示例中,此类漏洞利用的前提是利用一个池的 LP token,该token存储在目标合约中作为租金支付,作为递归恶意池的货币。

这里可以找到滥用递归 LP token 耗尽奖励余额的类似示例。在这种情况下,增量会因流动性提供而丢失给 PoolManager。通过利用闪电核算进行同步、代表 PoolManager 领取、结算并最终获取提取的token,可以窃取由包装流动性token封装的底层token化激励。

H-04 显示了通过费用/捐赠机制累积的dust余额如何由于上述未结清或未同步的dust的未结算增量问题而导致 hook 恢复。

更多示例:[ H-02, 2, 3, 4, 5, 6]。

启发式: 是否有任何具有多个核算名称的资产或地址?是否存在 ERC-20 和 ERC-6909 核算的混合?如果是,是否协同工作正常?是否明确考虑了费用、捐赠或任何其他保留资产?是否应支持递归 LP 代币,并且在提供流动性时是否可以窃取任何底层收益?

错误的swap逻辑

自定义核算解锁的设计空间的一个关键方面是能够覆盖底层集中流动性模型的swap逻辑。虽然这种开创性的功能允许在原始 Uniswap v4 hook 架构之上构建创新的 AMM 设计,但这大大增加了攻击面和潜在的细微算术错误,这些错误可能会完全破坏核心功能。

考虑 TOB-BUNNI-15/16/17/18M-21,其中可能:

  • 执行免费swap,提供零输入token但接收非零数量的输出token。

  • 从往返swap中获得净正token数量。

  • 根据精确的输入/输出配置接收不同数量的输入/输出token。

  • 由于算术错误触发未处理的panic回退。

  • 低估可用于swap的流动性。

另一个示例 M-13 此外还详细介绍了swap的 DoS 向量,该向量错误地使用了指定的swap价格限制而不是计算下一个swap步骤的价格目标。

启发式: hook 是否覆盖了底层的集中流动性模型?掉期是否按预期运行?能否打破任何 AMM 不变量?

错误的 delta 核算

自定义核算的另一个方面是,hook 可以覆盖表示池、hook 和调用者之间token净移动的增量。如果这些增量计算或应用不正确,可能会导致无声但严重的核算差异,从而以协议或其用户的利益为代价泄漏价值,具体取决于监督的性质。

这里容易犯的一个错误是误解 swapDeltahookDeltacallerDelta 的符号约定。例如,如果在 beforeSwap() 中低估了指定token的调用者增量,或者如果在 afterSwap() 中高估了 hook 增量,则可能导致 hook 少付给其用户费用。在最坏的情况下,如果token以错误的方向转移,则可能会以 hook 为代价从池中提取价值。

请注意,核心 Hooks::beforeSwap 逻辑可防止通过应用 hook 增量来更改swap的语义:

// 根据 hook 的返回更新swap数量,并检查swap类型是否未更改(精确输入/输出)
if (hookDeltaSpecified != 0) {
    bool exactInput = amountToSwap < 0;
    amountToSwap += hookDeltaSpecified;
    if (exactInput ? amountToSwap > 0 : amountToSwap < 0) {
        HookDeltaExceedsSwapAmount.selector.revertWith();
    }
}

换句话说,明确禁止精确输入swap变成被认为是精确输出swap,反之亦然。但是,如果 afterSwap() 返回一个符号不正确的 hookDelta,认为应该支付,那么支付的金额和方向可能会反转。

更多示例:[ M-02, 2]。

启发式: 净值流动方向是否与最初的意图一致?用户是否会被少收/多收费用或直接从 hook 中提取价值?增量是否在应该减小/递减时被添加/递增,反之亦然?

重入

如引言中所述,hook 可能会遭受众所周知的智能合约漏洞的影响,包括重入,这可能是由于在更复杂的 hook 协议架构中将许多较低严重性问题链接在一起而引起的。这正是 Bunni v2live critical vulnerability 中发生的情况,由 Cyfrin 报告 ,其中整个协议 TVL 面临风险。

简而言之,通过 deployBunniToken(),可以部署未经任何验证的恶意 hook,随后可用于解锁全局重入保护。由于 Bunni 池的 hook 不受限制为规范的 BunniHook 实现,并且考虑到 unlockForRebalance() 仅要求给定池的 hook 成为调用者,因此任何人都可以创建一个恶意 hook,该 hook 直接调用此函数以解锁保护核心 BunniHub 合约的重入保护。这与在对再抵押金库进行不安全的外部调用之前缓存池状态相结合,攻击者可以再次自由地将这些金库指定为任意恶意地址,这意味着实际上可以从 hookHandleSwap()withdraw() 中进行重入,以递归方式提取跨所有池计算的原始余额和金库储备。

C-03 详细介绍了此问题的类似但影响较小的变体,其中可以滥用 pre/post hooks 之间的中间状态的存款执行。这恰好也是引入有问题的函数的发现,该函数允许将一系列较低严重性的漏洞组合成一个完全耗尽的关键漏洞。与 1.97 亿美元的 Euler 漏洞利用 类似,这突显了全面缓解审查的挑战以及识别细微的(看似微不足道的)逻辑变更的潜在下游影响的重要性。

即使无法在目标合约上直接重入,由于 L-13 中指出的缺少重入保护,只读重入仍可能影响依赖于报价函数的集成合约。

启发式: 规范hook或其任何相关合约是否执行不安全的外部调用?这是否可以受到调用者定义的输入的影响,这些输入可能会指定恶意地址?是否对所有关键函数应用重入保护?它是否可以被绕过,也许是通过恶意的hook实现?

舍入和精度损失

另一个众所周知但棘手的智能合约漏洞是由于舍入造成的精度损失。虽然识别何时会发生此行为可能相对简单,但要确定可能在攻击中利用的具体场景则更具挑战性。

840 万美元的 Bunni v2 漏洞利用 中,不幸的是,上述披露不足以防止资金的灾难性损失。从本质上讲,此漏洞利用是由底舍入引起的,这允许攻击者构建原子流动性增加。总而言之,漏洞利用步骤包括:

  1. 交换池的几乎所有储备,以最大限度地减少活动余额,从而使舍入误差变得显着。

  2. 反复提取少量份额以滥用闲置余额的舍入行为,从而有效地创建份额价格膨胀,同时不成比例地减少基于活动余额和闲置余额的流动性计算。

  3. 交换回来以锁定原子流动性增加,然后在膨胀的价格下耗尽池。

虽然以上几点可能看起来很简单,但这是一个针对复杂协议的复杂漏洞利用。可悲的是,攻击者精心制作输入以完成此操作的精度。可以在 此处 找到完整的事后分析报告,以及 此处 的更详细分析。强烈建议阅读这两篇文章,以了解问题的全部细微差别。

更多示例:[ 1, 2]。

启发式: 任何 hook 逻辑是否容易受到舍入误差的影响?如果是这样,那么如何滥用它是否显而易见?如果不是,请考虑哪些其他计算可能依赖于它(例如,份额价格、流动性等),以及执行可能如何在极端情况下进行。

Tick 问题

未对齐的 Tick

根据给定池的 tick 间距,在最小/最大可用 tick 和由 Uniswap v3 数学定义的实际最小/最大 tick 之间存在一个小的 tick 空间区域,应仔细避免。例如,假设 tick 间距为 60,则可用 tick 范围为 [-887220, 887220]。因此,sqrt 价格 tick 不应进入范围 [-887272, -887220) 或 (887220, 887272]。

当流动性头寸的 tick 不是池的 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
        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 范围之外。即使池中只有少量非零流动性,也可能因 tick 被操纵到极端值而出现各种问题。

在某些情况下,这可能会通过套利导致诚实用户遭受损失,如 C-03 中所示。在其他情况下,交换向这些终端范围添加单边流动性 可能导致核心功能的完全 DoS。

对于实施额外激励措施的协议,应注意 在没有池流动性时避免执行计算和存入token

更多示例:[ H-01, L-26]。

启发式: 是否可以自由地将 tick/范围推送到或以其他方式指定为极端值?它们是否针对给定 tick 间距的最小/最大可用 tick 进行了验证?当没有流动性时,是否有任何其他机制会不正确地运行?

错误的 Tick 穿越

Uniswap v3 集中流动性 tick 穿越逻辑定义了在范围内和活跃状态下考虑流动性的位置(即,价格在上限/下限 tick 之间)。活跃流动性由交换使用并赚取费用,因此每当价格穿过 tick 边界时,也会为变得活跃/不活跃的流动性头寸触发费用增长状态更新。

对活跃流动性的错误计算可能会产生灾难性后果,如 KyberSwap Elastic 关键漏洞披露5600 万美元的 KyberSwap Elastic 漏洞利用此处 解释得很好)和 440 万美元的 Raydium 漏洞利用 所证明的那样。这可能是由于在交换期间穿过 tick 时的舍入或不正确的状态更新,或修改流动性,例如导致重复计数或其他可能被攻击者利用的差异。

此示例 中,活跃流动性计算似乎按预期运行;但是,对于零换一的交换,其中当前 tick 是流动性范围上限处的 tick 间距的精确倍数,边界 tick 的效果会被跳过,并且流动性会被错误地计算。因此,净流动性远小于预期,这最终会夸大计算出的费用增长,并允许所有奖励被这种有意的,甚至可能是无意的,恶意头寸窃取。

更多示例:[ H-01, 2]。

启发式: tick 穿越是否已正确处理?是否存在任何边缘情况,例如准确地在 tick/单词边界上开始/结束,其中逻辑会中断?围绕这些边界条件是否有足够的测试?

其他

本地token处理

与 Uniswap v3 不同,Uniswap v4 支持通过自定义 Currency 类型抽象出的原生 ETH。虽然这成功地提供了更好的用户和开发人员体验,但并非没有危险。对本地token的不充分或不正确的处理可能会导致一些与本地token支持相关并应预期的典型问题。

第一个也是可能影响最大的点是,本地token转移是 潜在重入 的来源。在处理 Currency 类型和 ERC-6909 表示时,这可能更容易被忽视,但识别所有可能被重入的不安全外部调用的来源仍然非常重要。

第二个更微妙的错误是缺少 receive() 函数。根据 hook 及其相关合约如何以本地token的形式转移和接收价值,这可能会导致 DoS。H-04 说明了当无法接收从路由合约重定向的微尘余额时,这种情况是如何发生的。

在最坏的情况下,如果实现了该功能但 msg.value 未经过充分验证,则可能导致本地token余额被锁定,如 L-22 中所示。这也可能导致破坏自定义交换增量核算,如 M-19 中所示。

另请注意,没有必要对 currency1 执行验证,因为本地token只能是 token0,因为货币按地址排序,并且 address(0) 用于原生货币 [ 1, 2]。

启发式: hook 是否支持原生 ETH?如果是这样,它可以用于重新进入任何关键功能吗?期望接收本地token的合约是否实现了 receive() 函数?如果不是,是否已考虑路由器集成等边缘情况?msg.value 是否经过充分验证?

JIT 流动性

即时流动性支持创新的资本效率协议设计(例如 Euler),但也可以恶意地用于操纵定价或奖励机制。这可能包括暂时抬高流动性头寸以获取费用、激励措施或在立即提取之前更改其他核心状态,如 M-1/2 中所示,这依赖于抢先自定义流动性和奖励逻辑。

TOB-BUNNI-9 描述的场景显示了 hook 撤回队列逻辑中的一个错误如何启用 JIT 流动性供应,这可用于操纵池重新平衡order计算,可能使其处于比以前更糟糕的状态。

更多示例:[ 1]。

启发式: 是否可以提供 JIT 流动性?它是否代表净正流动,或者它是否可能是恶意的并且应该被阻止/阻止?

错误的路由器参数

虽然 Uniswap V4Router 与所有其他路由器实现一样,是一个外围合约,但必须注意确保:

  1. 所有接收者和token地址都是正确的。

  2. 金额不会意外地为零或颠倒。

C-03 是前者的一个例子,而 C-04/H-07 是后者的例子,但这些错误当然也可能发生在 hook 逻辑本身中。

启发式: 是否指定了正确的接收者地址?它不一定总是 msg.senderamount0/token0 是否意外地用于代替 amount1/token1,反之亦然?

自定义激励

以下是一些其他的有趣的发现列表,这些发现与 Uniswap v4 hooks 没有直接关系,而是与自定义 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 穿越和费用增长计算时。对于更复杂的 hooks,完整的端到端测试还应验证多个集成合约之间的 token 流动,以确保没有遗漏的逻辑/转移可能导致资金被卡住。

结论

Hooks 是一项非常强大的创新。这种设计大大降低了 AMM 实验的门槛,但也同样提高了健全和安全实现的门槛,尤其是在自定义底层集中流动性机制时。正如本文所希望展示的那样,整体攻击面很大,而且可能相当微妙。如果你觉得它有帮助,我们将非常感谢你的放大和所有反馈!

  • 原文链接: surfing-solodit.com/unis...
  • 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
点赞 0
收藏 0
分享
本文参与登链社区写作激励计划 ,好文好收益,欢迎正在阅读的你也加入。

0 条评论

请先 登录 后评论
solodit
solodit
江湖只有他的大名,没有他的介绍。