2025年11月30日,Yearn Finance的yETH Weighted Stable Pool遭到攻击,损失超过900万美元[1]。根本原因是invariant求解器_calc_supply()中的不安全算术运算以及一个未被禁用的引导(bootstrap)路径,该路径允许重新进入初始化逻辑。官方事后分析报告[2]列出了五个根本原因;我们将其重新归类为两个缺陷(上述漏洞)以及两个架构前提条件,这些前提条件只有在存在这些缺陷的情况下才变得可被利用。其他现有分析主要关注攻击交易的逐步细节。在高层次总结与交易层面细节之间,仍存在一个空白:攻击究竟为何以及如何生效?本文旨在填补这一空白,使用Foundry和Python模拟来逐步追踪关键数值的演变以及计算在何处崩溃。
本分析主要作出以下三项贡献:
- 按漏洞划分的损失分解。 这两个漏洞并非相互依赖:仅不安全算术运算就造成了约810万美元的损失(占总损失的90%),而引导路径则额外促成了约90万美元的损失。这明确了哪个漏洞是主要因素。
- 根本原因的重新分类。 官方报告中的五个根本原因,更应理解为两个实现缺陷(将其中三项合并)加上两个架构前提条件,这些前提条件只有与缺陷相结合才变得可被利用。
- 纠正技术上的误解。 "第二次迭代中的下溢将乘积项归零"这一说法并不成立:我们的模拟表明,乘积是通过除法中的舍入归零的,而非下溢,而产生利润的下溢发生在完全不同的阶段。
本文其余部分结构如下。第0x1节介绍yETH weighted stable pool及其invariant求解器的背景知识。第0x2节分析两个根本原因及其失效模式。第0x3节详细追踪三阶段攻击过程。第0x4节结合模拟证据纠正两个常见误解。第0x5节给出结论和建议。
太长不看版
根本原因: 攻击利用了两个漏洞,但影响程度不对称:
_calc_supply()中的不安全算术运算(主要原因,约810万美元)。该函数根据池状态重新计算yETH供应量,其中存在两处算术失效:unsafe_div()中的向下舍入可能使内部乘积项归零,而unsafe_sub()中的下溢可能使某个中间值回绕(wrap)为一个巨大的正整数。仅此漏洞就足以抽干yETH weighted stableswap pool。- 未被禁用的引导(bootstrap)路径(次要原因,约90万美元)。
prev_supply == 0初始化分支在部署后从未被永久性地关闭。在第一个漏洞将供应量抽干至零之后,该路径变得可重新到达,从而使攻击者能够从yETH/WETH Curve池中获取额外利润。
在不安全算术运算漏洞中,第2阶段仅使用了向下舍入失效(失效模式A);下溢失效(失效模式B)与引导路径相互依赖,二者共同促成了第3阶段。
攻击者执行了一个三阶段的攻击序列:
- 准备阶段: 通过反复的存入/取出循环扭曲池的资产分布,在虚拟余额中造成极端不平衡。
- 供应量操纵: 利用
_calc_supply()中的向下舍入使乘积项归零,然后通过一系列铸造/销毁操作将总供应量抽干至零。此后池中所有的LST资产均被取出并兑换为WETH,导致约810万美元的损失。 - 利润提取: 通过粉尘级存款触发引导路径(
prev_supply == 0),利用_calc_supply()中的下溢铸造约2.35×10⁵⁶个yETH,并用其抽干yETH/WETH Curve池,导致约90万美元的损失。
纠正的两个常见误解:
- "invariant崩溃是因为
pow_up()和pow_down()的舍入方式不同。" 我们在Foundry模拟中用pow_down()替换pow_up()进行了验证:攻击依然生效。舍入方式不一致并非根本原因。 - "第二次迭代中的下溢导致某个中间项归零。" 我们的Foundry和Python模拟表明,第二次迭代中并未发生下溢。实际数值约为1.91e19(而非所称的约1.94e18),这是一次正确减法运算的合法结果。使乘积归零的原因是随后除法运算中的向下舍入,而非下溢。
0x1 背景
在此事件中,共有两个池损失了资产:yETH weighted stableswap pool(一个持有LST的Yearn池,损失约810万美元)以及yETH/WETH Curve池(一个Curve stableswap池,损失约90万美元)。核心漏洞位于yETH weighted stableswap pool中。本节提供理解该漏洞及利用过程所需的背景知识。
0x1.1 虚拟余额与不变量
yETH协议是一个用于以太坊流动性质押代币(LST)的自动做市商(AMM)[3]。受影响的yETH weighted stableswap pool将多种LST聚合到单个池中:用户存入LST并获得yETH作为池份额代币。
由于每种LST代表随时间产生收益的质押ETH,其相对于基础ETH的兑换率会不断变化。为统一记账方式,该池为每种资产定义了一个虚拟余额 :即链上余额乘以兑换率。这将所有资产统一到beacon链ETH单位下。所有虚拟余额之和记为 。
该池包含8种资产(索引0-7),每种资产都有一个指定的权重 :
该池的状态由一个weighted StableSwap风格的不变量所约束[4]:
其中:
- 是不变量规模(invariant scale),它直接等于该池的yETH总供应量。当池处于完全平衡状态时,。
- 是加权乘积项,定义为 ,其中 是资产 i 的权重,。
- 是放大系数(amplification factor),一个单一的协议参数(并非 )。 表示该系数的 次幂,其中 为资产数量(本池中为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]),n 是资产数量。该形式在代数上等价于第0x1.1节中的定义,只是将 分配到乘积项中。
add_liquidity()有两条路径(代码见第0x2.2节):
- 引导路径(Bootstrap path)(当
prev_supply == 0时):使用公式(4)从头计算vb_prod。该路径在部署后仍可访问,这正是第0x2.2节中讨论的状态管理漏洞。 - 正常路径(Normal path)(当
prev_supply > 0时):计算过程分为两个步骤:-
a) 基于新旧虚拟余额的比例进行增量更新:
其中 和 分别是存款前和存款后的虚拟余额。
-
b) 通过将该估计值作为输入调用
_calc_supply(),迭代校准得出精确值,重新计算不变量 以及 的精确值。
-
-
update_rates()在兑换率发生变化时被触发,导致相应资产的虚拟余额被更新。其后续计算流程遵循add_liquidity()的正常路径,即迭代地重新计算不变量。此外,基于新计算出的供应量,合约会铸造或销毁yETH,以确保流动性供应量与更新后的虚拟余额状态保持一致。 -
remove_liquidity()在按比例减少每个虚拟余额后,总是使用公式(4)从头计算vb_prod。
0x2 根本原因分析
攻击者利用了两个漏洞,二者所起的作用和造成的影响不同。主要根本原因是不变量求解器_calc_supply()中的一个计算缺陷,该缺陷有两种失效模式:(A)向下舍入可能使乘积项归零,使不变量退化为恒和模型,导致LP代币被过量铸造(供应量膨胀);以及(B)下溢条件也可能导致供应量膨胀。第2阶段仅使用了失效模式A(约810万美元)。失效模式B则依赖于次要漏洞。
次要根本原因是一个状态管理缺陷:池的初始化分支仍可被重新访问。在第2阶段将供应量驱动至零之后,失效模式B与引导路径相结合,额外促成了约90万美元的损失(第3阶段)。
0x2.1 _calc_supply()中的不安全算术运算(主要原因)
下图将_calc_supply()的实现与第0x1.2节中的数学过程进行了映射,并标注了下文分析的两个算术失效位置:
代码变量与数学术语的对应关系如下:
| 代码变量 | 数学作用 |
|---|---|
s |
当前供应量估计值 |
r |
乘积项 |
sp |
下一个供应量估计值 |
l |
分子常量: |
d |
分母常量: |
关键表达式如下:
sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d) # Step 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # Step 2: π update (per asset)
该函数内部存在两种算术失效模式,分别针对不同的代码行,产生不同的效果。二者都需要池处于极端状态才能被触发。
在正常条件下,迭代过程表现正常: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算术中执行减法运算的,一个负值结果会回绕(wrap)为一个巨大的正整数(接近 )。该回绕后的值会在除法运算及后续迭代中传播,产生一个荒谬地巨大的供应量估计值,而协议随后会将其铸造为真实的yETH代币。
一个常见的说法认为,这种下溢发生在某个特定供应量操纵步骤的第二次迭代中。第0x4节将证明该说法是不正确的:实际导致供应量膨胀的下溢发生在一个完全不同的上下文中(攻击的第3阶段)。
3. 这些失效如何促成攻击
这两种失效模式在攻击的不同阶段发挥作用,对利润的贡献也不同:
-
失效模式A(第2阶段,约810万美元):当攻击者对一个严重不平衡的池进行存款时,乘积项归零,导致
_calc_supply()返回一个膨胀的供应量。协议向攻击者过量铸造yETH。仅凭这一失效模式,无需引导路径的任何参与,攻击者就能抽干yETH weighted stableswap pool的LST资产。 -
失效模式B(第3阶段,约90万美元):在供应量被抽干至零之后,引导路径根据粉尘级存款重新计算出一个巨大的乘积项,导致减法运算发生下溢。协议铸造出天文数字级别的yETH,攻击者用其抽干独立的yETH/WETH Curve池。
这种依赖关系是单向的:失效模式A可独立被利用,并造成了90%的损失,而失效模式B则需要失效模式A先将供应量驱动至零。
0x2.2 未被禁用的引导(Bootstrap)路径(次要原因)
add_liquidity()函数包含一个用于处理池初始存款的分支:
该逻辑可抽象如下:
if prev_supply == 0:
# Bootstrap path — compute vb_prod and vb_sum from scratch
vb_prod, vb_sum = _calc_vb_prod_sum(balances, rates, weights, ...)
supply = vb_sum
else:
# Normal path — use stored vb_prod, perform incremental checks
...
# Called after both branches, with prev_supply == 0 as a flag
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,使用第0x1.3节中的公式(4)。该引导分支原本设计仅供池初始化时一次性使用,但在首次存款后从未被永久性地限制访问。
如果总供应量能够被(通过任意组合的销毁和取款操作)驱动至零,该分支就会再次变得可到达。重新进入该路径的攻击者能够控制传递给_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()通过第0x1.3节中的公式(5)增量更新来估算新的乘积项。由于对于高权重资产而言 ,所有比例 都是远小于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。此操作不会取出任何代币,但该函数会根据当前池状态,使用第0x1.3节中的公式(4)重新计算vb_prod。由于虚拟余额均为非零值,这会产生一个非零乘积(~9.09e19),覆盖之前被破坏的零值。
4. 通过update_rates()修正供应量
攻击者对资产索引6(WOETH)或7(mETH)调用update_rates()。如果自上次更新以来兑换率已发生变化,该函数会以已恢复的(非零)乘积触发_calc_supply()。这一次,迭代能够正确收敛,并产生一个远低于当前膨胀值的供应量。差额将从yETH质押合约中被销毁。根据官方事后分析报告[2],这构成了协议自有流动性(Protocol-Owned Liquidity, 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中,攻击者存入LST并获得过量铸造的yETH(由于乘积被破坏)。在步骤4中,当供应量被修正时,多余的yETH是从POL(质押合约)中销毁的,而非从攻击者账户中扣除。在步骤5中,攻击者按其yETH持有量的比例取出LST。由于POL吸收了销毁部分,而攻击者的yETH余额保持不变,攻击者最终取出的LST数量超过了其存入的数量。这一差额在三个循环中累积提取,总计约810万美元。
Rebase的作用
交易追踪(在第一和第二循环之间)还显示了对OETHVaultProxy.rebase()的调用,该操作触发了OETH的rebase:WOETH合约持有的OETH余额增加,从而提高了WOETH的有效兑换率。这个"被保留"的汇率差异正是使第二循环的步骤4能够再次成功的原因:当update_rates()最终被调用时,它检测到差异并触发_calc_supply()。
抽干至零
在重复该五步循环三次后,攻击者已将池的总供应量降低至低于其所持yETH的数量。攻击者以剩余的供应量最后一次调用remove_liquidity(),将其抽干至零。
此时池的供应量、乘积以及vb_sum均为零。这种退化状态违反了设计上的隐含假设,即一个曾有过存款记录的池永远不会回到其未初始化的状态。
0x3.3 第3阶段: 利用零供应量获取额外利润(约90万美元)
目标: 从退化的池状态中铸造出海量yETH,然后将其兑换为真实资产。此阶段利用的是次要漏洞(未被禁用的引导路径)与失效模式B(下溢)相互依赖的组合,二者共同贡献了约10%的总损失。
1. 通过下溢进行铸造
总供应量为零时,攻击者以粉尘级金额(余额为[1, 1, 1, 1, 1, 1, 1, 9])调用add_liquidity()。
由于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阶段已将总供应量驱动至零;在任何非退化的池状态下,l > s * r都成立,减法运算是安全的。
2. 兑换为真实资产
攻击者将部分过量铸造的yETH在yETH-WETH Curve池中兑换为约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 ← initial supply
r = 3538247433646816 ← initial product (very small)
d = 4490000000000000000000
sp = (l - s*r) / d ≈ 1.093e22 ← new supply jumps ~4x
new r ≈ 4.49e22 ← product inflates dramatically
======= _calc_supply iteration 1 =======
s = 10926206313726454855296 ← from previous sp
r = 44892226765713223838396 ← from previous inner loop
sp = 19113493328251743069 ← ≈ 1.91e19, legitimately small
new r = 0 ← rounds to zero!
关键观察在于:在迭代1中,sp的计算结果约为1.91e19。这是一个合法的、较小的正值,并非下溢产生的假象。减法l - s*r产生一个较小的正值结果,是因为在此次迭代中,放大系数加权和l与供应量乘积项s*r的数量级相近。
真正使乘积归零的原因,是随后发生的事情:内循环计算r = r * sp / s,其中sp(~1.91e19)远小于s(~1.09e22)。分子r * sp低于分母s,整数除法将结果向下舍为零。
我们在Python中独立验证了这一点,使用任意精度整数计算相同的数值,确认减法运算并未发生下溢:
乘积是通过除法中的舍入归零的,而非通过减法中的下溢。使供应量膨胀的unsafe_sub下溢发生在一个完全不同的上下文中:攻击的第3阶段,即当粉尘级流动性被添加到一个已被抽干至零供应量的池中时。
0x5 结论
yETH攻击事件涉及两个影响程度不对称的漏洞。不安全算术运算是_calc_supply()中的主要根本原因:其向下舍入失效(失效模式A)仅凭第2阶段就独立造成了约810万美元的损失。未被禁用的引导路径是次要漏洞;结合下溢失效(失效模式B),它在第3阶段额外促成了约90万美元的损失,但这只有在第2阶段已先将供应量抽干至零之后才可能发生。这种损失分解方式使本分析与其他已发布报告有所区别,后者并未区分第2阶段与第3阶段各自的获利情况。
官方事后分析报告[2]列出了五个根本原因。我们将其重新归类为两个缺陷(不安全算术运算,合并了官方所列的#1和#5;未被禁用的引导路径为#4)以及两个架构前提条件(#2 不对称的Π处理方式;#3 POL机制促成的零供应量状态)。二者的区别在于:缺陷是违背设计意图的实现层面错误(求解器本不应产生零乘积或发生下溢),而前提条件则是按预期正常运作的设计选择,但当与缺陷相结合时,便构成了可被利用的攻击面。
建议
- 在不变量求解器中使用经检查的算术运算。 使用
safe_div和safe_sub,在发生下溢/上溢时显式回退(revert),即使这会付出一定的gas效率代价。该求解器最多运行256次迭代,相较于安全风险而言,gas开销可忽略不计。 - 对中间值进行边界检查。 验证乘积项在各次迭代之间是否保持在合理范围内。乘积降至零或供应量估计值在迭代间发生数量级跃升,都是退化状态的信号。
- 不平衡限制。 对任何资产的虚拟余额与其按目标权重比例计算出的余额之间的最大偏差加以强制限制。这将能够阻止第1阶段创造出相应的前提条件。
- 不变量单调性检查。 在
_calc_supply()返回结果后,验证新的供应量是否与变化方向相符(添加流动性绝不应导致供应量减少,汇率更新也不应产生10倍级别的变化等)。 - 永久禁用初始化路径。 在池完成首次存款后,对
prev_supply == 0的引导分支进行门控,使其不能被重新进入。这将能够彻底阻止第3阶段的发生。 - 防止零供应量状态。 确保协议层面的销毁操作(来自POL或质押合约)在池仍持有非零余额的情况下,不能将总供应量降至零。设置最低供应量下限可阻止系统转入使引导路径可被重新进入的退化状态。
- 实时异常检测。 监控异常的状态转变(例如乘积项降至零、供应量出现数量级变化,或短时间内出现重复的存入/取出循环),并在损失累积扩大之前触发警报或熔断机制。
参考文献
- Yearn Finance事件公告
- Yearn Security事后分析报告
- yETH文档
- yETH白皮书:不变量推导
- Phalcon Explorer上的攻击交易
- BlockSec: Balancer boosted pool事件分析(2023年8月)
关于BlockSec
BlockSec是一家提供全栈区块链安全与加密合规服务的服务商。我们打造的产品与服务,能够帮助客户在协议和平台的整个生命周期中执行代码审计(涵盖智能合约、区块链及钱包)、实时拦截攻击、分析事件、追踪非法资金,并履行反洗钱/反恐融资(AML/CFT)义务。
BlockSec已在多个知名会议上发表了区块链安全相关论文,报告了多起DeFi应用的零日攻击,阻止了多次黑客攻击,挽回超过2000万美元的损失,并保障了数十亿美元加密资产的安全。
-
官方网站: https://blocksec.com/
-
官方Twitter账号: https://twitter.com/BlockSecTeam



