2025年11月30日、Yearn FinanceのyETH Weighted Stable Poolが900万ドル以上の被害を受けて悪用されました[1]。根本原因は、invariantソルバー_calc_supply()における安全でない演算と、初期化ロジックへの再突入を許した無効化されていないブートストラップパスでした。公式のポストモーテム[2]では5つの項目を根本原因として挙げていますが、我々はこれらを2つの欠陥(上記の脆弱性)と、これらの欠陥が存在する場合にのみ悪用可能となる2つのアーキテクチャ上の前提条件として再分類します。他の公開されている分析は、攻撃トランザクションの詳細をステップごとに追うことに焦点を当てています。ハイレベルな概要とトランザクションレベルの詳細の間には、攻撃が実際にどのように、そしてなぜ機能したのかというギャップが残っています。本記事は、FoundryとPythonのシミュレーションを用いて、主要な値がどのように段階的に変化し、どこで計算が破綻するかを追跡することで、そのギャップを埋めます。
本分析は主に以下の3つの貢献を行います:
- 脆弱性ごとの損失の内訳。 2つの脆弱性は相互依存しているわけではありません。安全でない演算単独で約810万ドル(全体の90%)の損失を引き起こし、一方でブートストラップパスは追加で約90万ドルを可能にしました。これにより、どちらの脆弱性が主要なものであったかが明確になります。
- 根本原因の再分類。 公式レポートの5つの根本原因は、2つの実装上の欠陥(5項目のうち3つを統合)と、その欠陥と組み合わさった場合にのみ悪用可能となる2つのアーキテクチャ上の前提条件として理解する方がより適切です。
- 技術的誤解の訂正。 「2回目の反復におけるアンダーフローが積の項をゼロにする」という主張は成立しません。我々のシミュレーションでは、積は除算における丸め処理によってゼロになるのであり、アンダーフローによるものではないことが示されています。また、利益を生み出すアンダーフローは全く別のフェーズで発生しています。
本記事の残りの構成は以下の通りです。0x1節では、yETHのweighted stable poolとそのinvariantソルバーに関する背景情報を提供します。0x2節では、2つの根本原因とその失敗モードを分析します。0x3節では、3段階の攻撃を詳細に追跡します。0x4節では、シミュレーションによる証拠を用いて2つの一般的な誤解を訂正します。0x5節では、推奨事項を含めて結論を述べます。
TL;DR
根本原因: 2つの脆弱性が悪用されましたが、その影響は非対称でした:
_calc_supply()における安全でない演算(主要、約$8.1M)。プール状態からyETHの供給量を再計算する関数には、2つの演算上の欠陥があります。unsafe_div()における切り下げは内部の積の項をゼロにする可能性があり、unsafe_sub()におけるアンダーフローは中間値を巨大な正の整数にラップさせる可能性があります。この脆弱性単独で、yETH weighted stableswap poolを枯渇させるのに十分でした。- 無効化されていないブートストラップパス(二次的、約$0.9M)。
prev_supply == 0の初期化分岐は、デプロイ後に永続的にゲートされることがありませんでした。1つ目の脆弱性が供給量をゼロまで枯渇させた後、このパスが到達可能になり、yETH/WETH Curve poolからの追加の利益を可能にしました。
安全でない演算の脆弱性の中で、丸め切り下げの失敗(失敗モードA)のみがフェーズ2で使用されました。アンダーフローの失敗(失敗モードB)はブートストラップパスと相互依存しており、両者が合わさってフェーズ3を可能にしました。
攻撃者は3段階の手順を実行しました:
- 準備: 繰り返しの追加/削除サイクルを通じてプールの資産分布を偏らせ、仮想バランスに極端な不均衡を生み出す。
- 供給量の操作:
_calc_supply()の丸め切り下げを悪用して積の項をゼロに崩壊させ、その後一連のミント/バーン操作によって総供給量をゼロにまで枯渇させる。その後、プールの全てのLSTが引き出され、WETHにスワップされ、約$8.1Mの損失につながった。 - 利益の抽出: ダスト額のデポジットによってブートストラップパス(
prev_supply == 0)を発動させ、_calc_supply()のアンダーフローを悪用して約2.35×10⁵⁶ yETHをミントし、これを使ってyETH/WETH Curve poolを枯渇させ、約$0.9Mの損失につながった。
訂正された2つの一般的な誤解:
- 「invariantが破綻するのは
pow_up()とpow_down()の丸め方が異なるためである」。 Foundryのシミュレーションでpow_up()をpow_down()に置き換えて検証したところ、exploitは依然として機能しました。丸め方の不一致は根本原因ではありません。 - 「2回目の反復におけるアンダーフローが中間の項をゼロに崩壊させる」。 我々のFoundryとPythonのシミュレーションでは、2回目の反復でアンダーフローは発生していないことが示されています。実際の値は約1.91e19(主張されている約1.94e18ではない)であり、正当な減算の結果です。積をゼロにするのは、その後の除算における切り下げであり、アンダーフローではありません。
0x1 背景
この事件では、2つのプールが資産を失いました。yETH weighted stableswap pool(LSTを保有するYearnのプール、約$8.1Mの損失)と、yETH/WETH Curve pool(Curveのstableswapプール、約$0.9Mの損失)です。核心となる脆弱性が存在するのはyETH weighted stableswap poolです。本節では、脆弱性とexploitを理解するために必要な背景情報を提供します。
0x1.1 仮想バランスとInvariant
yETHプロトコルは、Ethereum Liquid Staking Tokens (LSTs)向けのAutomated Market Maker (AMM)です[3]。影響を受けたyETH weighted stableswap poolは複数のLSTを1つのプールに集約します。ユーザーはLSTをデポジットし、プールのシェアトークンとしてyETHを受け取ります。
各LSTはステークされたETHを表し、時間とともに報酬が積み上がるため、ベースとなるETHに対する交換レートが変化します。会計を統一するために、プールは各資産に対して仮想バランス を定義します:オンチェーンバランス × 交換レート。これにより、すべての資産がbeacon-chain ETHの単位に正規化されます。すべての仮想バランスの合計は と表記されます。
プールには8つの資産(インデックス0-7)が含まれ、それぞれに指定されたウェイト があります:
プールの状態は、weighted StableSwap形式のinvariant[4]によって支配されます:
ここで:
- はinvariantスケールであり、このプールのyETH総供給量に直接等しくなります。プールが完全にバランスしている場合、 となります。
- は加重積の項であり、 として定義されます。ここで は資産 i のウェイト、 です。
- は増幅係数で、単一のプロトコルパラメータです( ではありません)。 はこの係数の 乗を表し、 はこのプールにおける資産数(8)です。これは、constant-sum(均衡付近)とconstant-product(極端な状態)の間の曲線の形状を制御します。
重要な特性: には閉形式の解がありません。数値的に解く必要があります。そのソルバーである_calc_supply()が、演算上の脆弱性が存在する場所です。
0x1.2 Invariantソルバー
プロトコルは、最大256ラウンドに制限された固定点反復によって を再計算します。このアルゴリズムはコード上で_calc_supply()として実装されています(0x2.1節で詳述)。各ラウンドは3つのステップを実行します:
ステップ1: 供給量の推定値を更新する。
ステップ2: 新しい供給量に合わせて積の項を更新する。
ステップ3: 収束をチェックする。
であれば を返し、そうでなければステップ1から繰り返します。
初期値 、、 は初期の反復に影響を与えます。理論的には最終的な収束には無関係ですが、有限の反復回数と固定精度の演算のために、実際には結果に影響を及ぼします。
この実装は固定精度の整数演算を使用しています:除算は切り下げられ、減算はアンダーフローに対する保護がありません。通常のプール状態下では、中間値は安全な範囲内に留まります。極端なプール状態下では、そうではありません。0x2.1節ではこれらの失敗モードを詳細に分析します。
0x1.3 3つのインターフェースとInvariantソルバー
プロトコルは、加重積の項 (コード上では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()関数は、コントラクト内に記録されている交換レートが外部のレートと一致しているかをチェックします。不一致が検出された場合、仮想バランスの再計算をトリガーし、その後invariantを更新します。そうでなければ、更新処理はスキップされます。
各インターフェースがπをどう処理するか
これら3つの関数は、invariantへの影響の仕方に基づいて、2つのカテゴリーに分類できます。具体的には、add_liquidity()とupdate_rates()は仮想バランスの非比例的な変化を許可するため、供給量 と積 の反復的な再計算が必要です。対照的に、remove_liquidity()は流動性を比例的に引き出すため、反復計算を必要としません。
積をゼロから計算する基本式は次の通りです:
ここで、 は供給量、 は資産 のウェイト、 はその仮想バランス(コード上ではvb[i]として保存)、 は資産数です。この形式は、0x1.1節の定義と代数的に等価であり、 が積の中に分配されています。
add_liquidity()には2つのパスがあります(コードは0x2.2節に示します):
- ブートストラップパス(
prev_supply == 0の場合):等式(4)を用いてvb_prodをゼロから計算します。このパスがデプロイ後にアクセス可能なままであることが、0x2.2節で議論する状態管理の脆弱性です。 - 通常パス(
prev_supply > 0の場合):計算プロセスは2つのステップに分かれています:-
a) 古い仮想バランスと新しい仮想バランスの比率に基づく増分更新を使用します:
ここで と は、デポジット前後の仮想バランスです。
-
b) この推定値を入力として
_calc_supply()を呼び出すことで、正確な値を反復的に調整し、invariant と の正確な値を再計算します。
-
-
update_rates()は交換レートが変化した際にトリガーされ、対応する資産の仮想バランスが更新されます。その後の計算フローはadd_liquidity()の通常パスに従います。すなわち、invariantが反復的に再計算されます。さらに、新たに計算された供給量に基づいて、コントラクトはyETHをミントまたはバーンし、流動性の供給量が更新後の仮想バランス状態と一致するようにします。 -
remove_liquidity()は、各仮想バランスを比例的に減少させた後、常に等式(4)を用いてvb_prodをゼロから計算します。
0x2 根本原因分析
2つの脆弱性が悪用されましたが、それぞれ異なる役割と影響を持っていました。主要な根本原因は、invariantソルバー_calc_supply()における計算上の欠陥であり、2つの失敗モードがありました:(A)切り下げが積の項をゼロにする可能性があり、invariantをconstant-sumモデルに退化させ、過剰なLPミント(供給量のインフレーション)を引き起こす;(B)アンダーフロー条件も供給量をインフレーションさせる可能性がある。フェーズ2(約$8.1M)で使用されたのは失敗モードAのみでした。失敗モードBは二次的な脆弱性と相互依存していました。
二次的な根本原因は、状態管理の欠陥です:プールの初期化分岐が到達可能なままでした。フェーズ2が供給量をゼロに追い込んだ後、失敗モードBがブートストラップパスと組み合わさり、追加で約$0.9Mの損失(フェーズ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) # Step 1: D[m+1]
r = unsafe_div(unsafe_mul(r, sp), s) # Step 2: π update (per asset)
この関数内には、異なる行を対象とし、異なる影響を生み出す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が一度ゼロになると、それ以降のすべての反復でゼロのままになります。積の項 は永続的に崩壊してしまいます。
一般的な誤帰属として、この失敗が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演算で減算を実行するため、負の結果は巨大な正の整数(に近い値)にラップします。このラップされた値は除算とその後の反復を通じて伝播し、法外に大きな供給量推定値を生成し、プロトコルはそれを実際のyETHトークンとしてミントします。
一般的な主張では、このようなアンダーフローが特定の供給量操作ステップの2回目の反復で発生すると述べられています。0x4節では、この主張が正しくないことを示します:実際に供給量をインフレーションさせるアンダーフローは、全く異なる文脈(攻撃のフェーズ3)で発生します。
3. これらの失敗が攻撃をどのように可能にするか
これら2つの失敗モードは、攻撃の異なるフェーズで動作し、異なる利益への貢献をもたらします:
-
失敗モードA(フェーズ2、約$8.1M):攻撃者が深刻に不均衡なプールにデポジットすると、積の項がゼロになり、
_calc_supply()がインフレーションした供給量を返します。プロトコルは攻撃者に過剰にyETHをミントします。この失敗モード単独で、ブートストラップパスの介入なしに、攻撃者がyETH weighted stableswap poolのLST資産を枯渇させることを可能にしました。 -
失敗モードB(フェーズ3、約$0.9M):供給量がゼロに枯渇した後、ブートストラップパスはダストのデポジットから大きな積の項を再計算し、減算がアンダーフローを引き起こします。プロトコルは天文学的な量の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 の場合、この関数は保存された状態をバイパスし、等式(4)(0x1.3節)を用いて_calc_vb_prod_sum()を介してvb_prodとvb_sumをゼロから再計算します。このブートストラップ分岐は、プール初期化時の一度限りの使用を意図していましたが、最初のデポジット以降に永続的にゲートされることはありませんでした。
総供給量がゼロに(バーンと引き出しの何らかの組み合わせによって)追い込まれると、この分岐は再度到達可能になります。このパスに再突入する攻撃者は、_calc_supply()に渡される初期条件を制御し、通常のプール運用中には決して発生しないパラメータの下で、上述の演算上の失敗をトリガーする可能性があります。
これは既知の脆弱性パターンです。2023年8月、Balancer V2の事件も同様に、内部レートをリセットするために供給量をゼロに追い込むことに依存しており、攻撃者が人為的に有利なパラメータで初期化ロジックに再突入することを可能にしました[6]。デプロイされたプールが初期状態に戻される可能性があるかどうか、そしてそうなった場合にどのようなinvariantが保持されるかは、プロトコル設計者が明示的に取り組むべき問題です。
0x3 攻撃分析
このexploitは、攻撃トランザクション[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 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:供給量をゼロに崩壊させる(約$8.1M)
目標: invariantの積をゼロに追い込み、その後yETHの供給量をゼロに枯渇させる。このフェーズは主要な脆弱性(安全でない演算)のみを悪用し、総損失の約90%を引き起こしました。
このフェーズでは、次の5ステップのサイクルを3回繰り返し使用します:
add_liquidity()を介して積を破壊する;add_liquidity()を介して修正のための前提条件を確立する;- 0 yETHで
remove_liquidity()を実行し、積をリセットする; update_rates()を介して供給量を修正する;remove_liquidity()を介して資産を引き出す。
以下の図は、5ステップのサイクルが3回繰り返されていることが明確に見えるトランザクショントレースを示しています:
1. add_liquidity()を介して積を破壊する
攻撃者は、高ウェイトの資産(インデックス0、1、2、4、5:sfrxETH、wstETH、ETHx、rETH、apxETH)を、それぞれ現在の仮想バランスの約3倍の量でデポジットします。
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()を実行し、積をリセットする
攻撃者はremove_liquidity()を数量0で呼び出します。トークンは何も引き出されませんが、この関数は現在のプール状態から等式(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バランスは無傷のままです。
レートの不一致自体は利益の源泉ではありません。それは純粋にトリガー機構として機能します。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つのサイクルにわたって抽出され、合計で約$8.1Mに達します。
Rebaseの目的
このトレース(1回目のサイクルと2回目のサイクルの間)には、OETHVaultProxy.rebase()の呼び出しも示されており、これがOETHのrebaseをトリガーします:WOETHコントラクトが保有するOETHバランスが増加し、WOETHの実効交換レートが上昇します。この「保存された」レートの不一致が、2回目のサイクルのステップ4を再び可能にするものです:update_rates()が最終的に呼び出されると、不一致が検出され、_calc_supply()がトリガーされます。
ゼロまで枯渇させる
この5ステップのサイクルを3回繰り返した後、攻撃者はプールの総供給量を、自身が保有するyETHの量より下まで減少させています。残りの供給量に対する最後のremove_liquidity()の呼び出しが、それをゼロにまで枯渇させます。
プールは今、供給量ゼロ、積ゼロ、vb_sumゼロの状態を保持しています。この退化状態は、事前にデポジットが行われたプールは決して初期化されていない状態に戻ることはないという暗黙の設計上の前提に違反しています。
0x3.3 フェーズ3:ゼロ供給量を利用した追加利益(約$0.9M)
目標: 退化したプール状態から膨大な量の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 poolで約1,097e18 WETHにスワップし、そのWETH準備金を枯渇させます。フェーズ1で費やした800e18 WETHを考慮すると、純利益は約$0.9Mでした。
フェーズ2で抽出された約$8.1MのLST資産と合わせると、攻撃者はフラッシュローンを返済した後、合計で約900万ドルの利益を得ています。
ソースの資金や宛先アドレスを含む詳細な資金フロー分析は、他の公開されている分析(例:[2])で取り扱われており、本記事の範囲外です。
0x4 誤解の訂正
この事件について公開されている分析の多くは、攻撃者が前提条件をどのように整えたのかを完全に説明せずに、演算上の症状に焦点を当てています。特定の2つの主張は訂正が必要です。
0x4.1 主張:「pow_up()とpow_down()の丸め方の不一致がinvariantを破壊する」
一般的な解釈では、一部のコードパスでpow_up()が使用され、他のパスではpow_down()が使用されていることを根本原因とし、この方向性の不一致が悪用可能な不整合を引き起こしていると主張しています。
我々はこれを直接テストしました:コントラクトを修正してpow_down()を統一的に使用するようにし(すべてのpow_up()呼び出しを置き換え)、Foundryで完全な攻撃シミュレーションを再実行しました。exploitは同一に成功しました。 積は依然としてゼロに崩壊し、供給量は依然として枯渇し、アンダーフローは依然としてインフレーションしたミントを生み出しました。
ゼロ積の状態を可能にする丸め処理は、反復ループ内の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()を1回の反復ごとに追跡します:
======= _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のexploitは、非対称な影響を持つ2つの脆弱性を含んでいました。_calc_supply()における安全でない演算が主要な根本原因でした:その切り下げの失敗(失敗モードA)は、フェーズ2単独で約$8.1Mの損失を独立して可能にしました。無効化されていないブートストラップパスは二次的な脆弱性でした。アンダーフローの失敗(失敗モードB)と組み合わさることで、フェーズ2が既に供給量をゼロに枯渇させた後にのみ、フェーズ3で追加の約$0.9Mを可能にしました。この損失の内訳は、フェーズ2とフェーズ3の利益を分離していない他の公開レポートと、本分析を区別するものです。
公式のポストモーテム[2]は5つの根本原因を特定しています。我々はこれらを2つの欠陥(公式の#1と#5を統合した安全でない演算;無効化されていないブートストラップパスとしての#4)と、2つのアーキテクチャ上の前提条件(#2 非対称なΠ処理;#3 POLによって可能となったゼロ供給量状態)として再分類します。この区別は:欠陥は設計意図に違反する実装上のバグ(ソルバーはゼロの積やアンダーフローを生成すべきではない)であり、前提条件は意図した通りに機能する設計上の選択でありながら、欠陥と組み合わさった際に悪用可能な攻撃対象領域を生み出すものです。
推奨事項
- invariantソルバーにおけるチェック付き演算。 ガス効率のコストを払ってでも、アンダーフロー/オーバーフローで明示的にrevertする
safe_divとsafe_subを使用する。このソルバーは最大256回の反復しか実行せず、ガスのオーバーヘッドはセキュリティリスクに比べて無視できるほど小さい。 - 中間値の範囲チェック。 反復間で積の項が正常な範囲内に留まっていることを検証する。積がゼロに落ちる、または供給量の推定値が反復間で桁違いに増加することは、退化状態を示唆する。
- 不均衡の制限。 いずれかの資産の仮想バランスと、そのウェイトに応じた目標バランスとの間の最大偏差を強制する。これにより、フェーズ1が前提条件を作り出すことを防止できる。
- invariantの単調性チェック。
_calc_supply()が値を返した後、新しい供給量が変化の方向と一致していることを検証する(流動性の追加は供給量を減少させるべきではなく、レートの更新は10倍もの変化を生み出すべきではない、など)。 - 初期化パスの永続的な無効化。 プールの最初のデポジット後、
prev_supply == 0のブートストラップ分岐をゲートし、再突入できないようにする。これにより、フェーズ3を完全に防止できる。 - ゼロ供給量状態の防止。 プールが非ゼロのバランスを保持している間、プロトコルレベルのバーン(POLやステーキングコントラクトから)が総供給量をゼロに減少させないようにする。最小供給量のフロアを設定することで、ブートストラップの再突入を可能にする退化状態への移行をブロックできる。
- リアルタイムの異常検知。 異常な状態遷移(積の項がゼロに落ちる、供給量が桁違いに変化する、短時間での追加/削除サイクルの繰り返しなど)を監視し、損失が積み重なる前にアラートやサーキットブレーカーをトリガーする。
References
- Yearn Finance incident announcement
- Yearn Security post-mortem
- yETH documentation
- yETH whitepaper: invariant derivation
- Attack transaction on Phalcon Explorer
- BlockSec: Analysis of the Balancer boosted pool incident (August 2023)
About BlockSec
BlockSecは、フルスタックのブロックチェーンセキュリティおよび暗号資産コンプライアンスプロバイダーです。私たちは、プロトコルやプラットフォームのライフサイクル全体にわたって、コード監査(スマートコントラクト、ブロックチェーン、ウォレットを含む)の実施、攻撃のリアルタイムでの阻止、インシデントの分析、不正資金のトレース、AML/CFT義務の遵守を、顧客が実現できるよう支援するプロダクトとサービスを構築しています。
BlockSecは、権威ある学会において複数のブロックチェーンセキュリティ論文を発表し、DeFiアプリケーションにおけるいくつかのゼロデイ攻撃を報告し、複数のハッキングをブロックして2000万ドル以上を救済し、数十億ドル相当の暗号資産を保護してきました。
-
公式ウェブサイト: https://blocksec.com/
-
公式Twitterアカウント: https://twitter.com/BlockSecTeam



