本文是Notorious Bug Digest系列的第四期,主要分析了近期Web3领域出现的一些安全漏洞和事件。
合作者:Ionut-Viorel Gingu & Jainil Vora
欢迎来到 The Notorious Bug Digest #4——精选的 Web3 最新漏洞和安全事件的见解汇编。我们的安全研究人员在进行审计之余,还会花时间了解最新的安全领域动态,分析审计报告,并剖析链上事件。我们相信这些知识对于更广泛的安全社区来说是宝贵的,它为研究人员提供了一个磨练技能的资源,并帮助新手了解 Web3 安全的世界。加入我们,一起探索这批漏洞!
Binance Smart Chain 上的一个 KRC/BUSD 流动性池在一次漏洞利用中被耗尽。一个 AI 驱动的区块链交易分析代理 标记了一笔交易 为“涉及闪电贷和重复 skim()
调用的复杂漏洞利用”,并将根本原因归因于“skim/reserve
逻辑中缺少检查,使攻击者能够重复提取剩余的 token”。但这个诊断正确吗?
让我们深入研究 攻击 过程,如图 A 和图 B 所示:
pool.skim()
。skim
函数通过转移池的储备金和 token 余额之间的差额,强制使池的余额与其储备金相匹配,从而允许攻击者检索转移的 KRC token。图 A
图 B
攻击的一个关键方面是为什么池的 KRC 余额在 transfer-skim 操作期间不断减少。
检查 KRC token 的 transfer 实现(图 C 和图 D),我们看到它包含了 通缩 逻辑。具体来说,当池是接收者(即,兑换 BUSD)时,池的余额中会销毁 9% 的转移金额,这不会被池注意到和处理。此外,token 发送者会将转移的 token 的 10% 损失到一个特定的地址。 图 C
图 D
通过重复的 transfer-skim 操作,攻击者不断触发通缩逻辑,导致攻击者和池的 KRC 余额以线性速度减少。这种情况一直持续到 KRC token 的价格变得非常高,使攻击者能够用他们剩余的 KRC token 兑换池中几乎所有的 BUSD,从而有效地耗尽它。值得注意的是,为了确保利润超过成本,攻击者必须使用从初始兑换中获得的 KRC token 来进行攻击,而不是使用闪电贷或外部购买的 token。
在 FIRE token 的案例中,也出现过类似的漏洞利用,token 通缩暂时性地降低了流动性池的储备金。这使得攻击者能够以有利的价格兑换 token,从而慢慢耗尽池。
总而言之,通缩 token 对 AMM 池构成重大风险,尤其是在攻击者可以通过 token 销毁直接操纵池的余额或储备金时。AI 代理的误诊突出了需要对 AMM 特定的机制进行更细致的分析。通过以更细的粒度检查针对典型兑换或套利活动的攻击交易,AI 可以更好地检测到像这里利用的通缩行为这样的异常。
MetaPool 的 mpETH
合约继承了 OpenZeppelin 的 ERC4626Upgradeable
合约,以支持其基于 vault 的 Staking 机制,并在 Staking ETH/WETH 资产时提供 mpETH token 作为份额。值得注意的是,ERC4626Upgradeable
合约的 public
mint
和 deposit
函数都会调用 internal
_deposit
函数,其中包含最终的检查,以确保 assets
已从调用者转移到合约(我们称之为“收据检查”)。
在 mpETH
合约中,ERC4626Upgradeable
的 public
deposit
函数和 internal
_deposit
函数已被重写,并且收据检查已从 _deposit
函数中移出并移入 deposit
函数中。这样做是为了支持新的功能,包括设计变更,比如将 token 转移移到 internal
_deposit
函数之外。
然而,开发团队显然没有考虑到,除了 deposit
函数之外,mint
函数也依赖于 _deposit
来进行收据检查。因此,mint
函数没有被重写,并且没有在其中实现收据检查。因此,如果有人调用 mint
,则永远不会执行收据检查,并且他们将能够免费铸币。虽然该漏洞非常简单,但攻击向量并非如此。新的
_deposit
函数不是直接铸造 mpETH,而是首先使用 MetaPool 的 LiquidUnstakePool
- mpETH/ETH
池 进行兑换,并返回这些 mpETH token。只有当上述池无法提供所需数量的份额时,它才会铸造新的 mpETH token。为了利用此漏洞,攻击者 执行了以下步骤:
depositETH
函数,以存入大量的 ETH,从而完全耗尽 MetaPool 的 mpETH/ETH
池mint
函数。由于使用的 _deposit
函数是新的重写函数,并且 mpETH/ETH
池已被耗尽,因此免费为攻击者铸造了新的 mpETH tokenmpETH/ETH
池上将 mpETH 兑换为 ETH 以偿还闪电贷,随后在 Uniswap V3 池中兑换新铸造的 mpETH token,以产生 8.89 ETH 的净利润顺便提一下,攻击者 被 MEV 机器人抢先交易,该机器人获得了 45.79 ETH 的利润。
总而言之,必须彻底审查对 internal
函数的重写及其对父合约的依赖性,以防止此类意外后果。
Permit2
合约 提供了两个关键功能:
Permit2 通过 ECDSA 签名验证(如果调用者不是智能合约)或通过 ERC-1271 签名验证(如果调用者是智能合约)来授权签名。为了防止签名重放攻击,Permit2
会跟踪每个唯一的(owner
、token
和 spender
)组合的 nonce,并确保合约跟踪的 nonce 与签名中或调用者提供的 nonce 相匹配。如果它们不匹配,则不会授予授权,并且交易将被回滚。
Across Protocol 的 SpokePoolPeriphery
合约通过直接 转移、ERC-20 批准 或通过 Permit2
来促进 token 兑换。由于 SpokePoolPeriphery
是一个合约,因此使用的授权方法将是 ERC-1271,并且如果 SpokePoolPeriphery
的 nonce 与 Permit2
的 nonce 相匹配,则将授予授权,从而导致 isValidSignature
调用 通过。
我们 发现了一个漏洞,该漏洞存在于 performSwap
函数 中,该漏洞是由于可以任意指定 exchange
和 routerCalldata
参数的灵活性而引起的。
图片 H
如果恶意用户将 exchange
设置为 Permit2
合约,并将 routerCalldata
设置为 invalidateNonces
函数,他们可以人为地增加预期的 nonce,从而有效地解除 Permit2
和 SpokePoolPeriphery
合约之间的 nonce 值的同步。
图片 I
这种解除同步会导致无法纠正的交易失败,因为 SpokePoolPeriphery
中的 nonce 调整取决于交易的成功完成。
这个问题强调了严格审查基本假设(例如 exchange
和 routerCalldata
参数的性质)以及彻底检查外部依赖项(即,Permit2.invalidateNonces
函数的存在,该函数超出了该审计的范围)的必要性。
在 Rust 中,对宽度为 N
的值执行 shift 操作(<<
,>>
)可以接受 >= N
的 shift 值,并且 shift 值将在执行 shift 之前被掩码为模 N
。例如,u32 >> 65
等同于 u32 >> 1
(65 % 32 = 1
)。此行为在 Rust RFC 560 - Integer Overflow 中定义:
Shift 被指定为屏蔽掉过长 Shift 的位。
在 debug 模式下编译时,此类 Shift 会触发溢出 panic。但是,在 release 模式下,如前所述,shift 值将被屏蔽为模 N
。这与 Go 和 Solidity 不同,在 Go 和 Solidity 中,结果将被截断为零。
在审计 Stylus 加密库期间,我们发现了一个与此 shift 溢出行为相关的 问题。shr_assign
和 shl_assign
函数实现了 Uint<N>
类型的 shift-and-assign 运算符(<<=
和 >>=
),该类型表示具有 N
个 limb 数组的多 limb 无符号整数,每个 limb 都表示为 u64
类型。这些函数迭代所有 limb 以处理位 shift。
以下是 >>=
操作的简化版本:
图片 J
由于每个 limb 都可以跨 shift 后的边界分成两部分,因此该代码使用两个单独的逻辑路径。第一部分(index1
)处理最低有效位,这些位可能以较低下 limb(索引为 index1
)的最高有效位显示。第二部分(index2
)处理最高有效位,这些位可能以较低下 limb(索引为 index1+1
)的最低有效位显示。条件 index_shift < index
和 index_shift <= index
可确保 shift 后的位保留在有效的位范围内。
下图说明了对 uint256
整数(表示为 [u64; 4]
)进行 76 位右移的情况。对于索引 1 处的 limb,最低有效 12 位(index1
)超出范围,而最高有效 52 位(index2
)成为索引 0 处新 limb 的最低有效 52 位。
但是,shift 溢出问题可能会发生在 index1
逻辑中。具体来说,当 limb_shift
为零时,current_limb << (64 - limb_shift)
将变为 current_limb << 64
。由于 current_limb
是一个 u64
值,因此 shift 量将回绕为零,从而使 current_limb
保持不变并错误地设置 limbs[index1]
。预期的行为是 current_limb
在 shift 后应为零,从而使 limbs[index1]
保持不变。
当使用 Stylus 合约库的代码在 release 模式下编译,并将 Uint<N>
变量 shift 64 * x
位时,此问题可能会导致不正确的算术结果。例如:
let num: Uint<4> = Uint::::new(limbs: [\
0xffffffffffffffff,\
0xffffffffffffffff,\
0,\
0xffffffffffffffff,\
]);
assert_eq!(
num >> 64, // 内部使用 >>=
Uint::<4>::new([0xffffffffffffffff, 0, 0xffffffffffffffff, 0])
);
该测试失败,表明索引 1 处的 limb 被错误地设置为左侧索引 2 处的旧 limb 的值(0xFF..FF
),而右侧是预期的行为:
要解决此问题,可以使用 checked_shl
和 checked_shr
使用 来缓解可能的溢出。
重要的是要强调,此内容背后的意图不是批评或指责受影响的项目,而是提供客观的概述,作为社区学习的教育材料,并在未来更好地保护项目。
- 原文链接: blog.openzeppelin.com/no...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!