Back to Blog

Yearn Finance事件:不变求解器中不安全的算术运算实至名归

Code Auditing
February 11, 2026
24 min read

2025年11月30日,Yearn Finance 的 yETH 加权稳定币池(Weighted Stable Pool)遭到攻击,损失超过 900 万美元 [1]。其根本原因是 _calc_supply() 不变量求解器中的不安全算术运算,以及一个未禁用的引导路径(bootstrap path),该路径允许重新进入初始化逻辑。官方事后审计报告 [2] 列出了五个根本原因;我们将它们重新分类为两个缺陷(上述漏洞)和两个仅在这些缺陷存在时才可被利用的架构前提。其他现有的分析主要集中在攻击交易的逐步细节上。在高层汇总与交易级细节之间,仍存在一个空白:攻击究竟是如何发生的?本文通过 Foundry 和 Python 模拟来追踪关键值的演变轨迹以及计算中断的环节,旨在填补这一空白。

本分析主要包含以下三项贡献:

  1. 按漏洞拆解损失。 这两个漏洞并非相互依赖:仅不安全算术运算就造成了约 810 万美元的损失(占总额的 90%),而引导路径则额外促成了约 90 万美元的损失。这明确了哪个漏洞是主导因素。
  2. 重新分类根本原因。 官方报告中提到的五个根本原因,更恰当的理解是两个实现缺陷(将五个项目中的三个进行了合并)以及两个仅在与缺陷结合时才可被利用的架构前提。
  3. 纠正技术误解。 关于“第二次迭代中的下溢导致乘积项归零”的说法是不成立的:我们的模拟显示,乘积是因为除法中的舍入而归零,而非下溢;而导致获利的下溢发生在完全不同的阶段。

本文其余部分组织如下:0x1 节提供了 yETH 加权稳定币池及其不变量求解器的背景;0x2 节分析了两个根本原因及其失效模式;0x3 节详细追踪了三阶段攻击过程;0x4 节通过模拟证据纠正了两个常见的误解;0x5 节总结了建议。

TL;DR

根本原因: 攻击利用了两个漏洞,但其影响是不对称的:

  1. _calc_supply() 中的不安全算术运算(主要,约 810 万美元)。该函数根据池状态重新计算 yETH 供应量,其中包含两个算术故障:unsafe_div() 中的向下舍入会导致内部乘积项归零,而 unsafe_sub() 中的下溢会将中间值转换成巨大的正整数。仅此漏洞就足以耗尽 yETH 加权稳定币池。
  2. 未禁用的引导路径(次要,约 90 万美元)。部署后,prev_supply == 0 的初始化分支从未被永久关闭。在第一个漏洞将供应量耗尽至零后,该路径变得可访问,从而从 yETH/WETH Curve 池中获取了额外利润。

在不安全算术漏洞中,只有向下舍入失效(失效模式 A)在第 2 阶段被使用;下溢失效(失效模式 B)与引导路径相互依赖,两者共同促成了第 3 阶段。

攻击者执行了三个阶段的攻击序列:

  1. 准备阶段: 通过重复的添加/移除循环使池的资产分布倾斜,在虚拟余额中造成严重失衡。
  2. 供应操纵: 利用 _calc_supply() 中的向下舍入将乘积项归零,随后通过一系列铸造/销毁操作将总供应量降至零。之后,池中所有的 LST 被取出并兑换为 WETH,导致约 810 万美元的损失。
  3. 利润提取: 通过存入少量余额(dust deposits)触发引导路径(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 加权稳定币池(持有 LST 的 Yearn 池,损失约 810 万美元)和 yETH/WETH Curve 池(Curve 稳定币池,损失约 90 万美元)。yETH 加权稳定币池是核心漏洞所在。本节提供了理解该漏洞和攻击方式所必需的背景知识。

0x1.1 虚拟余额与不变量

yETH 协议是以太坊流动质押代币(LST)的自动化做市商(AMM)[3]。受影响的 yETH 加权稳定币池 将多种 LST 聚合到一个池中:用户存入 LST 并获得 yETH 作为池份额代币。

由于每种 LST 都代表随时间积累收益的质押 ETH,其相对于基础 ETH 的汇率会发生变化。为了统一计算,该池为每个资产定义了一个虚拟余额 xix_i:链上余额 × 汇率。这将所有资产标准化为信标链 ETH 单位。所有虚拟余额之和记为 σ=xi\sigma = \sum x_i

该池包含 8 种资产(索引 0–7),每种资产都有一个指定的权重 wiw_i

索引 资产 索引 资产
0 sfrxETH 4 rETH
1 wstETH 5 apxETH
2 ETHx 6 WOETH
3 cbETH 7 mETH

该池的状态由加权 StableSwap 风格的不变量 [4] 决定:

Afn  σ+D=Afn  D+Dπ(1)\mathit{Af}^{\,n}\;\sigma + D = \mathit{Af}^{\,n}\;D + D \cdot \pi \tag{1}

其中:

  • DD不变量规模,其直接等于该池的总 yETH 供应量。当池完全平衡时,D=σD = \sigma
  • π\pi加权乘积项,定义为 π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i},其中 wiw_i 是资产 ii 的权重,vi=winv_i = w_i \cdot n
  • Af\mathit{Af}放大系数,是一个协议参数。Afn\mathit{Af}^{\,n} 表示该参数的 nn 次方(nn 为资产数量,即 8)。它控制恒定总和(接近平衡时)与恒定乘积(极端状态下)之间的曲线形状。

关键属性:DD 没有闭式解,必须通过数值求解。那个求解器 _calc_supply(),正是算术漏洞的藏身之处。

0x1.2 不变量求解器

协议通过固定点迭代来重新计算 DD,上限为 256 轮。该算法在代码中实现为 _calc_supply()(第 0x2.1 节进行了详细说明)。每一轮执行三个步骤:

步骤 1:更新供应量估计值。

Dm+1=AfnσDmπmAfn1(2)D_{m+1} = \frac{\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m}{\mathit{Af}^{\,n} - 1} \tag{2}

步骤 2:更新乘积项以匹配新的供应量。

πm+1=πm(Dm+1Dm)n(3)\pi_{m+1} = \pi_m \cdot \left(\frac{D_{m+1}}{D_m}\right)^n \tag{3}

步骤 3:检查收敛性。

如果 Dm+1Dm<ϵ|D_{m+1} - D_{m}| < \epsilon,则返回 DmD_{m};否则从步骤 1 重复执行。

初始值 D0D_0π0\pi_0σ\sigma 会影响早期迭代;虽然在理论上与最终收敛无关,但在实践中,由于有限的迭代次数和固定精度算术,它们会影响结果。

实现中使用的是固定精度整数运算:除法向下舍入,且减法不防止下溢。在正常的池条件下,中间值处于安全范围内。但在极端状态下则不然。第 0x2.1 节详细分析了这些失效模式。

0x1.3 三个接口与不变量求解器

协议暴露了三个通过更新加权乘积项 π\pi(代码中存储为 vb_prod)来影响池状态的入口点:

接口 操作内容 是否触发 _calc_supply()
add_liquidity() 以任意比例存入资产
update_rates() 更新外部汇率
remove_liquidity() 按权重比例取出资产 (使用比例缩放)

这种不对称性很重要:add_liquidity() 允许任意比例的存款(它可以极大地倾斜池),而 remove_liquidity() 始终按比例取出。因此,通过重复的添加/移除循环,可以将池推向日益失衡的状态。

更新汇率的机制

如上所述,虚拟余额 (xix_i) 基于 LST 的汇率计算。因此,理解更新汇率的方式非常重要。

具体来说,add_liquidity()update_rates() 函数可以通过内部函数 _update_rates() 更新汇率,而 remove_liquidity() 函数不执行汇率同步。

  • add_liquidity() 在执行关键操作前调用 _update_rates(),以确保资产汇率与最新状态同步。
  • update_rates() 允许手动更新汇率。

_update_rates() 函数会检查合约内记录的汇率是否与外部汇率一致。如果发现差异,它会触发虚拟余额的重新计算并随后更新不变量;否则,跳过更新过程。

每个接口如何处理 π

根据它们对不变量的影响,这三个函数可分为两类。具体来说,add_liquidity()update_rates() 允许虚拟余额发生非比例变化,因此需要对供应量 DD 和乘积 π\pi 进行迭代重算。相反,remove_liquidity() 按比例取出流动性,不需要迭代计算。

从头计算乘积的基本公式为:

π=i(Dwixi)nwi(4)\pi = \prod_{i} \left(\frac{D \cdot w_i}{x_i}\right)^{n \cdot w_i} \tag{4}

其中 DD 是供应量,wiw_i 是资产 ii 的权重,xix_i 是其虚拟余额(代码中存储为 vb[i]),nn 是资产数量。此形式在代数上等同于第 0x1.1 节中的定义,其中 DnD^n 分布在乘积中。

  1. add_liquidity() 有两条路径(代码见第 0x2.2 节):
  • 引导路径(当 prev_supply == 0 时):使用公式 (4) 从头计算 vb_prod。此路径在部署后仍可访问,是第 0x2.2 节讨论的状态管理漏洞。
  • 正常路径(当 prev_supply > 0 时):计算过程分为两步:
    • a) 基于新旧虚拟余额的比率进行增量更新:

      πestimated=πi=0n1(xixi)win(5)\pi_{\text{estimated}} = \pi \cdot \prod_{i=0}^{n-1} \left(\frac{x_i}{x_i'}\right)^{w_i \cdot n} \tag{5}

      其中 xix_ixix_i' 分别是存款前后的虚拟余额。

    • b) 将此估计值作为输入调用 _calc_supply() 来迭代校准精确值,重新计算不变量 DDπ\pi 的精确值。

  1. update_rates() 在汇率变化时触发,导致相应资产的虚拟余额更新。其后续流遵循 add_liquidity() 的正常路径,即不变量被迭代重算。此外,根据新计算的供应量,合约会铸造或销毁 yETH,以确保流动性供应与更新后的虚拟余额状态保持一致。

  2. 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 当前供应量估计值 DmD_m
r 乘积项 πm\pi_m
sp 下一个供应量估计值 Dm+1D_{m+1}
l 分子常数:Afnσ\mathit{Af}^{\,n} \cdot \sigma
d 分母常数:Afn1\mathit{Af}^{\,n} - 1

关键表达式为:

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 为零,它在所有后续迭代中都会保持为零。乘积项 π\pi 已永久崩溃。

一种常见的错误归因声称此失效源于 pow_up()pow_down() 之间的舍入不匹配。第 0x4 节提供的证据表明这是不正确的。

2. 失效模式 B:下溢导致供应膨胀

在步骤 1 中,新的供应量估计计算为:

sp = unsafe_div(unsafe_sub(l, unsafe_mul(s, r)), d)   # sp = (l - s*r) / d

方程 2 中的减法 l - s*rAfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m。在正常条件下,这是正数。然而,当池达到零供应的退化状态时,add_liquidity() 中的初始化分支(详见第 0x2.2 节)会从头重新计算乘积项,相对量级可能会反转。

具体而言,当在零供应池中以少量存款调用 add_liquidity() 时,初始化分支会调用 _calc_vb_prod_sum() 使用公式 (4)(第 0x1.3 节)计算新鲜值。由于存款很小,vb_sum 微不足道(例如 16),但在除以接近零的余额并提高到高次幂后,乘积倍增到一个不成比例的巨大值(例如 ~9.13e20)。当 s * r 超过 l 时,减法会产生负数结果。

由于 unsafe_sub() 使用的是未经检查的 uint256 算术运算,负数结果会环绕为一个巨大的正整数(接近 22562^{256})。该环绕值通过除法和后续迭代传播,产生一个荒谬的巨大供应估计值,协议随后将其铸造为真实的 yETH 代币。

一种观点声称这种下溢发生在特定供应操纵步骤的第二次迭代中。第 0x4 节表明这种说法不正确:导致供应膨胀的实际下溢发生在攻击的第 3 阶段。

3. 这些失效如何促成攻击

这两种失效模式在攻击的不同阶段发挥作用,对利润的影响也不同:

  • 失效模式 A(第 2 阶段,约 810 万美元):当攻击者存入严重失衡的池时,乘积项归零,导致 _calc_supply() 返回膨胀的供应量。协议向攻击者过度铸造 yETH。仅此失效模式,无需引导路径的参与,就使攻击者能够耗尽 yETH 加权稳定币池的 LST 资产。

  • 失效模式 B(第 3 阶段,约 90 万美元):在供应量被耗尽至零后,引导路径从少量存款中重新计算出一个巨大的乘积项,导致减法发生下溢。协议铸造了天文数字般的 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() 使用公式 (4)(第 0x1.3 节)从头计算 vb_prodvb_sum。这个引导分支本意是用于池初始化的单次使用,但在第一次存款后却从未被永久关闭。

如果总供应量能够被降至零(通过销毁和取出的组合),那么该分支将再次变得可访问。能够重新进入此路径的攻击者可以控制传递给 _calc_supply() 的初始条件,从而在常规池操作中绝不会出现的参数下触发上述算术失效。

这是一个已知的漏洞模式。2023 年 8 月,Balancer V2 事件同样依赖于将供应量驱动至零来重置内部汇率,使攻击者能够以极其有利的参数重新进入初始化逻辑。已部署的池是否可以退回到其初始状态,以及当它退回时哪些不变量仍然成立,是协议设计者必须明确回答的问题。


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% 的损失。

此阶段使用一个重复的五步循环,执行了三次:

  1. 通过 add_liquidity() 破坏乘积;
  2. 通过 add_liquidity() 为校正建立前提;
  3. 通过 remove_liquidity()(存入 0 yETH)重置乘积;
  4. 通过 update_rates() 校正供应量;
  5. 通过 remove_liquidity() 取出资产。

下图显示了交易轨迹,其中五步循环的三次重复清晰可见:

1. 通过 add_liquidity() 破坏乘积

攻击者存入大量高权重资产(索引 0、1、2、4、5:sfrxETH、wstETH、ETHx、rETH、apxETH),每种数量约为其当前虚拟余额的三倍。

add_liquidity() 通过公式 (5)(第 0x1.3 节)中的增量更新来估计新的乘积项。由于对于高权重资产 xixix_i' \gg x_i,比值 (xi/xi)(x_i / x_i') 均为小于 1 的分数,且其幂很大。这使得 πnew\pi_{\text{new}} 从约 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)重置乘积

攻击者以 0 数量调用 remove_liquidity()。没有代币被取出,但该函数使用公式 (4)(第 0x1.3 节)从当前池状态重新计算 vb_prod。由于虚拟余额均为非零,这产生了一个非零乘积(约 9.09e19),覆盖了被破坏的零值。

4. 通过 update_rates() 校正供应量

攻击者调用资产索引 6 (WOETH) 或 7 (mETH) 的 update_rates()。如果汇率自上次更新以来已发生变化,函数便会触发带有已恢复(非零)乘积的 _calc_supply()。此次,迭代正确收敛,产生了一个远低于当前膨胀值的供应量。多出的 yETH 被从 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() 取出资产

在每个循环结束时,攻击者取出资产。

如何提取利润

利润的提取机制如下:在步骤 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_prodvb_sum,然后将这些值传入 _calc_supply()。这是第二个漏洞在起作用:攻击者将池驱动回未初始化状态,从而获得了传递给求解器的初始条件的控制权。

当所有虚拟余额处于微量水平(汇率接近 1e18)时,计算出的值为:

  • vb_sum = 16
  • vb_prod ≈ 9.13e20
  • _supply = vb_sum = 16

_calc_supply() 内部,变量初始化为:

  • l = _amplification * _vb_sum ≈ 4.5e20 × 16 ≈ 7.2e21
  • d = _amplification - PRECISION4.49e20
  • s = _supply = 16
  • r = _vb_prod9.13e20

现在执行减法 l - s * r

7.2×102116×9.13×1020=7.2×10211.46×10227.4×10217.2 \times 10^{21} - 16 \times 9.13 \times 10^{20} = 7.2 \times 10^{21} - 1.46 \times 10^{22} \approx -7.4 \times 10^{21}

结果为负数。在未经检查的 uint256 算术中,unsafe_sub 将其环绕为约 22567.4×10212^{256} - 7.4 \times 10^{21},这是一个天文数字。除以 d(约 4.49e20)后,得到的供应估计值约为 2.35e56,协议将此全部铸造给攻击者。这种下溢之所以可能发生,仅是因为在第 2 阶段总供应量被驱动至零;在任何非退化的池状态下,l > s * r 成立,减法是安全的。

2. 将 yETH 兑换为真实资产

攻击者将部分过度铸造的 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        ← 初始供应量
  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 独立验证了这一点,利用任意精度整数计算相同的值,证实了减法并未发生下溢:

乘积是通过除法舍入归零的,而非通过减法下溢。促使供应膨胀的 unsafe_sub 下溢发生在完全不同的环境中:攻击的第 3 阶段,即当将少量流动性存入一个供应量已被耗尽至零的池中时。


0x5 结论

yETH 攻击涉及两个影响不对称的漏洞。_calc_supply() 中的不安全算术运算是主要的根本原因:其向下舍入失效(失效模式 A)仅在第 2 阶段就独立促成了约 810 万美元的损失。未禁用的引导路径是一个次要漏洞;结合下溢失效(失效模式 B),它促成了第 3 阶段约 90 万美元的额外损失,但前提是第 2 阶段已经将供应量耗尽至零。这种损失拆解使本分析区别于其他已发布报告。

官方事后审计 [2] 确定了五个根本原因。我们将它们重新分类为两个缺陷(由官方 #1 和 #5 合并的不安全算术;#4 作为未禁用的引导路径)和两个架构前提(#2 不对称的 π\pi 处理;#3 POL 启用的零供应状态)。它们的区别在于:缺陷是违反设计意图的实现错误(求解器不应产生零乘积或下溢),而前提是按预期运行但与缺陷结合时会产生攻击面的设计选择。

建议

  • 不变量求解器中使用检查算术。 即使以牺牲 gas 效率为代价,也要使用带有显式下溢/溢出回滚的 safe_divsafe_sub。求解器最多运行 256 次迭代,相比安全风险,gas 开销微不足道。
  • 中间值的边界检查。 验证乘积项在迭代间是否保持在合理范围内。掉落至零的乘积或在迭代间数量级增加的供应估计值,均标志着退化状态。
  • 不平衡限制。 强制限制任何资产的虚拟余额与目标权重比例之间的最大偏差。这将防止第 1 阶段产生攻击前提。
  • 不变量单调性检查。_calc_supply() 返回后,验证新供应量是否与变化方向一致(添加流动性不应减少供应量,汇率更新不应产生 10 倍的变化)。
  • 永久禁用初始化路径。 在池的首次存款后,关闭 prev_supply == 0 的引导分支,使其不可重入。这将完全杜绝第 3 阶段。
  • 防止零供应状态。 确保协议级别的销毁(从 POL 或质押合约中)不能在池中持有非零余额时将总供应量降至零。最低供应下限可以阻止过渡到能够启用引导重入的退化状态。
  • 实时异常检测。 监控异常状态转换(如乘积项掉落至零、供应量数量级变化或短时间内反复添加/移除循环),并在损失扩大前触发警报或断路器。

参考资料

  1. Yearn Finance 事件公告
  2. Yearn 安全事后审计报告
  3. yETH 文档
  4. yETH 白皮书:不变量派生
  5. BlockSec 浏览器上的攻击交易
  6. BlockSec:Balancer 加速池事件分析(2023 年 8 月)

关于 BlockSec

BlockSec 是一家全栈区块链安全和加密合规服务提供商。我们打造的产品和服务可帮助客户执行代码审计(包括智能合约、区块链和钱包)、实时拦截攻击、分析事件、追踪非法资金并履行 AML/CFT 合规义务,涵盖协议和平台的完整生命周期。

BlockSec 已在顶级学术会议上发表多篇区块链安全论文,报告了多个 DeFi 应用的零日攻击,并成功拦截多次攻击挽回超 2000 万美元资产,保护了数十亿美元的加密货币。

Sign up for the latest updates

Best Security Auditor for Web3

Validate design, code, and business logic before launch. Aligned with the highest industry security standards.

BlockSec Audit