Back to Blog

Yearn.finance 事件 #5:不安全算術導致 Invariant Solver 名實不符

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. 利潤提取: 通過微量存款觸發引導路徑(prev_supply == 0),利用 _calc_supply() 中的下溢鑄造約 2.35×10⁵⁶ yETH,這些代幣隨後被用於耗盡 yETH/WETH Curve 池,導致約 90 萬美元的損失。

糾正兩個常見誤解:

  • 「因為 pow_up()pow_down() 的四捨五入方式不同,導致不變量失效。」 我們在 Foundry 模擬中用 pow_down() 替換了 pow_up() 進行驗證:漏洞依然有效。四捨五入不匹配並非根本原因。
  • 「第二次迭代中的下溢使得中間項崩潰為零。」 我們的 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:鏈上餘額 × 匯率。這將所有資產歸一化為信標鏈(beacon-chain)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不變量規模(invariant scale),直接等於該池的總 yETH 供應量。當池完全平衡時,D=σD = \sigma
  • π\pi加權乘積項(weighted product term),定義為 π=Dni(wixi)vi\pi = D^n \prod_{i} \left(\frac{w_i}{x_i}\right)^{v_i},其中 wiw_i 是資產 i 的權重,vi=winv_i = w_i \cdot n
  • Af\mathit{Af}放大係數(amplification factor),一個協議參數。Afn\mathit{Af}^{\,n} 表示該係數的 nn 次方(此池中 n=8n=8)。它控制了常數和(接近平衡時)與常數乘積(極端情況下)之間的曲線形狀。

關鍵屬性是:DD 沒有封閉形式的解,必須通過數值方法求解。而該求解器 _calc_supply() 正是算術漏洞所在之處。

0x1.2 不變量求解器

協議通過固定點迭代(上限為 256 輪)來重新計算 DD。該算法在代碼中實現為 _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() 函數會檢查合約內記錄的匯率是否與外部匯率一致。如果檢測到差異,它會觸發虛擬餘額的重新計算並隨後更新不變量;否則,更新過程將被跳過。

每個接口如何處理 π\pi

根據它們如何影響不變量,這三個函數可分為兩類。特別是 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 節):
  • 引導路徑(Bootstrap path)(當 prev_supply == 0 時):使用公式 (4) 從頭計算 vb_prod。此路徑在部署後依然可訪問,即為 0x2.2 節討論的狀態管理漏洞。
  • 正常路徑(Normal path)(當 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() 中的不安全算術(主要)

下圖將 _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*r,即 AfnσDmπm\mathit{Af}^{\,n} \cdot \sigma - D_m \cdot \pi_m。在正常條件下,這是正數。然而,當池達到供應量為零的退化狀態時,add_liquidity() 中的初始化分支(詳見 0x2.2 節)會從頭重新計算乘積項,此時相對幅度可能會顛倒。

具體來說,當在一個零供應量的池中以微量(dust)調用 add_liquidity() 時,初始化分支會調用 _calc_vb_prod_sum() 使用公式 (4)(0x1.3 節)計算新鮮值。由於存款極小,vb_sum 極其微小(例如 16),但在除以接近零的餘額並乘以高次方後,乘積會被放大為極大的數值(例如 ~9.13e20)。當 s * r 超過 l 時,減法產生負數結果。

由於 unsafe_sub() 執行的是 未經檢查(unchecked)的 uint256 算術,負數結果會繞回成一個巨大的正整數(接近 22562^{256})。該繞回值隨後會在除法和後續迭代中傳播,產生荒謬巨大的供應量估計,協議隨即將其鑄造為真實的 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() 使用公式 (4) 從頭重新計算 vb_prodvb_sum(0x1.3 節)。此引導分支本意僅供池初始化時使用,但在第一次存款後從未被永久關閉。

如果總供應量能被驅動至零(通過銷毀和提款的組合),該分支就會再次變得可訪問。重新進入此路徑的攻擊者可以控制傳遞給 _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、6 和 7 的餘額被耗盡了超過 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() 破壞乘積

攻擊者存入大量高權重資產(sfrxETH, wstETH, ETHx, rETH, apxETH),每種資產約為當前池虛擬餘額的三倍。

add_liquidity() 通過公式 (5) 的增量更新估算新的乘積項。由於高權重資產滿足 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) 從當前池狀態 重新計算 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() 提取資產

在每個循環結束時,攻擊者提取資產。

如何提取利潤

利潤提取機制如下:在第 1 步中,攻擊者存入 LST 並收到超額鑄造的 yETH(由於乘積被破壞)。在第 4 步中,供應量校正時,多餘的 yETH 從 POL(質押合約)而非攻擊者處被銷毀。在第 5 步中,攻擊者根據其 yETH 持有量按比例提取 LST。由於 POL 吸收了銷毀,而攻擊者的 yETH 餘額未變,攻擊者最終提取的 LST 比存入的更多。此差異在三個循環中總計約 810 萬美元。

重定基數(Rebase)的目的

交易追蹤(在第一次和第二次循環之間)還顯示了對 OETHVaultProxy.rebase() 的調用,這觸發了 OETH 的重定基數:WOETH 合約持有的 OETH 餘額增加,提高了 WOETH 的有效匯率。這種「保存」下來的匯率差異使得第二次循環的第 4 步再次成為可能:當最終調用 update_rates() 時,它檢測到差異並觸發 _calc_supply()

耗盡至零

重複上述五步循環三次後,攻擊者已將池的總供應量降低至低於其持有的 yETH 數量。最後一次調用 remove_liquidity() 取出剩餘的供應量,將池耗盡至

此時池供應量、乘積和 vb_sum 全部為零。這種退化狀態違背了一個默認的設計假設:一個有過存款的池永遠不會回到其未初始化的狀態。

0x3.3 第 3 階段:利用零供應量獲取額外利潤(約 90 萬美元)

目標: 從退化的池狀態中鑄造海量 yETH,然後兌換為真實資產。此階段利用了次要漏洞(未禁用的引導路徑)和故障模式 B(下溢)的依賴組合。

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–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 迭代 0 =======
  l = 4905875511098192451202650000000000000000
  s = 2514373972590845290489        ← 初始供應量
  r = 3538247433646816               ← 初始乘積(非常小)
  d = 4490000000000000000000

  sp = (l - s*r) / d ≈ 1.093e22     ← 新供應量跳變 ~4 倍
  新 r ≈ 4.49e22                    ← 乘積戲劇性膨脹

======= _calc_supply 迭代 1 =======
  s = 10926206313726454855296        ← 來自前一個 sp
  r = 44892226765713223838396        ← 來自前一個內循環

  sp = 19113493328251743069          ← ≈ 1.91e19,合法的微小數值
  新 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 階段和第 3 階段的利潤。

官方事後分析報告 [2] 指出了五項根本原因。我們將其重新歸類為 兩個缺陷(不安全算術,整合了官方的 #1 和 #5;未禁用的引導路徑為 #4)以及 兩個架構前提條件(#2 不對稱的 Π 處理;#3 POL 啟用的零供應量狀態)。區別在於:缺陷是違背設計意圖的實現 Bug(求解器不應產生零乘積或下溢),而前提條件則是意圖如此但當與缺陷結合時會產生可利用攻擊面的設計選擇。

建議

  • 在不變量求解器中使用受檢算術。 使用會對下溢/溢出進行顯式 revert 的 safe_divsafe_sub,即便以 gas 效率為代價也在所不惜。求解器最多運行 256 次迭代,此 gas 開銷相較安全風險而言微不足道。
  • 針對中間值的邊界檢查。 驗證乘積項在迭代之間是否保持在合理範圍內。一個降至零的乘積或在迭代間發生量級變化的供應量估計,均是退化狀態的信號。
  • 不平衡限制。 強制執行任何資產虛擬餘額與其預期權重比例份額之間的最大偏差。這將防止第 1 階段創造出前提條件。
  • 不變量單調性檢查。 調用 _calc_supply() 返回後,驗證新的供應量與變化方向的一致性(添加流動性絕不能減少總供應量,匯率更新不應產生 10 倍的變化等)。
  • 永久禁用初始化路徑。 在池首次存款後,關閉 prev_supply == 0 的引導分支,使其不可被重新進入。這將徹底杜絕第 3 階段。
  • 防止零供應量狀態。 確保協議級銷毀(來自 POL 或質押合約)不能在池中持有非零餘額時將總供應量降低至零。設置最小供應量下限將阻止過渡到實現引導路徑再次進入的退化狀態。
  • 實時異常檢測。 監控異常狀態轉變(例如乘積項歸零、供應量進行量級變更、或短時間內的重複添加/移除循環),並在損失累計前觸發警報或熔斷機制。

參考資料

  1. Yearn Finance 事件公告
  2. Yearn 安全事後分析報告
  3. yETH 文檔
  4. yETH 白皮書:不變量推導
  5. BlockSec Explorer 上的攻擊交易
  6. BlockSec:Balancer Boosted 池攻擊事件分析(2023年8月)

關於 BlockSec

BlockSec 是一家提供全棧區塊鏈安全與加密合規服務的供應商。我們構建產品和服務,協助客戶進行代碼審計(包括智能合約、區塊鏈和錢包)、實時攔截攻擊、分析事件、追蹤非法資金,並滿足各協議和平台整個生命週期中的 AML/CFT 合規要求。

BlockSec 已在頂級會議上發表多篇區塊鏈安全論文,上報了數個 DeFi 應用的零日漏洞,攔截了多次攻擊並挽回了超過 2,000 萬美元的資金,並保護了價值數十億美元的加密資產。

Best Security Auditor for Web3

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

BlockSec Audit