2025年11月30日,Yearn Finance 的 yETH 加权稳定池被利用,损失超过 900 万美元[1]。根本原因是不安全算术在不变式求解器 _calc_supply() 中以及未禁用的引导路径,后者允许重新进入初始化逻辑。官方事后分析[2]列出了五项根本原因;我们将其重新归类为两个缺陷(上述漏洞)和两个架构先决条件,这些先决条件只有在存在这些缺陷时才会被利用。其他可用分析侧重于攻击交易的详细步骤。在高层概述和交易细节之间,仍然存在一个差距:攻击是如何以及为何实际奏效的?本文通过使用 Foundry 和 Python 模拟来追踪关键值如何逐步演变以及计算何时出错,从而填补了这一差距。
本分析主要做出以下三点贡献:
- 按漏洞划分的损失明细。 这两个漏洞并非相互依赖:仅不安全算术就造成了约 810 万美元的损失(占总数的 90%),而引导路径则额外造成了约 90 万美元的损失。这阐明了哪个漏洞是主要的。
- 根本原因的重新分类。 官方报告的五项根本原因可以更好地理解为两个实现缺陷(整合了五项中的三项)加上两个架构先决条件,这两个先决条件仅在与缺陷结合时才可被利用。
- 纠正技术误解。 “第二次迭代中的下溢使乘积项归零”的说法不成立:我们的模拟显示乘积项通过除法的舍入归零,而不是下溢,而产生利润的下溢发生在完全不同的阶段。
本文其余部分的组织结构如下。0x1 节提供了 yETH 加权稳定池及其不变式求解器的背景。0x2 节分析了两个根本原因及其故障模式。0x3 节详细追踪了三阶段攻击。0x4 节通过模拟证据纠正了两个常见的误解。0x5 节以建议结束。
摘要
根本原因: 两个漏洞被利用,但影响不对称:
_calc_supply()中的不安全算术(主要,约 810 万美元)。该函数从池状态重新计算 yETH 供应,包含两个算术故障:unsafe_div()中的向下舍入可能导致内部乘积项归零,而unsafe_sub()中的下溢可能将中间值包装成一个巨大的正整数。仅此漏洞就足以耗尽 yETH 加权稳定兑换池。- 未禁用的引导路径(次要,约 90 万美元)。
prev_supply == 0的初始化分支在部署后从未被永久禁用。在第一个漏洞将供应耗尽至零后,该路径变得可达,从而能够从 yETH/WETH Curve 池中获得额外利润。
在不安全算术漏洞中,只有向下舍入故障(故障模式 A)在第 2 阶段使用(约 810 万美元);下溢故障(故障模式 B)与引导路径相互依赖,两者结合起来实现了第 3 阶段。
攻击者执行了三阶段序列:
- 准备: 通过重复的添加/移除周期扭曲资产分配,造成虚拟余额的极端不平衡。
- 供应操纵: 利用
_calc_supply()中的向下舍入将乘积项折叠为零,然后通过一系列的增发/销毁操作将总供应耗尽至零。池中的所有 LSTs 均被提取并随后兑换为 WETH,导致约 810 万美元的损失。 - 利润提取: 通过尘埃存款触发引导路径(
prev_supply == 0),利用_calc_supply()中的下溢增发约 2.35×10⁵⁶ 个 yETH,用于耗尽 yETH/WETH Curve 池,导致约 90 万美元的损失。
纠正的两个常见误解:
- “不变式之所以失效是因为
pow_up()和pow_down()的舍入方式不同。” 我们通过在 Foundry 模拟中将pow_up()替换为pow_down()来验证:该攻击仍然有效。舍入不匹配不是根本原因。 - “第二次迭代中的下溢使中间项归零。” 我们的 Foundry 和 Python 模拟显示第二次迭代中没有发生下溢。实际值约为 1.91e19(而不是声称的约 1.94e18),这是正确减法的一个合法结果。使乘积项归零的是随后的除法向下舍入,而不是下溢。
0x1 背景
在此事件中,两个池子损失了资产:yETH 加权稳定兑换池(Yearn 池,持有 LSTs,约损失 810 万美元)和 yETH/WETH Curve 池(Curve 稳定兑换池,约损失 90 万美元)。yETH 加权稳定兑换池是核心漏洞所在。本节提供理解漏洞和利用所需的背景信息。
0x1.1 虚拟余额与不变式
yETH 协议是针对以太坊流动性质押代币(LSTs)的自动化做市商(AMM)[3]。受影响的yETH 加权稳定兑换池将多个 LSTs 聚合到一个池中:用户存入 LSTs 并收到 yETH 作为池份额代币。
由于每种 LST 代表随着时间推移会产生奖励的质押 ETH,其相对于基础 ETH 的汇率会发生变化。为了统一记账,该池为每种资产定义了一个虚拟余额 :链上余额 × 汇率。这将所有资产归一化为信标链 ETH 单位。所有虚拟余额的总和表示为 。
该池包含 8 种资产(索引 0-7),每种资产都有指定的权重 :
池的状态由加权稳定兑换不变式决定 [4]:
其中:
- 是不变式尺度,它直接等于池的总 yETH 供应量。当池完美平衡时,。
- 是加权乘积项,定义为 ,其中 是资产 i 的权重,。
- 是放大因子,一个单一协议参数(不是 )。 表示该因子乘以 的幂,其中 是资产数量(在本池中为 8)。它控制着恒定和(接近均衡)到恒定乘积(在极端)之间的曲线形状。
关键属性: 没有封闭形式的解。它必须通过数值求解。该求解器 _calc_supply() 是算术漏洞所在的代码。
0x1.2 不变式求解器
该协议通过最多 256 轮的定点迭代来重新计算 。该算法实现在代码中为 _calc_supply()(在 0x2.1 节中详述)。每轮执行三个步骤:
步骤 1:更新供应估计。
步骤 2:更新乘积项以匹配新供应。
步骤 3:检查收敛性。
如果 ,则返回 ;否则从步骤 1 重复。
初始值 、 和 会影响早期迭代;虽然理论上与最终收敛无关,但由于有限的迭代和固定精度算术,它们在实践中会影响结果。
实现使用固定精度整数运算:除法向下舍入,减法不防范下溢。在正常池条件下,中间值保持在安全范围内。在极端池状态下,则不然。0x2.1 节详细分析了这些故障模式。
0x1.3 三个接口和不变式求解器
该协议公开三个接口,这些接口通过更新加权乘积项 (在代码中存储为 vb_prod)来影响池状态:
| 接口 | 功能 | 触发 _calc_supply()? |
|---|---|---|
add_liquidity() |
以任意比例存入资产 | 是 |
update_rates() |
更新外部汇率 | 是 |
remove_liquidity() |
按比例提取资产 | 否(使用比例缩放) |
这种不对称性很重要:add_liquidity() 允许任意比例的存款(它可以极大地扭曲池),而 remove_liquidity() 总是按比例提取。因此,重复的添加/移除循环可以将池子推向越来越不平衡的状态。
更新汇率的机制
如上所述,虚拟余额 () 是基于 LST 的汇率计算的。因此,理解更新汇率的方式很重要。
具体来说,add_liquidity() 和 update_rates() 函数可以通过内部函数 _update_rates() 来更新汇率,而 remove_liquidity() 函数不执行汇率同步。
add_liquidity()在执行关键操作之前调用_update_rates(),以确保资产汇率与最新状态同步。update_rates()允许手动更新汇率。
_update_rates() 函数检查合同中记录的汇率是否与外部汇率一致。如果检测到差异,它将触发对虚拟余额的重新计算,并随后更新不变式;否则,将跳过更新过程。
每个接口如何处理 π
基于它们如何影响不变式,这三个函数可分为两类。具体来说,add_liquidity() 和 update_rates() 允许虚拟余额发生非比例变化,因此需要迭代重新计算供应 和乘积 。相反,remove_liquidity() 按比例提取流动性,不需要迭代计算。
从头计算乘积的基本公式是:
其中 是供应量, 是资产 的权重, 是其虚拟余额(在代码中存储为 vb[i]), 是资产数量。此形式在代数上等同于 0x1.1 节中的定义,其中 分配到乘积中。
-
add_liquidity()有两个路径(代码在 0x2.2 节中显示):- 引导路径(当
prev_supply == 0时):使用公式 (4) 从头开始计算vb_prod。此路径在部署后仍然可访问是 0x2.2 节中讨论的状态管理漏洞。 - 正常路径(当
prev_supply > 0时):计算过程分为两个步骤:- a)使用基于旧虚拟余额与新虚拟余额比率的增量更新:
其中 和 是存款前后的虚拟余额。
- b)通过将此估计值作为输入调用
_calc_supply()来迭代校准精确值,重新计算不变式 和 的精确值。
- a)使用基于旧虚拟余额与新虚拟余额比率的增量更新:
- 引导路径(当
-
update_rates()在汇率改变时触发,导致相应资产的虚拟余额更新。其后续计算流程遵循add_liquidity()的正常路径,即迭代重新计算不变式。此外,基于新计算的供应量,合同会增发或销毁 yETH,以确保流动性供应与更新的虚拟余额状态保持一致。 -
remove_liquidity()在按比例减少每个虚拟余额后,始终使用公式 (4) 从头计算vb_prod。
0x2 根本原因分析
两个漏洞被利用,具有不同的作用和影响。主要根本原因是不变式求解器 _calc_supply() 中的计算缺陷,它有两个故障模式:(A)向下舍入可能使乘积项归零,将不变式退化为恒定和模型并导致过量 LP 增发(供应膨胀);(B)下溢情况也可能导致供应膨胀。只有故障模式 A 在第 2 阶段被使用(约 810 万美元)。故障模式 B 与次要漏洞相互依赖。
次要根本原因是状态管理缺陷:池的初始化分支仍然可达。在第 2 阶段将供应量耗尽至零后,故障模式 B 与引导路径结合,额外造成了约 90 万美元的损失(第 3 阶段)。
0x2.1 _calc_supply() 中的不安全算术(主要)
图 2 将 _calc_supply() 实现与 0x1.2 节的数学过程映射,并标注了下面分析的两个算术故障点:
代码变量映射到数学术语如下:
| 代码变量 | 数学角色 |
|---|---|
s |
当前供应估计 |
r |
乘积项 |
sp |
下一个供应估计 |
l |
分子常量: |
d |
分母常量: |
关键表达式是:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # 步骤 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # 步骤 2: π 更新(按资产)
存在两个算术故障模式,目标不同,产生不同的效果。两者都需要池处于极端状态才能触发。
在正常条件下,迭代行为正确:l - s * r 是一个适度的正值,迭代在几轮内收敛。
1. 故障模式 A:向下舍入使乘积归零
在步骤 2 中,乘积按资产进行更新:
r = unsafe_div(unsafe_mul(r, sp), s) # r = r * sp / s
由于 unsafe_div() 执行整数除法,它总是向下舍入。当池极度不平衡且 sp 远小于 s(在操纵的大笔存款后发生)时,分子 r * sp 可能小于分母 s。整数除法然后产生**r = 0**。
一旦 r 为零,它在所有后续迭代中都保持为零。乘积项 已永久崩溃。
一个常见的错误归因声称此故障源于 pow_up() 和 pow_down() 之间的舍入不匹配。0x4 节提供了证据表明这是不正确的。
2. 故障模式 B:下溢导致供应膨胀
在步骤 1 中,新的供应估计计算如下:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # sp = (l - s*r) / d
l - s*r 的减法在公式 2 中。在正常条件下,这是正的。然而,当池达到零供应的退化状态时,add_liquidity() 中的初始化分支(在 0x2.2 节中详述)会从头重新计算乘积项,并且相对幅度会反转。
具体来说,当对一个零供应的池用微量金额调用 add_liquidity() 时,初始化分支会调用 _calc_vb_prod_sum() 来使用公式 (4)(0x1.3 节)计算新值。使用微量存款,vb_sum 非常小(例如 16),但除以接近零的余额并取高次幂会使乘积不成比例地放大(例如约 9.13e20)。当 s * r 超过 l 时,减法产生一个负的数学结果。
由于 unsafe_sub() 在未检查的 uint256 算术中执行减法,负结果会包装成一个巨大的正整数(接近 )。这个包装后的值通过除法和后续迭代传播,产生一个荒谬的巨大供应估计,然后协议将其作为实际的 yETH 代币增发。
一个常见的说法声称这种下溢发生在特定供应操纵步骤的第二次迭代中。0x4 节表明这种说法是不正确的:导致供应膨胀的实际下溢发生在完全不同的上下文中(攻击的第 3 阶段)。
3. 这些故障如何实现攻击
这两种故障模式在利用的不同阶段运作,具有不同的利润贡献:
- 故障模式 A(第 2 阶段,约 810 万美元):当攻击者存入一个极度不平衡的池时,乘积项归零,导致
_calc_supply()返回膨胀的供应量。协议向攻击者增发了过多的 yETH。仅此故障模式,无需引导路径参与,就足以让攻击者耗尽 yETH 加权稳定兑换池中的 LST 资产。 - 故障模式 B(第 3 阶段,约 90 万美元):在供应量耗尽为零后,引导路径通过尘埃存款重新计算了一个大的乘积项,导致减法发生下溢。协议增发了天文数字的 yETH,攻击者利用这些 yETH 耗尽了单独的 yETH/WETH Curve 池。
依赖关系是单向的:故障模式 A 是独立可利用的,并造成了 90% 的损失,而故障模式 B 需要故障模式 A 首先将供应量耗尽至零。
0x2.2 未禁用的引导路径(次要)
add_liquidity() 函数包含池初始存款的分支:
该逻辑可抽象如下:
if prev_supply == 0:
# 引导路径 — 从头开始计算 vb_prod 和 vb_sum
vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
supply = vb_sum
else:
# 正常路径 — 使用存储的 vb_prod,执行增量检查
...
# 在两个分支之后调用,prev_supply == 0 作为标志
supply, vb_prod = _calc_supply(num_assets, supply, amplification, vb_prod, vb_sum, prev_supply == 0)
当 prev_supply == 0 时,函数绕过存储状态,通过 _calc_vb_prod_sum() 从头重新计算 vb_prod 和 vb_sum,然后将它们传递给 _calc_supply()。这个引导分支原本设计用于池初始化期间的一次性使用,但在首次存款后从未被永久禁用。
如果总供应量可以被耗尽至零(通过任何燃烧和提取的组合),该分支就可以再次被访问。重入此路径的攻击者可以控制传递给 _calc_supply() 的初始条件,可能在正常池操作中绝不会出现的参数下触发上述算术故障。
这是一个已知的漏洞模式。2023 年 8 月,Balancer V2 事件也依赖于将供应量推至零以重置内部汇率,从而使攻击者能够以人为的有利参数重新进入初始化逻辑[6]。已部署的池是否能恢复到其初始状态,以及届时不变式为何,是协议设计者必须明确解决的问题。
0x3 攻击分析
利用过程通过一个协调的攻击交易序列[5]展开,分为三个阶段。每个阶段都建立在前一阶段建立的状态之上。
0x3.1 第 1 阶段:扭曲池(准备)
目标: 跨资产产生极端的虚拟余额不平衡。
下图说明了此阶段的交易跟踪(由于篇幅限制,闪电贷步骤已省略):
攻击者首先通过 Balancer 和 Aave 的闪电贷借入大量 LST 资产,具体为 5,500e18 wstETH、3,100e18 WETH、1,800e18 rETH、2,000e18 ETHx 和 200e18 cbETH。
接下来,攻击者在 yETH/WETH Curve 池中将约 800e18 WETH 兑换成约 416e18 yETH,然后使用获得的 yETH 从池中移除流动性。
核心操纵利用了 0x1 节(背景)中描述的接口不对称性:add_liquidity() 允许任意比例存款,而 remove_liquidity() 则按比例(按池权重)提取资产(上图中红色矩形框高亮显示)。通过重复循环添加 → 移除操作,仅存入选定的资产,同时按比例提取所有资产,攻击者逐渐将池子推向一个极度不平衡的状态:
| 资产 | 权重 | 之前 | 之后 | 变化 |
|---|---|---|---|---|
| 0 (sfrxETH) | 20% | 628,097,482,908,289,585,170 | 684,908,495,923,316,419,717 | +9.04% |
| 1 (wstETH) | 20% | 376,569,216,105,249,117,091 | 684,906,088,027,654,432,883 | +81.88% |
| 2 (ETHx) | 10% | 187,473,530,249,048,974,586 | 410,441,661,092,336,995,160 | +118.93% |
| 3 (cbETH) | 10% | 267,387,722,745,796,900,349 | 3,532,430,695,689,175,233 | -98.68% |
| 4 (rETH) | 10% | 201,828,029,369,446,137,136 | 410,441,659,865,060,509,563 | +103.36% |
| 5 (apxETH) | 25% | 753,792,636,209,697,936,333 | 549,134,446,963,315,842,411 | -27.15% |
| 6 (WOETH) | 2.5% | 49,640,000,870,620,479,267 | 655,788,758,768,556,847 | -98.68% |
| 7 (mETH) | 2.5% | 47,667,894,211,903,277,629 | 629,735,467,970,876,930 | -98.68% |
资产 3 (cbETH)、6 (WOETH) 和 7 (mETH) 已被消耗超过 98%。这种不平衡不直接提取利润。它为下一阶段创造了数字先决条件。
0x3.2 第 2 阶段:将供应量折叠至零(约 810 万美元)
目标: 将不变式乘积推至零,然后将 yETH 供应量耗尽至零。此阶段仅利用主要漏洞(不安全算术),造成了约 90% 的总损失。
此阶段使用一个重复的五步循环,执行三次:
- 通过
add_liquidity()破坏乘积; - 通过
add_liquidity()建立纠正的先决条件; - 通过
remove_liquidity()并带 0 yETH 重置乘积; - 通过
update_rates()纠正供应量; - 通过
remove_liquidity()提取资产。
下图显示了交易跟踪,其中三个五步循环的重复清晰可见:
1. 通过 add_liquidity() 破坏乘积
攻击者存入大量高权重资产(索引 0、1、2、4、5:sfrxETH、wstETH、ETHx、rETH、apxETH),每种资产的存入量约是其当前虚拟余额的三倍。
add_liquidity() 通过公式 (5)(0x1.3 节)中的增量更新来估算新乘积项。由于高权重资产的 ,因此比率 均为远小于 1 的分数,并取高次幂。这会将 从约 42e18 降至约 0.00353e18,一个接近零的估计乘积。
这个微小的乘积进入 _calc_supply()。在迭代中,乘积更新 r = r * sp / s 遇到了 0x2 节(根本原因分析)中描述的向下舍入条件:分子小于分母,整数除法将 r 向下取整至零。该函数返回零乘积和膨胀的供应量(约 vb_sum),导致协议过度增发 yETH。
2. 通过 add_liquidity() 建立纠正的先决条件
攻击者为资产索引 3(cbETH,一个已被消耗的低权重资产)单边添加流动性,存入约是该资产当前池余额的 6.5 倍。这仅收到少量 yETH 代币,但足以重新平衡池,使得下一次迭代不会剧烈振荡。
没有这一步,即使在步骤 3 中将乘积重置为非零,步骤 4 中的迭代仍然会因为极度不平衡产生的剧烈振荡而导致零乘积。我们的 Foundry 模拟证实了这一点:跳过步骤 2 会导致步骤 4 中的纠正失败。
3. 通过 remove_liquidity() 并带 0 yETH 重置乘积
攻击者调用 remove_liquidity(),数量为 0。没有提取代币,但该函数使用公式 (4)(0x1.3 节)根据当前池状态重新计算 vb_prod。由于虚拟余额非零,这将产生一个非零乘积(约 9.09e19),覆盖了被破坏的零值。
4. 通过 update_rates() 纠正供应量
攻击者调用资产索引 6(WOETH)或 7(mETH)的 update_rates()。如果汇率自上次更新以来发生变化,该函数将调用 _calc_supply() 并带回已恢复的(非零)乘积。这次迭代会正确收敛,并产生一个远低于当前膨胀供应量的供应值。差额从 yETH 质押合同中销毁。根据官方事后分析[2],这构成了协议所有流动性(POL),这意味着销毁减少了协议的头寸,而不是攻击者的持有量。这种不对称性至关重要:每个周期都会减少总供应量,而攻击者的 yETH 余额保持不变。
汇率差异本身并非利润来源;它仅作为触发机制。在三个池接口中,只有 add_liquidity() 和 update_rates() 调用 _calc_supply();remove_liquidity() 使用比例缩放而不调用。在步骤 3 恢复非零乘积后,攻击者需要触发 _calc_supply() 而无需存入额外资产。使用过时的汇率调用 update_rates() 可以精确地做到这一点:汇率变化触发供应量重新计算,而对攻击者无需成本。
这解释了攻击的一个微妙之处:在准备阶段(第 1 阶段),攻击者故意避免为 WOETH 和 mETH 添加流动性。如果在 add_liquidity() 期间更新了这些汇率,则不存在汇率差异,并且此步骤中的 update_rates() 不会触发 _calc_supply()。
5. 通过 remove_liquidity() 提取资产
在每个循环结束时,攻击者通过 remove_liquidity() 提取资产。
如何提取利润
利润机制如下:在步骤 1 中,攻击者存入 LSTs 并收到过量增发的 yETH(由于乘积被破坏)。在步骤 4 中,当供应量被纠正时,多余的 yETH 从 POL(质押合同)中销毁,而不是从攻击者处销毁。在步骤 5 中,攻击者按其 yETH 持有量比例提取 LSTs。由于 POL 吸收了销毁,而攻击者的 yETH 余额保持不变,攻击者最终提取的 LSTs 比他们存入的要多。这个差额,在三个周期中提取,总计约 810 万美元。
再基于的(Rebase)目的
跟踪(在第一个和第二个周期之间)还显示了一个对 OETHVaultProxy.rebase() 的调用,它触发了一次 OETH 再基于:WOETH 合同持有的 OETH 余额增加,提高了 WOETH 的有效汇率。这种“保存”的汇率差异使得第二个周期的 update_rates() 再次成为可能:当最终调用 update_rates() 时,它会检测到差异并触发 _calc_supply()。
耗尽至零
重复此五步循环三次后,攻击者将池的总供应量减少到低于其持有的 yETH 数量。最后一次使用剩余供应量调用 remove_liquidity() 将其耗尽至零。
现在池子持有零供应、零乘积和零 vb_sum。这种退化状态违反了“有先前存款的池永远不会回到其未初始化状态”的隐含设计假设。
0x3.3 第 3 阶段:利用零供应量获取额外利润(约 90 万美元)
目标: 从退化池状态中增发巨量 yETH,然后将其兑换成真实资产。此阶段利用了次要漏洞(未禁用的引导路径)和故障模式 B(下溢)的相互依赖组合,共同贡献了约 10% 的总损失。
1. 通过下溢增发
在总供应量为零的情况下,攻击者调用 add_liquidity() 并传入尘埃金额(余额 [1, 1, 1, 1, 1, 1, 1, 9])。
由于 prev_supply == 0,代码进入 0x2 节(根本原因分析)中描述的引导路径:它绕过存储状态,通过 _calc_vb_prod_sum() 从头重新计算 vb_prod 和 vb_sum,然后将它们传递给 _calc_supply()。这是第二个漏洞的作用:攻击者已将池推回其未初始化状态,从而控制了传递给求解器的初始条件。
由于所有虚拟余额都处于尘埃水平(汇率接近 1e18),计算值如下:
vb_sum= 16vb_prod≈ 9.13e20_supply=vb_sum= 16
在 _calc_supply() 内部,变量初始化如下:
l=_amplification * _vb_sum≈ 4.5e20 × 16 ≈ 7.2e21d=_amplification - PRECISION≈ 4.49e20s=_supply= 16r=_vb_prod≈ 9.13e20
现在进行减法 l - s * r:
这是负数。在未检查的 uint256 算术中,unsafe_sub 将其包装成大约 ,一个天文数字。除以 d(约 4.49e20)后,得到的供应估计约为 2.35e56,协议将全部增发给攻击者。这种下溢只有在第 2 阶段将总供应量耗尽至零才可能发生;在任何非退化池状态下, 成立,减法是安全的。
2. 兑换成真实资产
攻击者在 yETH–WETH Curve 池中将部分过度增发的 yETH 兑换成约 1,097e18 WETH,耗尽了其 WETH 储备。在考虑了第 1 阶段花费的 800e18 WETH 后,净利润约为 90 万美元。
结合第 2 阶段提取的约 810 万美元 LST 资产,攻击者在偿还闪电贷后总共净赚约 900 万美元。
详细的资金流分析,包括资金来源和目的地地址,已包含在其他已发表的分析中(例如 [2]),超出了本文的范围。
0x4 纠正误解
对此事件的大部分已发表分析都侧重于算术症状,但没有完全解释攻击者如何设置先决条件。有两个特定说法值得纠正。
0x4.1 说法:“pow_up() 和 pow_down() 之间的舍入不匹配破坏了不变式”
一种常见的解释将根本原因归结为某些代码路径中使用 pow_up() 而其他路径中使用 pow_down(),并认为方向不匹配引入了可利用的不一致性。
我们直接进行了测试:我们将合同修改为统一使用 pow_down()(替换所有 pow_up() 调用),并在 Foundry 中重新运行了完整的攻击模拟。该攻击成功地相同。乘积仍然折叠到零,供应量仍然被耗尽,下溢仍然产生膨胀的增发。
导致零乘积状态的舍入是迭代循环内的**r = unsafe_div(unsafe_mul(r, sp), s) 中的地板除法**,而不是用于估算初始乘积值的幂函数中的舍入方向。
0x4.2 说法:“第二次迭代中的下溢使中间项归零”
一种广泛引用的解释认为,在 _calc_supply() 的第二次迭代中,unsafe_sub 中的下溢产生了 sp ≈ 1.94e18,然后导致 r 向下舍入为零。
我们使用 Foundry(链上回放)和 Python(数学验证)重现了精确的中间值。Foundry 模拟逐次迭代地追踪 _calc_supply():
======= _calc_supply iteration 0 =======
l = 4905875511098192451202650000000000000000
s = 2514373972590845290489 ← 初始供应
r = 3538247433646816 ← 初始乘积(非常小)
d = 4490000000000000000000
sp = (l - s*r) / d ≈ 1.093e22 ← 新供应跳升约 4 倍
new r ≈ 4.49e22 ← 乘积急剧膨胀
======= _calc_supply iteration 1 =======
s = 10926206313726454855296 ← 来自上一个 sp
r = 44892226765713223838396 ← 来自上一个内循环
sp = 19113493328251743069 ← ≈ 1.91e19,合法地小
new r = 0 ← 向下舍入为零!
关键观察:在迭代 1 中,sp 的计算结果约为 1.91e19。这是一个合法的小正值,而不是下溢的产物。因为在这种迭代中,放大因子加权的求和 l 和供应量-乘积项 s*r 的幅度接近,所以减法 l - s*r 产生一个小的正结果。
接下来的事情使乘积归零:内循环计算 r = r * sp / s,其中 sp(约 1.91e19)远小于 s(约 1.09e22)。分子 r * sp 小于分母 s,整数除法将结果向下取整至零。
我们在 Python 中独立验证了这一点,使用任意精度整数计算相同的值,并确认减法没有发生下溢:
乘积通过除法中的舍入而归零,而不是通过减法中的下溢。在攻击的第 3 阶段,当尘埃流动性被添加到已耗尽至零供应的池子时,会发生 unsafe_sub 下溢,导致供应量膨胀。
0x5 结论
yETH 漏洞涉及两个影响不对称的漏洞。不安全算术是 _calc_supply() 中的主要根本原因:其向下舍入故障(故障模式 A)独立地通过第 2 阶段实现了约 810 万美元的损失。未禁用的引导路径是次要漏洞;与下溢故障(故障模式 B)结合使用,它实现了额外的约 90 万美元损失(第 3 阶段),但仅在第 2 阶段将供应量耗尽为零之后。此损失细分使本次分析区别于其他未区分第 2 阶段和第 3 阶段利润的已发表报告。
官方事后分析[2]确定了五项根本原因。我们将其重新分类为两个缺陷(不安全算术整合了官方的第 1 项和第 5 项;未禁用的引导路径为第 4 项)和两个架构先决条件(第 2 项不对称的 Π 处理;第 3 项 POL 启用的零供应状态)。区别在于:缺陷是违反设计意图的实现错误(求解器不应产生零乘积或下溢),而先决条件是设计选择,当与缺陷结合时,会创建可被利用的攻击面。
建议
- 不变式求解器中的受检查算术。 使用
safe_div和safe_sub,并明确禁止下溢/上溢,即使以牺牲gas 效率为代价。求解器最多运行 256 次迭代,gas 开销与安全风险相比微不足道。 - 对中间值进行边界检查。 验证乘积项在迭代之间是否保持在合理的范围内。乘积降至零或供应量估计在迭代之间呈数量级增长,都表明存在退化状态。
- 不平衡限制。 强制执行任何资产的虚拟余额与其目标权重比例余额之间的最大偏差。这将阻止第 1 阶段创建先决条件。
- 不变式单调性检查。
_calc_supply()返回后,验证新供应量是否与变化方向一致(流动性添加不应减少供应量,汇率更新不应产生 10 倍的变化等)。 - 永久禁用初始化路径。 在池首次存款后,将
prev_supply == 0的引导分支设为永久禁用,使其无法重新进入。这将完全阻止第 3 阶段。 - 防止零供应状态。 确保协议层面的销毁(来自 POL 或质押合同)不会在池持有非零余额的情况下将总供应量减少到零。最低供应地板将阻止向允许引导重入的退化状态过渡。
- 实时异常检测。 监控异常状态转换(例如乘积项降至零、供应量发生数量级变化,或短时间内重复的添加/移除周期),并在损失加剧之前触发警报或熔断器。
参考文献
- Yearn Finance 事件公告
- Yearn 安全事后分析
- yETH 文档
- yETH 白皮书:不变式推导
- BlockSec 浏览器上的攻击交易
- BlockSec:近期 Balancer 事件深度分析(2023 年 8 月)
关于 BlockSec
BlockSec 是一个全栈区块链安全和加密合规提供商。我们构建产品和服务,帮助客户在协议和平台的整个生命周期中执行代码审计(包括智能合约、区块链和钱包)、实时拦截攻击、分析事件、追踪非法资金,并满足 AML/CFT 义务。
BlockSec 在著名的会议上发表了多篇区块链安全论文,报告了多个 DeFi 应用的零日攻击,阻止了多次黑客攻击挽救了超过 2000 万美元,并确保了数十亿美元的加密货币安全。
- 官方网站:https://blocksec.com/
- 官方 Twitter 账号:https://twitter.com/BlockSecTeam
- 🔗 BlockSec 审计服务: 提交请求
- 🔗 Phalcon 安全 APP: 预约演示
- 🔗 Phalcon 合规



