本文是 Notorious Bug Digest 5,总结了近期Web3领域的一些安全漏洞和事件。
作者:Jainil Vora, Frank Lei, Ionut-Viorel Gingu, Dario Lo Buglio & Henrique Scocco
欢迎来到臭名昭著的 Bug 摘要 #5——精选的近期 Web3 Bug 和安全事件的见解汇编。我们的安全研究人员在不深入审计时,会投入时间来了解安全领域的最新动态,分析审计报告并剖析漏洞。我们认为,这些知识对于广大安全社区来说是宝贵的,它为研究人员提供了磨练技能的资源,并帮助新手驾驭 Web3 安全世界。加入我们,一起探索这批 Bug!
EIP-7702,在 Pectra 升级中引入,允许 EOA 在其帐户中设置代码。此更改弃用了常用的确保调用者是 EOA 的“仅 EOA”检查(即,msg.sender == tx.origin),从而破坏了向后兼容性。在 EIP-7702 之前,事务 EVM 只能由 EOA 发起,并且 tx.origin 始终保证为 EOA。
“仅 EOA”检查有效地确认了调用者不是合约,并减少了对诸如基于闪电贷或重入的漏洞利用等攻击媒介的担忧。但是,在 EIP-7702 之后,发起交易的 EOA 可以像具有 EIP-7702 委托代码的合约一样运行,这会破坏依赖于 msg.sender == tx.origin 作为仅 EOA 安全保护的合约的假设。
在审查的事件中,攻击者利用此向后兼容性问题,于 2025 年 8 月 24 日黑了一个 BSC 链上的未验证合约。受害者合约 是一个 staking 合约,允许用户 stake Wrapped POT Tokens (POT) 并生成以相同 POT 代币计价的基于时间的奖励。它还依赖于 PancakeSwap 的 BSC-USD/POT 池 来获取 staking 时的 POT 代币价格。
受害者合约使用上述“仅 EOA”检查来确保 0x93649277(stake) 函数免受 staking 期间的任何闪电贷攻击,并且链上 Uniswap V2 类型池价格(通过 getReserves())将在不受操纵的情况下获取(如图像 A 所示)。在获取 POT/BSC-USD 价格后,该函数将计算并稍后存储 staking 的 POT 代币价值(如图像 B 所示)。

图像 A - Stake 函数

图像 B - 链上价格获取
攻击者在 victim 合约中进行 staking 的同时操纵 POT 代币的价格,并在攻击结束时通过以下步骤产生了 8.5 万美元的利润:
tx.origin == msg.sender 检查通过,使 victim 合约相信调用者是 EOA,而实际上,它是在幕后工作的委托的恶意合约。
图像 C - 恶意委托的 EOA 调用
fallback 函数中,攻击者借入了 350 万美元的 BSC-USD 代币的闪电贷,并从 PancakeSwap BSC-USD/POT 池 购买了等量的 POT 代币以提高其价格。0x93649277 函数来 stake 约 22 万个 POT 代币。在这里,0x93649277 函数利用了 POT 代币的操纵价格,并将膨胀的价值添加到用户的 stake 头寸。
图像 D - 攻击步骤
unstake 函数 - 通过调用攻击者的 自己的委托 EOA 地址 上的 withdraw 函数,然后调用 unstake 函数。该 unstake 函数将 330 万个 POT 代币转账给攻击者,这远远超过了最初 stake 的 22 万个 POT 代币,这是由于存储的膨胀的 stake 值和 POT 价格被重置。攻击者将这些 POT 代币兑换为 BSC-USD 代币,并能够产生 8.5 万美元的利润。值得注意的是,受害者合约在 stake 和 unstake 操作之间有 1 天的延迟(如图像 E 所示)。攻击者巧妙地将 stake 交易 的时间安排在 2025-08-24 23:59:06 (UTC),并将 unstake 交易 的时间安排在 2025-08-25 00:00:14 (UTC) 以避免延迟。

图像 E - Unstake 函数
总而言之,这是首批基于 EIP-7702 的漏洞利用之一,其中仅 EOA 可以发起交易的假设被使用委托的 EOA 颠覆了。
Uniswap v4 hooks 让池在生命周期点(例如,添加/删除流动性之后)运行自定义逻辑。在我们对 Uniswap Hooks 的 审计 期间,我们报告了一个与 just-in-time (JIT) 流动性 的惩罚机制相关的微妙 Bug。
JIT 是一种 AMM 套利策略,其中流动性提供者 (LP) 在大型兑换交易发生之前暂时向池中添加集中的流动性,从该兑换中赚取费用,然后立即删除流动性。通常,这是通过发现公共内存池中的待处理兑换,捆绑交易(例如,通过 Flashbots)以添加流动性,让兑换得到处理,并在同一区块内燃烧该头寸来完成的。
为了防御此类套利活动,可以使用 LiquidityPenaltyHook 合约,其中费用会在新的头寸上被扣留,并且如果在短时间内(blockNumberOffset)移除流动性,则会应用惩罚,然后在移除时捐赠给范围内的 LP。如下面的代码所示,捐赠是通过 poolManager.donate 函数进行的,并增加了 LP 的累计费用。

图像 F - 在 ‘afterRemoveLiquidity()’ Hook 中
然而,存在一个允许绕过套利保护措施的微妙缺陷。如果攻击者控制在移除时设置的范围内 LP,则“捐赠”的惩罚只会返回给攻击者。例如,攻击者可以使用辅助帐户在其他情况下为空的 tick 范围内放置流动性,并且攻击将按如下方式进行:
B 在低流量或遥远的 tick 范围内提供少量流动性,其中不存在其他流动性。A 在当前 tick 附近添加一个大的流动性头寸,预计即将到来的用户兑换。这些兑换产生费用,这些费用会累积到 A,但在 JIT 窗口期间会被 hook 扣留。A 通过兑换将池的价格移动到 B 的 tick 范围内。当 A 移除流动性时,hook 会通过将费用捐赠给范围内的头寸(B 的头寸)来惩罚该行为。然后,B 可以立即提取捐赠的费用。A 再次兑换以将池恢复到其原始状态并避免套利。
动画 A - JIT 惩罚绕过过程
在具有一般流动性的真实池中,这种攻击发生的实际风险很低,因为将价格移动到特定的 tick 范围、提取流动性并恢复价格是昂贵的,通常超过了从捐赠费用中获得的潜在收益。此外,攻击者需要在 JIT 窗口内进行足够的用户兑换。
虽然此问题的严重性被认为是低的,但这种攻击更有可能在低流动性池中获利,并且利用模式为 Audit 提供了重要的启示:如果接收者受攻击者控制,则可以操纵基于捐赠的惩罚。在这种情况下,“惩罚”变成了回扣,有效地绕过了保护。
这个关键 Bug 是在 OpenZeppelin 最近执行的 f(x) v2 协议审计 中发现的。我们将首先了解 f(x) 协议如何管理多个头寸,然后继续揭示该 Bug。
f(x) 协议是一个独特的系统,提供两种产品:
当用户开设多头头寸时,他们提供抵押品、借入 fxUSD 并创建一个超额抵押头寸。借入的 fxUSD 代币被新铸造为协议的债务,然后通过闪电贷在市场上出售,以为用户创建 xPositions,如此处 解释。
只要基础抵押品的价格上涨,协议就会保持健康,因为所有头寸都保持超额抵押。但是,如果价格下跌,则需要重新平衡或清算头寸。重新平衡可以被认为是部分清算,其中仅调整头寸的一部分以恢复抵押。
为了最大限度地提高效率,f(x) 协议使用价格带系统管理头寸。每个价格带的宽度为 0.15%,并且同一价格带内的头寸会一起重新平衡或清算。该系统在内部通过使用 Disjoint-Set Data Structure 的 "tick-tree" 机制实施,其中每个 tick 代表一个价格带。在协议的上下文中,tick 反映了债务与抵押品之比:头寸的债务越高,其 tick 越高,因此,其清算风险越大。
每个 tick 都分配有一个默认的根节点。根节点始终将 debtRatio 和 collRatio 设置为 100%。这两个值分别表示该节点未清算的债务和抵押品的百分比。每当创建一个新头寸时,都会根据该头寸的债务和抵押品计算其 tick,并且始终将其添加到该 tick 的根节点。如图像G所示,头寸 P1、P2、P3 和 P4 附加到每个 tick 的根节点,并且随着新头寸添加到每个节点,totalDebt 和 totalColl 都会被更新。

图像 G - Tick-Tree 结构
当价格下跌时,头寸不会单独清算。相反,整个 tick 都会被清算。在图像 H 中,我们可以看到,随着价格的下降,tick 103 被清算并分配了一个全新的节点(节点 ID:2045),节点中的 totalDebt 和 totalColl 都重置为 0,并且比率重置为 100%。较旧的节点及其附加的头寸会从树中丢弃。
当一个 tick 被部分清算时,根节点会通过重新计算剩余的抵押品和债务而移动到新的 tick。然后,根节点会成为新 tick 的根节点的子节点。在图像 H 中,我们可以看到节点 ID:1024 从 tick 100 移动到 tick 80,并成为 tick 80 根节点(节点 ID:1031)的子节点。请注意,节点 1024 的 collRatios 和 debtRatios 值已更新为抵押品和债务的 75%。这意味着该节点值的 25% 已被清算,而 tick 100 获得了一个新的节点 2048,类似于 tick 103。

图像 H - 清算和再平衡情景
当需要更新头寸时,它会计算到最后一个根节点的所有 collRatios 和 detbRatios 值,以得出其剩余抵押品和债务的精确金额。为此,它利用具有定点乘法累积的 路径压缩算法。
此 Bug 结合了位于代码库不同部分的 3 个不同的问题:
_operate 函数用于更新/管理头寸,它依赖于 路径压缩递归函数 _getRootNodeAndCompress 来获取头寸的当前根节点,并在发生任何部分清算时更新头寸节点的 collRatios 和 debtRatios。但是,观察到如果树包含大约 150 个子节点,则此递归函数可能会触发 "Stack Too Deep" 错误。
图像 I - 递归 ‘getRootNodeAndCompress()’ 函数
redeem、rebalance 和 liquidate 函数对部分清算没有任何最低金额要求。这允许任何人清算顶部 tick 的债务,小至 2 wei。这个少量保证了顶部 tick 到同一 tick 上的部分清算。在极端情况下可以取消暂停的 redeem 函数允许清算多个 tick,而无需任何价格变动。
redeem 功能可以 从顶部 tick 清算最多 20%(即,它开始减少高杠杆头寸,直到成功赎回给定数量的 fxUSD)。在极端情况下,由于此功能,高杠杆头寸可能会遭受未实现利润的损失,但 fxUSD 的Hook得以维持。_liquidateTick 函数未验证 分配给部分清算的 tick 的新 tick 是否与当前的 tick 相同。这会创建一个如图 J 所示的场景,其中 tick 99 被部分清算,并且较旧的根节点(节点 ID:1002)最终指向同一 tick 的新根节点(节点 ID:2050)。图 K 显示了 _liquidate 函数中的错误代码。
图像 J - 附加在同一 Tick 上的子节点 图像 K - 内部 ‘_liquidateTick()’ 函数

图像 K - 内部 ‘_liquidateTick()’ 函数
攻击者可以利用上述递归属性、缺失的最小 rawDebt 要求和清算 tick 设计来执行以下步骤:
redeem 函数,用最少量的 rawDebt (fxUSD) 燃烧(例如,燃烧 2 wei 大约 150 次)。这确保了顶部 tick 永远不会更新,并且创建了 150 个子节点。redeem 函数,用计算出的高额转移到新的顶部 tick,以瞄准更多头寸。rebalance 和 liquidate 函数也可以用于执行攻击向量,但只能瞄准在给定的价格变动中可以清算的顶部 tick。redeem 函数一旦激活,就可以瞄准多个 tick。
在攻击向量结束时,树结构将类似于图 L 所示的结构,其中 tick 80 被瞄准以产生 150 多个附加到同一 tick 的子节点。每当用户尝试使用 operate 函数关闭或更新其中一个受影响的头寸(P1、P2 或 P3)时,_getRootNodeAndCompress 函数将因堆栈溢出错误而失败,因为子节点将超过某个限制。用户将无法关闭或更新他们的头寸,因为他们只能重新平衡或清算,导致用户资金被锁定。

图像 L - 操纵后的树结构
即使没有 redeem 函数,"Stack Too Deep" 错误也表明,任何重新平衡超过 150 次的头寸都将无法更新或关闭。
为了解决这个问题,团队转向了 _getRootNodeAndCompress() 函数的迭代版本,为 redeem、rebalance 和 liquidate 功能引入了最小的 rawDebts 要求,并添加了一个检查以确保 tick 在 redeem 函数期间始终移动。还引入了一个管理员函数来压缩节点链,以防通过对树结构的链下监控检测到任何意外行为。包含这些更改的拉取请求可以在 此处 查看。
总而言之,递归函数总是容易出现 "Stack Overflow" 错误。此外,必须彻底检查将这些函数置于拒绝服务 (DoS) 状态的潜在操纵向量。
注意:OpenZeppelin 审计 专门涵盖了多头头寸 (xPosition)。当时,尚未引入空头头寸 (sPosition),因此不在审计范围内。从那时起,协议已得到重大更新以支持空头头寸。
重要的是要强调,此内容背后的意图不是批评或指责受影响的项目,而是提供对漏洞的客观分析,这些漏洞可用作 Web3 社区学习的教育材料,并在未来更好地保护自己。
准备好保护你的代码了吗?
- 原文链接: openzeppelin.com/news/th...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!