2025年11月30日、Yearn FinanceのyETH Weighted Stable Poolが900万ドル以上を流出するハッキング被害に遭いました [1]。根本原因は、不変条件ソルバー_calc_supply()における安全でない算術演算と、初期化ロジックへの再入室を許可した無効化されていないブートストラップパスでした。公式の事後分析レポート [2] は5つの項目を根本原因として挙げていますが、これらは2つの欠陥(上記の脆弱性)と、これらの欠陥が存在する状況でのみ悪用可能となった2つのアーキテクチャ上の前提条件に再分類できます。他の利用可能な分析は、攻撃トランザクションの詳細に焦点を当てています。高レベルの概要とトランザクションレベルの詳細の間にはギャップがあります。攻撃はどのように、なぜ機能したのでしょうか?この記事はそのギャップを埋めるために、FoundryとPythonのシミュレーションを使用して、主要な値がどのように段階的に進化し、計算がどこで破綻するかを追跡します。
この分析は主に以下の3つの貢献をします。
- 脆弱性別の損失内訳。 2つの脆弱性は相互依存していません。不安全な算術演算のみで約810万ドル(総額の90%)の損失が発生し、ブートストラップパスによりさらに約90万ドルの損失が発生しました。これにより、どの脆弱性が主であったかが明確になります。
- 根本原因の再分類。 公式レポートの5つの根本原因は、2つの実装上の欠陥(5項目のうち3項目を統合)と、欠陥と組み合わせた場合にのみ悪用可能となった2つのアーキテクチャ上の前提条件として、より良く理解できます。
- 技術的な誤解の訂正。 「2回目の反復におけるアンダーフローが積項をゼロにする」という主張は成り立ちません。シミュレーションによると、積はアンダーフローではなく、除算における丸めによってゼロになり、利益を生むアンダーフローはまったく異なるフェーズで発生します。
本稿の残りは以下の通りです。セクション0x1では、yETHのweighted stable poolと不変条件ソルバーの背景を説明します。セクション0x2では、2つの根本原因とその障害モードを分析します。セクション0x3では、3段階の攻撃を詳細に追跡します。セクション0x4では、シミュレーション証拠を用いて2つの一般的な誤解を訂正します。セクション0x5では、推奨事項で締めくくります。
TL;DR
根本原因: 2つの脆弱性が悪用されましたが、影響は非対称でした。
_calc_supply()における安全でない算術演算(主要、約810万ドル)。プール状態からyETH供給を再計算する関数には、2つの算術演算上の障害があります。unsafe_div()における切り捨て丸めにより内部積項がゼロになる可能性、およびunsafe_sub()におけるアンダーフローにより中間値が巨大な正の整数にラップされる可能性があります。この脆弱性だけでyETH weighted stableswapプールを枯渇させることが可能でした。- 無効化されていないブートストラップパス(副次的、約90万ドル)。
prev_supply == 0の初期化分岐は、デプロイ後も恒久的にゲートされていませんでした。最初の脆弱性により供給がゼロに枯渇した後、このパスは到達可能になり、yETH/WETH Curveプールからの追加利益を可能にしました。
不安全な算術演算の脆弱性内では、切り捨て丸め障害(障害モードA)のみがフェーズ2で使用されました。アンダーフロー障害(障害モードB)はブートストラップパスと相互依存しており、これらが組み合わさることでフェーズ3が可能になりました。
攻撃者は3段階のシーケンスを実行しました。
- 準備: 仮想残高の極端な不均衡を作り出すために、追加/削除のサイクルを繰り返して、資産のプール分配を歪める。
- 供給操作:
_calc_supply()における切り捨て丸めを悪用して積項をゼロに崩壊させ、次にミント/バーン操作のシーケンスを通じて総供給をゼロに枯渇させる。すべてのプールLSTが引き出され、WETHに交換され、約810万ドルの損失につながった。 - 利益抽出: ダスト堆積物でブートストラップパス(
prev_supply == 0)をトリガーし、_calc_supply()におけるアンダーフローを悪用して約2.35×10^56 yETHをミントし、それらを使用してyETH/WETH Curveプールを枯渇させ、約90万ドルの損失につながった。
訂正された2つの一般的な誤解:
- 「不変条件は
pow_up()とpow_down()が異なる丸めを行うために破綻する。」 Foundryシミュレーションでpow_up()をpow_down()に置き換えたところ、エクスプロイトは依然として機能しました。丸めの不一致は根本原因ではありません。 - 「2回目の反復におけるアンダーフローにより中間項がゼロになる。」 FoundryとPythonのシミュレーションでは、2回目の反復でアンダーフローは発生しません。実際の値は約1.91e19(主張されている約1.94e18ではない)であり、正しい減算の正当な結果です。積をゼロにするのは、その後の除算における切り捨て丸めであり、アンダーフローではありません。
0x1 背景
このインシデントで資産を失ったプールは2つあります。yETH weighted stableswap pool(LSTを保持するYearnプール、約810万ドルの損失)とyETH/WETH Curve pool(Curve stableswapプール、約90万ドルの損失)です。yETH weighted stableswap poolにコアの脆弱性があります。このセクションでは、脆弱性とエクスプロイトを理解するために必要な背景情報を提供します。
0x1.1 仮想残高と不変条件
yETHプロトコルは、Ethereum Liquid Staking Tokens(LST)のAutomated Market Maker(AMM)です [3]。影響を受けたyETH weighted stableswap poolは、複数のLSTを単一のプールに集約します。ユーザーはLSTを預け入れ、プール共有トークンとしてyETHを受け取ります。
各LSTは、時間とともに報酬を蓄積するステーキングされたETHを表すため、ベースETHに対する交換レートは変化します。会計を統一するために、プールは各資産の仮想残高 を定義します。これは、オンチェーン残高×交換レートです。これにより、すべての資産はビーコンチェーンETH単位に正規化されます。すべての仮想残高の合計をとします。
プールには8つの資産(インデックス0-7)があり、それぞれに指定された重み があります。
プールは、加重StableSwapスタイルの不変条件 [4] に従います。
ここで:
- は不変条件スケールであり、このプールの総yETH供給に直接等しくなります。プールが完全にバランスが取れている場合、 です。
- は加重積項であり、 と定義されます。ここで、 は資産 i の重み、 です。
- は増幅係数であり、単一のプロトコルパラメータです(ではありません)。 はこの係数をn乗したものです。ここで、nは資産数(このプールでは8)です。これは、均衡付近(定数和)と極端(定数積)の間の曲線形状を制御します。
主要な特性: には閉形式解がありません。数値的に解く必要があります。そのソルバー、_calc_supply() は、算術演算の脆弱性が存在する場所です。
0x1.2 不変条件ソルバー
プロトコルは、256回の反復で制限された固定小数点反復によってを再計算します。このアルゴリズムはコード内の_calc_supply()として実装されています(セクション0x2.1で詳細)。各反復は3つのステップを実行します。
ステップ1:供給推定値の更新。
ステップ2:新しい供給に一致するように積項を更新。
ステップ3:収束の確認。
の場合、 を返します。そうでない場合は、ステップ1から繰り返します。
初期値、、およびは初期反復に影響を与えます。理論的には最終的な収束には無関係ですが、有限の反復と固定精度算術演算のため、実際の結果に影響します。
実装では固定精度整数演算が使用されます。除算は切り捨てられ、減算はアンダーフローをガードしません。通常のプール条件下では、中間値は安全な範囲内に留まります。極端なプール状態では、そうはなりません。セクション0x2.1では、これらの障害モードを詳細に分析します。
0x1.3 3つのインターフェイスと不変条件ソルバー
プロトコルは、加重積項(コードではvb_prodとして格納)を更新することにより、プール状態に影響を与える3つのエントリポイントを公開しています。
| インターフェイス | 何をするか | _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() 関数は、コントラクト内に記録された交換レートが外部レートと一致しているかどうかを確認します。不一致が検出された場合、仮想残高の再計算がトリガーされ、その後不変条件が更新されます。それ以外の場合は、更新プロセスがスキップされます。
各インターフェイスがπを処理する方法
不変条件に影響を与える方法に基づいて、これらの3つの関数は2つのカテゴリに分類できます。具体的には、add_liquidity() と update_rates() は仮想残高の非比例的な変更を許可するため、供給 と積 の反復的な再計算が必要です。対照的に、remove_liquidity() は比例的に流動性を引き出し、反復計算を必要としません。
積をゼロから計算するための基本式は次のとおりです。
ここで、 は供給、 は資産 の重み、 はその仮想残高(コードではvb[i]として格納)、nは資産数です。この形式は、 が積に分配されていることを除き、セクション0x1.1の定義と代数的に同等です。
add_liquidity()には2つのパスがあります(セクション0x2.2のコードを参照)。
- ブートストラップパス(
prev_supply == 0の場合):式(4)を使用して、vb_prodをゼロから計算します。デプロイ後もこのパスがアクセス可能であることは、セクション0x2.2で議論されている状態管理の脆弱性です。 - 通常パス(
prev_supply > 0の場合):計算プロセスは2つのステップに分かれています。-
a) 古い仮想残高と新しい仮想残高の比率に基づく増分更新を使用します。
ここで、 と は預け入れ前と預け入れ後の仮想残高です。
-
b) この推定値を入力として
_calc_supply()を呼び出し、不変条件 と正確な の値を再計算することにより、正確な値を反復的に調整します。
-
-
update_rates()は、交換レートが変更され、対応する資産の仮想残高が更新されるときにトリガーされます。その後の計算フローは、add_liquidity()の通常パスに従います。つまり、不変条件は反復的に再計算されます。さらに、新しく計算された供給に基づいて、コントラクトはyETHをミントまたはバーンし、流動性供給が更新された仮想残高状態と一致することを保証します。 -
remove_liquidity()は、各仮想残高を比例的に削減した後、常に式(4)を使用してゼロからvb_prodを計算します。
0x2 根本原因分析
2つの脆弱性が悪用され、異なる役割と影響がありました。主要な根本原因は、_calc_supply() の計算上の欠陥であり、2つの障害モードがありました。(A)切り捨て丸めにより積項がゼロになる可能性があり、不変条件を定数和モデルに退化させ、過剰なLPミント(供給インフレ)につながる。(B)アンダーフロー条件も供給をインフレさせる可能性があります。障害モードAのみがフェーズ2(約810万ドル)で使用されました。障害モードBは、二次的な脆弱性と相互依存していました。
二次的な根本原因は、状態管理の欠陥でした。プールの初期化分岐がアクセス可能なままでした。フェーズ2が供給をゼロにした後、障害モードBとブートストラップパスが組み合わさって、さらに約90万ドルの損失(フェーズ3)を可能にしました。
0x2.1 _calc_supply() における安全でない算術演算(主要)
図2は、_calc_supply() の実装をセクション0x1.2の数学的手順にマッピングし、以下で分析される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: π 更新(資産ごと)
この関数内には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 より小さくなる可能性があります。整数除算は、then yields 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で詳細)は、式(4)(セクション0x1.3)を使用してゼロから積項を再計算します。相対的な magnitudes が逆転する可能性があります。
具体的には、ダスト量のゼロ供給プールで add_liquidity() が呼び出されると、初期化分岐は _calc_vb_prod_sum() を呼び出して、式(4)(セクション0x1.3)を使用して新しい値を計算します。最小預け入れ量で vb_sum は最小(例:16)ですが、ゼロに近い残高で除算し、高いべき乗にすると、積が不均衡に大きな値(例:約9.13e20)に増幅されます。s * r が l を超えると、減算は負の数学的結果をもたらします。
unsafe_sub() はチェックされていない uint256 算術演算で減算を実行するため、負の結果は、巨大な正の整数(に近い)にラップアラウンドします。このラップされた値は、除算および後続の反復を通じて伝播し、不条理に大きな供給推定値を生み出し、プロトコルはそれを実際のyETHトークンとしてミントします。
一般的な主張は、そのようなアンダーフローが特定の供給操作ステップの2回目の反復で発生すると主張しています。セクション0x4は、この主張が誤りであることを示しています。供給をインフレさせる実際のアンダーフローは、まったく異なるコンテキスト(攻撃のフェーズ3)で発生します。
3. これらの障害が攻撃を可能にする方法
これらの2つの障害モードは、異なる効果と利益貢献度で、エクスプロイトの異なるフェーズで動作します。
-
障害モードA(フェーズ2、約810万ドル):攻撃者が極端に不均衡なプールに預け入れると、積項がゼロになり、
_calc_supply()がインフレした供給を返す原因となります。プロトコルは過剰にyETHを攻撃者にミントします。この障害モード単独で、ブートストラップパスを一切使用せずに、攻撃者がyETH weighted stableswapプールからLST資産を枯渇させることが可能になりました。 -
障害モードB(フェーズ3、約90万ドル):供給がゼロに枯渇した後、ブートストラップパスはダスト預け入れにより大きな積項を再計算し、減算がアンダーフローを引き起こします。プロトコルは天文学的に大きな量のyETHをミントし、攻撃者はそれを使用して別のyETH/WETH Curveプールを枯渇させ、約90万ドルの損失につながりました。
依存関係は一方向です。障害モードAは独立して悪用可能であり、損失の90%を引き起こしました。障害モードBは、フェーズ2がまず供給をゼロにする必要があるのに対し、障害モードBはそれを必要とします。
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() に渡します(セクション0x1.3の式(4)を使用)。このブートストラップ分岐は、プール初期化時の1回限りの使用を意図していましたが、最初の預け入れ後、恒久的にゲートされませんでした。
総供給をゼロに枯渇させることができる場合(バーンと引き出しの任意の組み合わせによる)、分岐は再び到達可能になります。このパスに再参入する攻撃者は、通常のプール操作では発生しないパラメータで、算術演算の障害をトリガーする可能性のある初期条件を制御できます。
これは既知の脆弱性パターンです。2023年8月、Balancer V2インシデントも同様に、攻撃者が有利なパラメータで初期化ロジックに再参入できるように、供給をゼロに枯渇させることに依存していました [6]。デプロイされたプールを初期状態に戻すことができるか、そしてその際の不変条件は何か、という問題はプロトコル設計者が明示的に対処する必要があります。
0x3 攻撃分析
エクスプロイトは、攻撃トランザクション [5] の協調シーケンス全体で3つのフェーズにわたって展開されます。各フェーズは、前のフェーズによって確立された状態に基づいています。
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() はプール重みに比例して資産を引き出します(上記の図の赤い四角で強調されています)。預け入れは選択した資産のみを行い、すべてを引き出す資産は比例的に行うという、追加 → 削除のサイクルを繰り返すことで、攻撃者はプールを極端に不均衡な状態に徐々に追い込みました。
| Asset | Weight | Before | After | Change |
|---|---|---|---|---|
| 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%を引き起こしました。
このフェーズは、3回実行される、繰り返し行われる5ステップのサイクルを使用します。
add_liquidity()により積を破損する。add_liquidity()により修正の前提条件を確立する。remove_liquidity()を0 yETHで呼び出して積をリセットする。update_rates()により供給を修正する。remove_liquidity()により資産を引き出す。
次の図は、5ステップサイクルの3回の繰り返しが明確に見られるトランザクショントレースを示しています。
1. add_liquidity() により積を破損する
攻撃者は、各資産を現在の仮想残高の約3倍預け入れることで、高重み資産(インデックス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で呼び出して積をリセットする
攻撃者は量0でremove_liquidity()を呼び出します。トークンは引き出されませんが、関数は現在のプール状態から式(4)(セクション0x1.3)を使用して**vb_prodを再計算します**。仮想残高がゼロではないため、これはゼロ以外の積(約9.09e19)を生成し、破損したゼロ値を上書きします。
4. update_rates() により供給を修正する
攻撃者は資産インデックス6(WOETH)または7(mETH)に対してupdate_rates()を呼び出します。交換レートが更新されてから変更されている場合、関数は復元された(ゼロ以外の)積で_calc_supply()をトリガーします。今回は、反復が正しく収束し、現在のインフレした値よりもはるかに低い供給値を生成します。差分はyETHステーキングコントラクトからバーンされます。公式の事後分析レポート [2] によると、これはプロトコル所有流動性(POL)を構成します。つまり、バーンは攻撃者の保有量ではなく、プロトコルのポジションを減らします。この非対称性は重要です。各サイクルは、攻撃者のyETH残高はそのままですが、総供給を減らします。
レートの不一致自体は利益の源ではありません。それは単にトリガーメカニズムとして機能します。3つのプールインターフェイスのうち、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を引き出すことになります。この差分は、3回のサイクルで抽出され、合計約810万ドルになります。
リベースの目的
トレース(最初のサイクルと2番目のサイクルの間)には、OETHVaultProxy.rebase() の呼び出しも示されています。これはOETHのリベースをトリガーします。WOETHコントラクトが保持するOETH残高が増加し、WOETHの効果的な交換レートが上昇します。この「保存された」レートの不一致は、2回目のサイクルのステップ4を再び可能にするものです。update_rates() が最終的に呼び出されると、不一致を検出し、_calc_supply() をトリガーします。
ゼロへの枯渇
この5ステップサイクルを3回繰り返した後、攻撃者はプールの総供給を、保有している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() に渡します。これが2番目の脆弱性です。攻撃者はプールを初期化されていない状態に戻し、ソルバーに渡される初期条件を制御しました。
すべての仮想残高がダストレベル(交換レートが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 誤解の訂正
このインシデントのほとんどの公開されている分析は、攻撃者が前提条件をどのように設定するかを十分に説明せずに、算術演算の症状に焦点を当てています。2つの特定の主張は訂正に値します。
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 主張:「2回目の反復におけるアンダーフローが中間項をゼロにする」
広く引用されている説明では、_calc_supply() の2回目の反復中に、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エクスプロイトには、非対称な影響を持つ2つの脆弱性が関与していました。_calc_supply() の安全でない算術演算が主要な根本原因でした。その切り捨て丸め障害(障害モードA)は、フェーズ2だけで約810万ドルの損失を独立して可能にしました。無効化されていないブートストラップパスは二次的な脆弱性でした。障害モードB(アンダーフロー)と組み合わさることで、フェーズ2がすでに供給をゼロに枯渇させた後、フェーズ3でさらに約90万ドルの損失を可能にしました。この損失の内訳は、フェーズ2とフェーズ3の利益を区別しない他の公開レポートとは異なります。
公式の事後分析レポート [2] は5つの根本原因を特定しています。これらを2つの欠陥(_calc_supply() の安全でない算術演算は公式の#1と#5を統合。無効化されていないブートストラップパスは#4)と2つのアーキテクチャ上の前提条件(#2 非対称なΠ処理。#3 POLによるゼロ供給状態)に再分類します。区別:欠陥は設計意図に違反する実装バグです(ソルバーはゼロ積やアンダーフローを生成すべきではない)。前提条件は、欠陥と組み合わせると攻撃可能な攻撃面を作成する設計選択です。
推奨事項
- 不変条件ソルバーにおけるチェック付き算術演算。 ガス効率のコストを犠牲にしてでも、アンダーフロー/オーバーフロー時の明示的なリバートを伴う
safe_divとsafe_subを使用します。ソルバーは最大256回の反復しか実行せず、ガスオーバーヘッドはセキュリティリスクと比較して無視できます。 - 中間値の境界チェック。 積項が反復間で健全な範囲内に留まることを検証します。積がゼロに低下したり、反復間で供給推定値が桁違いに増加したりすると、退化した状態を示します。
- 不均衡制限。 任意の資産の仮想残高と、そのターゲットの重み比例残高との間の最大偏差を強制します。これにより、フェーズ1が前提条件を作成できなくなります。
- 不変条件単調性チェック。
_calc_supply()が返された後、新しい供給が変更の方向と一致していることを確認します(流動性の追加は供給を減らすべきではなく、レート更新は10倍の変更を生成すべきではありません)。 - 初期化パスの恒久的無効化。 プールの最初の預け入れ後、
prev_supply == 0ブートストラップ分岐をゲートして、再参入できないようにします。これにより、フェーズ3が完全にブロックされます。 - ゼロ供給状態の防止。 プロトコルレベルのバーン(POLまたはステーキングコントラクトから)が、プールがゼロ以外の残高を保持している間に総供給をゼロに減らすことができないことを保証します。最小供給フロアは、ブートストラップ再参入を可能にする退化した状態への移行をブロックします。
- リアルタイム異常検出。 異常な状態遷移(積項がゼロに低下する、供給が桁違いに変化する、短期間での追加/削除サイクルの繰り返しなど)を監視し、損失が累積する前にアラートまたはサーキットブレーカーをトリガーします。
参考文献
- Yearn Finance incident announcement
- Yearn Security post-mortem
- yETH documentation
- yETH whitepaper: invariant derivation
- Attack transaction on BlockSec Explorer
- BlockSec: Analysis of the Balancer boosted pool incident (August 2023)
BlockSecについて
BlockSecは、フルスタックのブロックチェーンセキュリティおよび暗号コンプライアンスプロバイダーです。私たちは、顧客がコード監査(スマートコントラクト、ブロックチェーン、ウォレットを含む)、リアルタイムでの攻撃傍受、インシデント分析、不正資金追跡、およびAML/CFT義務の遵守を、プロトコルとプラットフォームのライフサイクル全体にわたって実行するのを支援する製品とサービスを構築しています。
BlockSecは、著名なカンファレンスで複数のブロックチェーンセキュリティ論文を発表し、DeFiアプリケーションの数多くのゼロデイ攻撃を報告し、複数のハッキングを阻止して2000万ドル以上を救済し、数十億ドルの暗号通貨を確保してきました。
-
公式ウェブサイト:https://blocksec.com/
-
公式Twitterアカウント:https://twitter.com/BlockSecTeam



