2025年11月30日,Yearn Finance的yETH Weighted Stable Pool遭到攻擊,損失超過$900萬美元 [1]。根本原因是不變量求解器_calc_supply()中的不安全算術運算以及未被禁用的引導(bootstrap)路徑,該路徑允許重新進入初始化邏輯。官方事後報告 [2] 列出了五項根本原因;我們將其重新分類為兩個缺陷(上述漏洞)以及兩個架構前提條件,這兩個前提條件只有在這些缺陷存在的情況下才會變得可被利用。其他現有分析主要聚焦於逐步的攻擊交易細節。在高層次總結與交易層級細節之間,仍存在一個空白:攻擊究竟為何以及如何實際奏效?本文旨在填補這一空白,運用Foundry和Python模擬逐步追蹤關鍵數值的演變過程,並找出計算失效之處。
本分析主要有以下三項貢獻:
- 按漏洞劃分的損失明細。 這兩個漏洞並非相互依存的:僅不安全算術運算本身就造成了約$810萬美元的損失(佔總損失的90%),而引導路徑則額外促成了約$90萬美元的損失。這釐清了哪個漏洞是主要原因。
- 根本原因的重新分類。 官方報告中的五項根本原因,更適合被理解為兩個實作缺陷(整合了五項中的三項)加上兩個架構前提條件,這兩個前提條件只有與這些缺陷結合時才會變得可被利用。
- 技術性誤解的糾正。 「第二次迭代中的下溢使乘積項歸零」這一說法並不成立:我們的模擬顯示,乘積歸零是透過除法中的捨入所導致,而非下溢;而產生利潤的下溢實際上發生在完全不同的階段。
本文其餘部分安排如下。第0x1節提供yETH weighted stable pool及其不變量求解器的背景知識。第0x2節分析兩個根本原因及其失效模式。第0x3節詳細追蹤三階段攻擊過程。第0x4節透過模擬證據糾正兩個常見誤解。第0x5節提出建議並作結。
摘要(TL;DR)
根本原因: 有兩個漏洞被利用,但其影響程度並不對稱:
_calc_supply()中的不安全算術運算(主要原因,約$810萬美元)。這個根據池狀態重新計算yETH供應量的函式,包含兩個算術缺陷:unsafe_div()中的向下捨入可能使內部乘積項歸零,而unsafe_sub()中的下溢則可能使中間值包裝(wrap)成一個極大的正整數。僅憑這一個漏洞就足以耗盡yETH weighted stableswap pool。- 未被禁用的引導路徑(次要原因,約$90萬美元)。
prev_supply == 0的初始化分支在部署後從未被永久性地封鎖。在第一個漏洞將供應量耗盡至零之後,該路徑重新變得可被觸及,從而使攻擊者能夠從yETH/WETH Curve pool中額外獲利。
在不安全算術漏洞內部,第2階段僅使用了向下捨入的失效模式(失效模式A);下溢失效模式(失效模式B)則與引導路徑相互依存,兩者共同促成了第3階段。
攻擊者執行了一個三階段的攻擊序列:
- 準備階段: 透過反覆的存入/提出循環使池的資產分佈發生扭曲,在虛擬餘額中製造出極端的不平衡。
- 供應量操縱: 利用
_calc_supply()中的向下捨入缺陷,使乘積項崩潰為零,接著透過一系列的鑄造/銷毀操作將總供應量耗盡至零。之後,池中所有的LST都被提領並兌換為WETH,造成約$810萬美元的損失。 - 利潤提取: 以微量存款觸發引導路徑(
prev_supply == 0),利用_calc_supply()中的下溢缺陷鑄造約2.35×10⁵⁶枚yETH,這些yETH被用來耗盡yETH/WETH Curve pool,造成約$90萬美元的損失。
兩個被糾正的常見誤解:
- 「不變量之所以會崩壞,是因為
pow_up()和pow_down()的捨入方式不同。」 我們在Foundry模擬中將pow_up()替換為pow_down()進行驗證:攻擊利用依然有效。捨入方式的不一致並非根本原因。 - 「第二次迭代中的下溢使某個中間項崩潰為零。」 我們的Foundry和Python模擬顯示,第二次迭代中並未發生下溢。實際數值約為1.91e19(而非所謂的約1.94e18),這是正確減法運算所得出的合理結果。使乘積歸零的是隨後除法運算中的向下捨入,而非下溢。
0x1 背景
在此次事件中,有兩個池損失了資產:yETH weighted stableswap pool(一個持有LST的Yearn池,損失約$810萬美元)以及yETH/WETH Curve pool(一個Curve stableswap pool,損失約$90萬美元)。核心漏洞就存在於yETH weighted stableswap pool中。本節提供理解該漏洞及攻擊利用手法所需的背景知識。
0x1.1 虛擬餘額與不變量
yETH協議是一個為以太坊流動性質押代幣(LST)而設計的自動化做市商(AMM)[3]。受影響的yETH weighted stableswap pool將多種LST聚合到單一池中:使用者存入LST,並獲得yETH作為池份額代幣。
由於每個LST代表隨時間累積獎勵的質押ETH,其相對於基礎ETH的兌換率會發生變化。為了統一記帳方式,該池為每項資產定義了一個虛擬餘額:鏈上餘額 × 兌換率。這將所有資產統一標準化為信標鏈(beacon-chain)ETH單位。所有虛擬餘額的總和記為。
該池包含8種資產(索引0至7),每種資產都具有指定的權重:
該池的狀態是由一個weighted StableSwap風格的不變量所支配 [4]:
其中:
- 是不變量規模(invariant scale),其直接等於該池的yETH總供應量。當池處於完全平衡狀態時,。
- 是加權乘積項(weighted product term),定義為,其中為資產i的權重,。
- 是放大係數(amplification factor),是一個單一的協議參數(而非)。表示該係數取次方,其中為資產數量(此池中為8)。它控制曲線形狀,介於常數和(constant-sum,接近平衡時)與常數積(constant-product,處於極端狀態時)之間。
關鍵特性在於:沒有封閉形式的解,必須透過數值方法求解。而這個求解器,即_calc_supply(),正是算術漏洞所在之處。
0x1.2 不變量求解器
該協議透過一個上限為256輪的定點迭代(fixed-point iteration)來重新計算。此演算法在程式碼中以_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節):
- 引導路徑(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階段(約$810萬美元)僅使用了失效模式A。失效模式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 around)成一個極其龐大的正整數(接近)。這個包裝後的數值會透過除法運算及後續迭代持續傳播,產生一個荒謬地巨大的供應量估計值,而該協議隨後會將其鑄造為真實的yETH代幣。
有一種廣為流傳的說法聲稱,這類下溢發生於某個特定供應量操縱步驟的第二次迭代中。第0x4節將說明此說法並不正確:實際導致供應量膨脹的下溢發生在完全不同的情境中(攻擊的第3階段)。
3. 這些失效模式如何促成攻擊
這兩種失效模式在攻擊利用的不同階段運作,其利潤貢獻也各不相同:
-
失效模式A(第2階段,約$810萬美元):當攻擊者向嚴重不平衡的池存款時,乘積項歸零,導致
_calc_supply()返回膨脹的供應量。協議因而向攻擊者過度鑄造yETH。僅憑此失效模式,且不涉及引導路徑,攻擊者便能耗盡yETH weighted stableswap pool中的LST資產。 -
失效模式B(第3階段,約$90萬美元):在供應量被耗盡至零之後,引導路徑根據微量存款重新計算出一個龐大的乘積項,導致減法運算下溢。協議鑄造出一筆天文數字般龐大的yETH,攻擊者利用這些yETH耗盡了另一個獨立的yETH/WETH Curve pool。
兩者的依存關係是單向的:失效模式A可獨立被利用,造成了90%的損失,而失效模式B則需要先由失效模式A將供應量推向零才能發揮作用。
0x2.2 未被禁用的引導路徑(次要原因)
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(),利用公式(4)(第0x1.3節)從頭重新計算vb_prod和vb_sum。此引導分支原本設計為在池初始化期間僅使用一次,但在首次存款之後從未被永久性地封鎖。
如果總供應量能夠透過任何組合的銷毀和提領操作被推向零,該分支就會重新變得可被觸及。重新進入此路徑的攻擊者,便能夠控制傳遞給_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 pool中將約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()建立修正的前提條件; - 以0枚yETH透過
remove_liquidity()重置乘積; - 透過
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. 以0枚yETH透過remove_liquidity()重置乘積
攻擊者以數量0呼叫remove_liquidity()。雖然沒有代幣被提領,但該函式會利用公式(4)(第0x1.3節),根據目前的池狀態重新計算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 pool中兌換成約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:對2023年8月Balancer boosted pool事件的分析
關於BlockSec
BlockSec是一家全端區塊鏈安全與加密合規服務提供商。我們構建的產品與服務,能夠協助客戶進行程式碼審計(包括智能合約、區塊鏈及錢包)、即時攔截攻擊、分析事件、追蹤非法資金,並在協議與平台的整個生命週期中滿足AML/CFT(反洗錢/打擊資助恐怖主義)義務。
BlockSec已在多個頂級會議上發表過區塊鏈安全相關論文,通報過多起DeFi應用程式的零日攻擊,成功攔截多起駭客攻擊並挽回超過2,000萬美元的損失,並為數十億美元的加密貨幣資產提供了安全保障。
-
官方Twitter帳號:https://twitter.com/BlockSecTeam
-
🔗 BlockSec審計服務 :提交申請



