Back to Blog

#5 Yearn Financeインシデント:インバリアント・ソルバーにおける安全でない算術処理の代償

Code Auditing
February 11, 2026
30 min read

2025年11月30日、Yearn FinanceのyETH Weighted Stable Poolが攻撃を受け、900万ドル以上の資金が流出しました[1]。根本原因は、不変量ソルバー _calc_supply() 内の安全ではない算術演算と、初期化ロジックへの再入を許容してしまった無効化されていないブートストラップパスでした。公式の事後検証報告書[2]には5つの根本原因が挙げられていますが、我々はこれらを2つの欠陥(前述の脆弱性)と、それらの欠陥が存在する場合にのみ悪用可能となる2つのアーキテクチャ上の前提条件として再分類します。他の公開されている分析は、攻撃トランザクションの詳細なステップに焦点を当てたものが多いです。高レベルな要約とトランザクションレベルの詳細の間には、「なぜ、そしてどのように攻撃が機能したのか」という隙間が残されています。本稿では、FoundryおよびPythonシミュレーションを用いて、キーとなる値がどのように段階的に変化し、どこで計算が破綻したのかを追跡することで、その隙間を埋めます。

本分析は、主に以下の3つの貢献をなすものです。

  1. 脆弱性別の損失内訳。 2つの脆弱性は相互依存していません。安全ではない算術演算のみで約810万ドルの損失(全体の90%)が発生し、ブートストラップパスにより追加で約90万ドルの損失が発生しました。これにより、どの脆弱性が主因であったかが明確になります。
  2. 根本原因の再分類。 公式レポートの5つの根本原因は、実装上の2つの欠陥(5つの項目のうち3つを集約)と、それらの欠陥と組み合わさった場合にのみ悪用可能となる2つのアーキテクチャ上の前提条件として理解する方が適切です。
  3. 技術的な誤解の訂正。 「2回目の反復でアンダーフローが発生し、積項がゼロになる」という主張は誤りです。シミュレーションの結果、積がゼロになるのは除算時の切り捨てによるものであり、アンダーフローによるものではないことが示されました。また、利益を生むアンダーフローは全く別のフェーズで発生しています。

本稿の残りの部分は次のように構成されています。0x1セクションでは、yETHのWeighted Stable Poolとその不変量ソルバーについての背景を説明します。0x2セクションでは、2つの根本原因とその障害モードを分析します。0x3セクションでは、3段階の攻撃プロセスを詳細に追跡します。0x4セクションでは、2つの一般的な誤解をシミュレーションの証拠に基づいて訂正します。0x5セクションでは、推奨事項を述べて結論とします。

TL;DR(要約)

根本原因: 2つの脆弱性が悪用されましたが、その影響は非対称です。

  1. _calc_supply() 内の安全ではない算術演算(主因、約810万ドル)。プール状態からyETH供給量を再計算する関数には2つの算術的な不備があります。unsafe_div() での切り捨てにより内部的な積項がゼロになり得ること、そして unsafe_sub() でのアンダーフローにより中間値が極めて大きな正の整数にラップし得ることです。この脆弱性単体で、yETH Weighted Stableswapプールを枯渇させるには十分でした。
  2. 無効化されていないブートストラップパス(副因、約90万ドル)。prev_supply == 0 の初期化ブランチは、デプロイ後に恒久的にゲートされていませんでした。最初の脆弱性によって供給量がゼロまで枯渇させられた後、このパスが到達可能となり、yETH/WETH Curveプールからの追加利益が可能となりました。

安全ではない算術演算の脆弱性のうち、フェーズ2で使用されたのは切り捨てによる障害(障害モードA)のみです。アンダーフローによる障害(障害モードB)はブートストラップパスと相互依存しており、これらが組み合わさってフェーズ3を可能にしました。

攻撃者は3段階のシーケンスを実行しました:

  1. 準備: add/removeのサイクルを繰り返すことでプール内の資産配分を歪め、バーチャルバランスに極端な不均衡を作り出しました。
  2. 供給の操作: _calc_supply() 内での切り捨てを悪用して積項をゼロに崩壊させ、一連のmint/burn操作を通じて総供給量をゼロまで枯渇させました。プールのすべてのLSTは引き出され、その後にWETHへスワップされたことで、約810万ドルの損失が発生しました。
  3. 利益の抽出: ダスト(微量)デポジットを用いてブートストラップパス(prev_supply == 0)をトリガーし、_calc_supply() 内のアンダーフローを悪用して約2.35×10⁵⁶ yETHをmintしました。これを用いて 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プール(LSTを保有するYearnプール、約810万ドル損失)と、yETH/WETH Curveプール(Curve Stableswapプール、約90万ドル損失)です。中核となる脆弱性はyETH Weighted Stableswapプールに存在します。このセクションでは、脆弱性と攻撃を理解するために必要な背景を説明します。

0x1.1 バーチャルバランスと不変量

yETHプロトコルは、イーサリアムのリキッドステーキングトークン(LST)のための自動マーケットメーカー(AMM)です[3]。影響を受けた yETH Weighted Stableswapプール は、複数のLSTを1つのプールに集約します。ユーザーは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積項(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) であり、単一のプロトコルパラメータです(A×fA \times f ではありません)。Afn\mathit{Af}^{\,n} はこの係数を nn 乗(このプールでは8乗)したものを表します。これは定数和(均衡に近い状態)から定数積(極端な状態)の間での曲線形状を制御します。

重要な特性として、DD には閉形式(closed-form)の解が存在しません。数値的に解く必要があります。そのソルバーである _calc_supply() こそが、算術的な脆弱性が存在する場所です。

0x1.2 不変量ソルバー

プロトコルは定点反復法を用いて DD を再計算し、最大256回で打ち切ります。このアルゴリズムはコード上では _calc_supply() として実装されています(詳細は0x2.1セクション)。各反復では次の3つのステップが実行されます:

ステップ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、$ \pi_0\sigma$ は初期の反復に影響を与えます。理論上は最終的な収束値とは無関係ですが、反復回数の制限や固定精度算術の影響で実際の結果には影響を及ぼします。

実装には固定精度の整数演算が使用されています。除算は切り捨てられ、減算はアンダーフローに対するガードがありません。通常のプール状態であれば、中間値は安全な範囲に収まりますが、極端なプール状態ではそうではありません。0x2.1セクションで、これらの障害モードを詳細に分析します。

0x1.3 3つのインターフェースと不変量ソルバー

プロトコルは、加重積項 π\pi(コード内では vb_prod として保存)を更新することでプール状態に影響を与える3つのエントリーポイントを公開しています:

インターフェース 動作内容 _calc_supply() をトリガーするか?
add_liquidity() 任意の比率で資産を預け入れる はい
update_rates() 外部の交換レートを更新する はい
remove_liquidity() 重みに応じて比例的に資産を引き出す いいえ(比例スケーリングを使用)

非対称性が重要です:add_liquidity()任意の比率 での預け入れを許可するためプールを大幅に歪めることができますが、remove_liquidity() は常に 比例的 に引き出します。そのため、add/removeのサイクルを繰り返すことで、プールを次第に均衡の取れていない状態へ追い込むことが可能です。

レートを更新する仕組み

前述の通り、バーチャルバランス (xix_i) はLSTの交換レートに基づいて計算されます。そのため、レートを更新する方法を理解することが重要です。

具体的には、add_liquidity() 関数と update_rates() 関数は内部関数 _update_rates() を介してレートを更新できますが、remove_liquidity() 関数はレートの同期を行いません。

  • add_liquidity() は重要な操作を実行する前に _update_rates() を呼び出し、資産の交換レートが最新の状態に同期されていることを確認します。
  • update_rates() は手動でのレート更新を可能にします。

_update_rates() 関数は、コントラクト内に記録された交換レートが外部レートと一致しているかを確認します。不一致が検出された場合、バーチャルバランスの再計算をトリガーし、続いて不変量を更新します。一致していれば更新プロセスはスキップされます。

各インターフェースによる π\pi の処理

不変量への影響に基づき、これらの3つの関数は2つのカテゴリに分類できます。具体的には、add_liquidity()update_rates() はバーチャルバランスの非比例的な変更を許容するため、供給量 DD と積 π\pi の反復的な再計算が必要となります。対照的に、remove_liquidity() は流動性を比例的に引き出すため、反復計算を必要としません。

加重積項 π\pi をゼロから計算するための基本式は以下の通りです:

π=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() には2つのパスがあります(コードは0x2.2セクションを参照):
  • ブートストラップパスprev_supply == 0 の場合):式 (4) を使用して vb_prod をゼロから計算します。デプロイ後もこのパスがアクセス可能なままであったことが、0x2.2セクションで議論する状態管理の脆弱性です。
  • 通常パスprev_supply > 0 の場合):計算プロセスは2つのステップに分かれます:
    • 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をmintまたはburnします。

  2. remove_liquidity() は、各バーチャルバランスを比例的に減少させた後、式 (4) を使用して常に vb_prod をゼロから計算します。


0x2 根本原因分析

2つの脆弱性が悪用され、それぞれ異なる役割と影響を及ぼしました。根本的な主因は、不変量ソルバー _calc_supply() 内の計算上の不備であり、これには2つの障害モードがありました:(A) 切り捨てにより積項がゼロになり得ること(不変量が定数和モデルへと退化し、過剰なLP mintが発生=供給のインフレ)、(B) アンダーフローが発生し、供給量がインフレし得ること。フェーズ2(約810万ドル)では障害モードAのみが悪用されました。障害モードBは、2つ目の脆弱性と相互依存しています。

根本的な副因は状態管理の欠陥です。プールの初期化分岐にアクセス可能なままであったことです。フェーズ2によって供給量がゼロまで追い込まれた後、障害モードBがブートストラップパスと結びつき、追加の損失(約90万ドル、フェーズ3)を可能にしました。

0x2.1 _calc_supply() 内の安全ではない算術演算(主因)

図2は、_calc_supply() の実装と0x1.2セクションの数学的手順をマッピングしたもので、以下に分析する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: π更新(資産ごと)

この関数内には、異なる行を標的とし、異なる影響を生み出す2つの算術障害モードが存在します。どちらも、トリガーするにはプールが極端な状態にあることが必要です。

通常の状況では反復は正しく動作します:l - s * r は適度な正の値であり、反復は数回で収束します。

1. 障害モードA:切り捨てが積をゼロにする

ステップ2において、積は資産ごとに次のように更新されます:

r = unsafe_div(unsafe_mul(r, sp), s)   # r = r * sp / s

unsafe_div() は整数除算を行うため、常に切り捨てが行われます。プールが深刻な不均衡状態にあり、sps よりも大幅に小さい場合(操作された大口預け入れ後に発生)、分子の 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() が呼び出されると、初期化分岐は式(4)(0x1.3セクション)を使用して新しい値を計算するために _calc_vb_prod_sum() を呼び出します。微量の預け入れでは vb_sum は極小(例:16)ですが、ほぼゼロのバランスで割って高次乗を行うことで、積は不釣り合いに大きな値(例:約9.13e20)に増幅されます。s * rl を超過すると、減算の結果は数学的に負となります。

unsafe_sub()チェックなしの uint256 算術 で減算を行うため、負の結果は非常に大きな正の整数(22562^{256} に近い値)にラップされます。このラップされた値が除算と後続の反復を経て伝播し、不当に大きな供給量推定値が生成され、プロトコルがこれを実際のyETHトークンとしてmintしてしまうのです。

供給量操作ステップの「2回目の反復でアンダーフローが発生する」という主張も一般的ですが、0x4セクションでこれが誤りであることを示します。実際のインフレを引き起こすアンダーフローは、攻撃のフェーズ3という全く異なる文脈で発生します。

3. これらの障害がどのように攻撃を可能にしたか

これら2つの障害モードは攻撃の異なるフェーズで動作し、利益への貢献度も異なります:

  • 障害モードA(フェーズ2、約810万ドル):攻撃者が深刻に不均衡なプールに預け入れると、積項がゼロになり、_calc_supply() がインフレした供給量を返します。プロトコルは過剰なyETHを攻撃者にmintします。この障害モードだけで、ブートストラップパスに関与することなく、yETH Weighted StableswapプールからLST資産を枯渇させることができました。

  • 障害モードB(フェーズ3、約90万ドル):供給量がゼロまで枯渇した後、ブートストラップパスがダストデポジットから大きな積項を再計算し、減算でアンダーフローが発生します。プロトコルは天文学的に大きな量のyETHをmintし、攻撃者はこれを使って別の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 の場合、関数は保存された状態をバイパスし、式 (4)(0x1.3セクション)を使用して vb_prodvb_sum をゼロから再計算します。このブートストラップ分岐はプールの初期化中に一度だけ使用される意図でしたが、最初の預け入れ後も恒久的にゲートされることはありませんでした。

(burnや引き出しの組み合わせによって)総供給量をゼロまで追い込むことができれば、この分岐は再び到達可能となります。このパスに再入した攻撃者は、_calc_supply() に渡される初期条件を制御下に置き、通常のプール運用では決して発生しないパラメータ下で上述の算術障害をトリガーする可能性があります。

これは既知の脆弱性パターンです。2023年8月、Balancer V2のインシデントも同様に、内部レートをリセットするために供給量をゼロに追い込むことに依存しており、攻撃者は不当に有利なパラメータで初期化ロジックに再入することを可能にしました。デプロイ済みのプールが初期状態に戻され得るか、そして戻された際どのような不変量が維持されるべきかは、プロトコル設計者が明示的に対処すべき問題です。


0x3 攻撃分析

エクスプロイトは、3つのフェーズで構成される調整された攻撃トランザクションシーケンスで展開されます[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() はプール重みに応じて 比例的に 資産を引き出します(上の図の赤い長方形で強調)。add→removeのサイクルを繰り返すことで、選択した資産のみを預け入れつつ、すべての資産を比例的に引き出すことで、攻撃者は次第にプールを深刻な不均衡状態へと追い込みます:

資産 重み 変化
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ステップのサイクルが使用されます:

  1. add_liquidity() による積の破損;
  2. add_liquidity() による補正のための前提条件の構築;
  3. 0 yETHでの remove_liquidity() による積のリセット;
  4. update_rates() による供給量の補正;
  5. remove_liquidity() による資産の引き出し。

下の図はトランザクショントレースを示しています。5ステップのサイクルが3回繰り返されているのが明確に視認できます:

1. add_liquidity() による積の破損

攻撃者は、現在バーチャルバランスの約3倍である高重み資産(インデックス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をmintします。

2. add_liquidity() による補正のための前提条件の構築

攻撃者は資産インデックス3(枯渇している低重み資産 cbETH)に対して、プールの現在の残高の約6.5倍をシングルサイドで預け入れます。これにより得られるyETHはわずかですが、プールをリバランスさせることで、次の 反復が激しく振動しないようにします。

このステップがないと、3での積が非ゼロにリセットされた後でも、4で生じる計算の反復が激しい不均衡によって暴力的な振動を起こし、積が再びゼロになってしまいます。我々のFoundryシミュレーションでも、このステップ2をスキップすると、4での補正が失敗することを確認しました。

3. 0 yETHでの remove_liquidity() による積のリセット

攻撃者は0 yETHで remove_liquidity() を呼び出します。トークンは引き出されませんが、関数は式(4)(0x1.3セクション)を使用して現在のプール状態から vb_prod再計算 します。バーチャルバランスは非ゼロであるため、これにより非ゼロの積(約9.09e19)が算出され、破損していたゼロの値が上書きされます。

4. update_rates() による供給量の補正

攻撃者は資産インデックス6 (WOETH) または 7 (mETH) の update_rates() を呼び出します。前回の更新から交換レートが変化していれば、関数は復元された(非ゼロの)積を用いて _calc_supply() をトリガーします。今度は反復が正しく収束し、現在のインフレしている値よりもはるかに低い供給量が得られます。この差分はyETHのステーキングコントラクトから burn されます。公式発表[2]によれば、これはプロトコル所有の流動性(POL)に該当し、burnは攻撃者の保有資産ではなく、プロトコルのポジションを減少させます。この非対称性が極めて重要です:各サイクルで総供給量が減る一方で、攻撃者のyETH残高は維持されるためです。

レートの不一致自体は利益の源泉ではなく、あくまで トリガー機構 として機能します。3つのプールインターフェースのうち、add_liquidity()update_rates() のみ _calc_supply() を呼び出し、remove_liquidity() は呼び出しません。ステップ3で非ゼロの積が復元された後、攻撃者は追加資産を預け入れずに _calc_supply() をトリガーする必要があります。古いレートで update_rates() を呼び出すことで、追加コストなしに正確にこれを達成します。

これが攻撃の微妙な側面を説明しています:準備フェーズ(フェーズ1)の間、攻撃者は意図的にWOETHとmETHの流動性追加を避けました。もしその時点でレートが更新されていたら不一致は生じず、このステップでの update_rates()_calc_supply() をトリガーしなかったはずです。

5. remove_liquidity() による資産の引き出し

サイクルごとに、攻撃者は remove_liquidity() で資産を引き出します。

利益はどのように抽出されるのか

利益のメカニズムは以下の通りです:ステップ1で、攻撃者はLSTを預け入れ、過剰mintされたyETH(破損した積のため)を受け取ります。ステップ4で供給量が適切に補正されると、過剰なyETHが攻撃者ではなくPOL(ステーキングコントラクト)からburnされます。ステップ5で、攻撃者は自分のyETH保有量に応じた比例分のLSTを退去させます。POLがburnを引き受ける一方で攻撃者のyETH残高が変わらないため、結果的に預け入れた以上のLSTを引き出すことになります。これが3サイクル繰り返され、計約810万ドルの利益となります。

リベースの目的

トレース(1回目と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をmintし、実際の資産とスワップする。攻撃の10%は、この副因(ブートストラップパス)と障害モードB(アンダーフロー)の相互依存によって発生しました。

1. アンダーフローによるmint

総供給量がゼロになった状態で、攻撃者はダスト金額の預け入れ(残高 [1, 1, 1, 1, 1, 1, 1, 9])で add_liquidity() を呼び出します。

prev_supply == 0 であるため、コードは0x2セクションで説明したブートストラップパスに侵入します:保存された状態をバイパスし、式(4)(0x1.3セクション)を用いて vb_prodvb_sum をゼロから再計算した後、_calc_supply() に渡します。これが悪用された2つ目の脆弱性です:攻撃者はプールを未初期化状態に戻し、ソルバーに入力される初期条件の制御権を奪ったのです。

すべてのバーチャルバランスがダストレベル(交換レートが約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 となり、プロトコルはこの全量を攻撃者にmintします。このアンダーフローは、フェーズ2で総供給量がゼロに駆動された場合にのみ可能です。通常の状態で l > s * r は常に成立し、減算は安全です。

2. 実際の資産とのスワップ

攻撃者は過剰mintしたyETHの一部をyETH-WETH Curveプール内で約1,097e18 WETHとスワップし、そのWETH準備金を枯渇させました。フェーズ1で費やされた約800e18 WETHを考慮すると、純利益は約90万ドルです。

フェーズ2で得た約810万ドルのLST資産と合わせると、フラッシュローンの返済後、攻撃者は合計で約 900万ドル の利益を上げました。


0x4 誤解の訂正

このインシデントに関する多くの分析は、攻撃者がどのように前提条件を整えたかを完全に説明することなく、算術的な兆候だけに焦点を当てています。以下、2つの主張を訂正します。

0x4.1 主張:「pow_up()pow_down() の丸め不一致が不変量を壊す」

一般的な解釈では、一部のコードパスで pow_up() を、他で pow_down() を使用していることが根源であるとし、方向性の不一致が悪用可能な不整合を招くと主張されます。

我々はこれを直接テストしました:すべての pow_up() 呼び出しを pow_down() に置き換えてコントラクトを修正し、Foundryで完全な攻撃シミュレーションを再実行しました。 エクスプロイトは全く同じように成功しました。 積は崩壊し、供給量は枯渇し、アンダーフローによるインフレmintは発生しました。

ゼロ積状態を可能にする丸めは、反復ループ内の 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)と組み合わさることでフェーズ3において約90万ドルの損失を可能にしましたが、それはフェーズ2が既に供給量をゼロまで追い込んだ後のことでした。この損失の内訳こそが、フェーズ2と3の利益を区別していない他の公開レポートと本分析を大きく分かつ点です。

公式の事後検証[2]には5つの根本原因が挙げられていますが、我々はそれらを 2つの欠陥(公式の#1と#5を集約した安全ではない算術、#4の無効化されていないブートストラップパス)と 2つのアーキテクチャ上の前提条件(#2の非対称なΠ処理、#3のPOLが許容した供給量ゼロ状態)に再分類します。欠陥とは設計意図に反する実装上のバグ(ソルバーはゼロ積やアンダーフローを及ぼすべきではない)であり、前提条件とは意図通りに機能していても、欠陥と組み合わさることで攻撃面を作ってしまう設計選択です。

推奨事項

  • 不変量ソルバーでのチェック付き算術の使用。 ガス効率を犠牲にしてでも、アンダーフロー/オーバーフロー時に明示的にrevertする safe_div および safe_sub を使用してください。ソルバーは最大256回の反復ですので、ガスのオーバーヘッドはセキュリティリスクに比べれば無視できます。
  • 中間値に対する境界チェック。 反復間で積項が健全な範囲内に留まっているかを検証してください。積がゼロに落ちたり、反復間で供給量の推定値が桁違いに増減したりすることは、退化状態の予兆です。
  • 不均衡制限。 各資産のバーチャルバランスと重みに応じたターゲット残高との間に、最大乖離を設けてください。これにより、フェーズ1のような前提条件の構築を防げます。
  • 不変量の単調性チェック。 _calc_supply() の返り値が変化の方向と一致しているか検証してください(流動性追加で供給量が減る、レート更新で10倍の変化が起こるなどはあってはなりません)。
  • 初期化パスの恒久的な無効化。 プールの最初の預け入れ後、prev_supply == 0 のブートストラップ分岐を再入不可能なようにゲートしてください。これによりフェーズ3を完全に防げます。
  • 供給量ゼロ状態の防止。 (POLやステーキングコントラクトでの)プロトコルレベルのburnによって、非ゼロの残高を持つプールの総供給量をゼロまで下げられないようにしてください。供給量の下限を設けることで、ブートストラップ再入を可能にする退化状態への移行をブロックできます。
  • リアルタイム異常検知。 状態の異常な遷移(積項がゼロになる、供給量が桁で変化する、短時間でのadd/removeサイクルの繰り返しなど)を監視し、損失が拡大する前にアラートの発報やサーキットブレーカーのトリガーを行ってください。

参照

  1. Yearn Finance インシデント告知
  2. Yearn Security 事後検証報告書
  3. yETH ドキュメント
  4. yETH ホワイトペーパー: 不変量の導出
  5. BlockSec Explorer による攻撃トランザクション
  6. BlockSec: Balancer プールインシデントの分析 (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