Back to Blog

zkLendエクスプロイト事後分析:1000万ドルのフラッシュローン攻撃の詳細解明と誤解の解消

February 20, 2025
10 min read

2025年2月12日、StarkNet上のレンディングプロトコルであるzkLend[1]は、そのアキュムレータメカニズムの巧妙な操作により、約1,000万ドルが不正利用されました。攻撃者はフラッシュローンと丸め誤差を利用して、担保価値を人為的に吊り上げ、プロトコルから他の資産を借り入れて利益を得ました。

しかし、セキュリティの観点からの詳細かつ正確な技術分析は依然として不足しています。他のセキュリティ研究者による既存の分析は貴重な洞察を提供しましたが、特に攻撃分析に関しては、いくつかの誤解が残っています。zkLendが後に公式の事後分析[2]を公開しましたが、これは簡略化された説明であり、詳細な技術分析には欠けています。本ブログでは、このインシデントを明確にするための包括的な調査を提供することを目的とします。

主要なポイント(TL;DR)

  • このインシデントの根本原因は、以下の3つの問題の組み合わせに起因します。

    • 空のマーケット初期化により、任意の資産の預け入れが可能になりました。
    • zkLendのフラッシュローンにおける特定の寄付メカニズムにより、ユーザーの担保残高を動的に調整するスケーリングファクターであるグローバル変数であるアキュムレータの操作が可能になりました。
    • 切り捨てによる精度損失が発生します。従来の除算における精度損失とは異なり、分母は1から始まりますが、非常に大きな値に膨れ上がり、シェアトークンをバーンする際の過小評価を引き起こしました。
  • **攻撃者は他のユーザーが預け入れたwstETHから利益を得たわけではありません。**代わりに、攻撃者は脆弱性を悪用して担保残高を操作し、少量のwstETHを初期資本として利用して担保残高を7,000以上のwstETHまで増加させ、それによって市場から他の資産を借り入れることを可能にしました。

以降のセクションでは、まずzkLendに関する重要な背景情報を提供します。その後、問題点とその関連する攻撃について詳細な分析を行います。

0x1 背景:zkLendのコアプロトコルの理解

zkLendは、担保ローンやフラッシュローンといった一般的なレンディングプロトコルをサポートする、StarkNet上のレンディングプロジェクトです。これらの2つのプロトコルの実装詳細を見ていきましょう。

0x1.1 担保ローン

担保ローンとは、ユーザーが特定の資産をプロトコルに担保として預け入れ、その代わりに他の資産を借り入れるプロセスを指します。担保の価値は、借入可能額を決定するために使用されます。レンディングプロトコルは通常、担保の資産価値を直接保存するのではなく、次の式を使用して計算することを理解しておくことが重要です。

担保残高 = レンディングアキュムレータ * 生の残高

具体的には、lending_accumulatorは各ユーザーの担保価値を動的に調整するスケーリングファクターであり、raw_balanceはユーザーが市場で保持する実際のシェアを表します。raw_balancelending_accumulatorを使用してcollateral_balanceから派生します。

この設計の目的は何でしょうか? それは、プロトコルが担保価値を効率的に管理し、ユーザーが資産を預け入れることを奨励することを可能にします。プロトコルの収益の一部を担保提供者に割り当てることで、lending_accumulatorが増加し、すべてのユーザーの担保価値が比例して同時に増幅されます。

0x1.2 zkLendにおけるフラッシュローン

フラッシュローンとは、ユーザーが通常単一のトランザクション内で、非常に短期間、プロトコルから無担保で資産を借り入れることができるローンの一種です。借り手がローンを返済できない、または指定された条件を満たせない場合、トランザクション全体がロールバックされ、ローンは実行されません。

zkLendのフラッシュローン実装には、独自の寄付メカニズムがあります。具体的には、ユーザーが資産を返済する際、最低限必要な金額を返済するだけでなく、追加の資金を寄付として提供することもできます。プロトコルはこれらの寄付された資金を追跡し、それに応じてlending_accumulatorを更新します。このプロセスはthesettle_extra_reserve_balance()関数で実装されています。lending_accumulatorを更新するための式は次のとおりです。

新しいアキュムレータ = (準備金残高 + 総負債 - 財務省への金額) / zトークン供給量

  • reserve_balance: コントラクトに保持されている基盤トークン(例:wstETH)の総量。これには、ユーザーによって寄付されたトークンの量が含まれます。
  • totaldebt: すべての借入ユーザーの総負債。
  • amount_to_treasury: プロトコルの収益額。
  • ztoken_supply: シェアトークン(例:zwstETH)の総供給量。ユーザーがwstETHを預け入れると、zkLend zトークンコントラクトは同量のzwstETHを発行します。

zkLendのコアプロトコルを理解した上で、次に攻撃者がlending_accumulatorraw_balance変数を操作して担保資産をどのように操作したかを正式に説明します。

0x2 攻撃分析

攻撃者は、zkLendコントラクトの以下のメカニズムと脆弱性を悪用して、担保の価値を操作しました。

  • lending_accumulatorの操作
    • 空のマーケット: 攻撃前、wstETHトークンのzkLendマーケットは空であり、操作に完璧な条件を提供しました。さらに、zkLend Marketコントラクトは、誰でも空のマーケットに任意の量の資産を預け入れることを許可しています。攻撃者は少量の資産を預け入れることで、lending_accumulatorの値を大幅に吊り上げました。
    • 寄付メカニズム: zkLend Marketコントラクトのflash_loan()関数には、独自の寄付メカニズムがあります。具体的には、ユーザーがフラッシュローンを返済する際、マーケットコントラクトは返却された超過額を計算し、グローバルなlending_accumulator変数を増加させ、それによってコントラクト内のすべてのユーザーの担保価値を増幅させます。
  • raw_balanceの操作
    • 丸め動作: シェアトークンをバーンする際の除算処理は切り捨てを使用しており、引き出し時のユーザーのraw_balanceの変化の過小評価につながります。

これらの両方の変数を操作することにより、攻撃者は担保残高を7,000以上のwstETHに増加させ、利益のために市場から他の資産を借り入れることができました。

0x2.1 lending_accumulator変数の操作

0x2.1.1 空のマーケット初期化

攻撃前のマーケットコントラクトのトランザクション記録を調べると、攻撃者が最初にwstETHマーケットコントラクトに1 weiのwstETHを預け入れたことがわかります。このトランザクションの内部呼び出しを確認すると、wstETHマーケットコントラクトが0 wstETHを保持しており、zwstETHの総供給量も0であったことが明らかです。

したがって、zkLend wstETHマーケットには以前の預け入れや借り入れがなかったことを確認できます。準備金残高とztoken_supplyはどちらも初期値の0であり、lending_accumulatorの初期値は1でした。この空のマーケットシナリオは、その後の攻撃の条件を作り出し、攻撃者が最小限のwstETHでlending_accumulatorを大幅に増幅することを可能にしました。

0x2.1.2 フラッシュローン経由でのlending_accumulatorの操作

次に、このトランザクションで、攻撃者はflash_loan()関数を呼び出し、1 weiのwstETHを借り入れて1000 weiのwstETHを返済しました。超過分の999 wei寄付として扱われ、コントラクトのreserve_balanceに記録されました。

lending_accumulatorを計算するための式によると、このトランザクションにより、lending_accumulator1から851.0に増加しました。

0x2.1.3 flash_loan()の繰り返し実行

攻撃者は合計10回のflash_loan()呼び出しを実行し、毎回1 weiのwstETHのみを借りましたが、より大きな金額を返済しました。その結果、lending_accumulator4,069,297,906,051,644,020(4.069 × 10^18)という天文学的な値にエスカレートし、これは偶然にもwstETHの小数点精度と一致しました。

0x2.2 raw_balance変数の操作

lending_accumulatorを約4.069 × 10^18に操作した後、攻撃者はマーケットコントラクトのdeposit()関数を4.069297906051644020 wstETHで呼び出しました。最新のlending_accumulatorの値に基づいて、攻撃コントラクトのraw_balance2になりました。

0x2.2.1 raw_balanceを操作した最初のトランザクション

このトランザクションでは、攻撃者は攻撃コントラクトのcallflashloandraaan()関数を呼び出しました。このコントラクトはオープンソースではありませんが、内部呼び出しトレースに基づいて、この関数のロジックには以下の操作を実行するループが含まれていると推測できます。

  • 預け入れ: 攻撃者は一定量のwstETHをマーケットコントラクトに預け入れます。
  • 引き出し: 攻撃者は特定の量のwstETHを引き出します。

トークン転送記録分析 攻撃者が預け入れるwstETHの量は、常にlending_accumulatorの整数倍であることがわかります。例えば、lending_accumulatorの2倍の値(例:8.13859)です。

しかし、引き出されるwstETHの量は、lending_accumulatorの1.5倍の値(例:6.10394)です。

計算により、引き出されたwstETHの量が預け入れられた量を超えていることがわかります。なぜこれが起こるのでしょうか?

丸め動作 deposit()withdraw()メソッドの実装を見ると、これらの2つのメソッドは、それぞれzwstETHの発行とバーンに関係していることがわかります。仕組みは以下のとおりです。

マーケットコントラクトの`mint()`関数

マーケットコントラクトの`burn()`関数

mint()およびburn()プロセスには、スケールダウンロジックが含まれています。スケールダウンロジックは、***床関数(最も近い整数への切り捨て)***による整数除算を伴い、エクスプロイトにおいて重要な役割を果たします。

攻撃者が一定量のzwstETHをバーンする際、スケールダウンロジックが適用されます。操作されたlending_accumulatorの値が非常に高いため(約4,069,297,906,051,644,020)、この除算により、6以上のzwstETHをバーンしているにもかかわらず、攻撃者のraw_balanceはわずか1単位しか減少しません。

攻撃者のraw_balanceの変化は、以下の表にまとめられています。

このトランザクションでは、攻撃者は預け入れ - 引き出しロジックを繰り返し実行し、withdraw()関数における精度損失を悪用して、raw_balanceの差の過小評価を引き起こしたことがわかります。最終的に、ユーザーのraw_balance2から3に増加し、追加の1単位を獲得しました。

0x2.2.2 後続の攻撃プロセス

その後の攻撃トランザクションは、最初の攻撃と同様のパターンに従いました。攻撃者は預け入れ - 引き出しトランザクションを繰り返し実行してwstETHを取得しました。

取得したwstETHは再度マーケットに預け入れられ、raw_balanceがさらに増加し、攻撃者の担保価値が上昇し続けました。

説明例 以下のトランザクションを例として使用します。

  • 合計30回の預け入れが行われ、毎回4.069 wstETHが預け入れられました。
  • 合計30回の引き出しが行われ、毎回6.104 wstETHが引き出されました。
  • このサイクル後、計算によると、攻撃者は61.39 wstETHを無事抽出しました。

さらに、これらの攻撃トランザクションの間には、いくつかのincrease()メソッドが呼び出されたことに注意する価値があります。これらのメソッドは、攻撃者のアカウントから攻撃コントラクトに特定の量のwstETHを転送するために使用され、その後、マーケットコントラクトへの後続の預け入れのための資金を提供しました。

これらの操作はraw_balanceの値を増加させ、攻撃者が担保価値を継続的に増加させることを可能にしました。最終的に、攻撃者のraw_balance1,724に達し、その価値は7,015.4 wstETHとなり、これは市場から他の資産を借り入れるのに十分でした。

0x3 利益分析

0x3.1 他の種類の資金の借り入れ

担保価値を操作した後、攻撃者は市場から他の種類の資金を借り入れ、以下のトランザクション(抜粋)を実行しました(抜粋)。

0x3.2 借り入れた資金をレイヤー1にブリッジ

攻撃者コントラクトのブリッジトランザクションを調査すると、攻撃者が借り入れた資金の一部をレイヤー1にブリッジしたことが観察されます。

0x4 結論

要約すると、zkLendプロトコルに対するこの攻撃は、分散型レンディングプロトコルの設計とセキュリティに関するいくつかの重要な示唆を浮き彫りにしています。

  • マーケットの初期化と資産預け入れ条件: 開始時の空のマーケットにより、攻撃者は少量のwstETHを預け入れ、lending_accumulatorを操作して、エクスプロイトのためのレバレッジを得ることができました。初期マーケット段階で十分な流動性基盤を確保するか、資産の寄付を制限することが、同様の攻撃を防ぐのに役立つ可能性があります。
  • 適切なアキュムレータメカニズムの重要性: 攻撃者は、flash_loan()関数の寄付メカニズムを悪用してlending_accumulatorを操作し、すべてのユーザーの担保価値を膨張させました。アキュムレータベースのメカニズムを持つプロトコルは、スケーリングファクターの容易な操作から保護されるべきです。
  • 丸め動作と精度損失: zwstETHトークンバーン中の丸め問題により、精度損失とraw_balanceの過小評価が生じ、攻撃者がraw_balanceを操作することが可能になりました。プロトコルは、そのようなエクスプロイトを防ぐために、より高い精度または検証チェックを使用する必要があります。

このインシデントは、初期化と運用状況に関するタイムリーな通知、および潜在的な損失を軽減するためのプロアクティブな脅威防止の重要性を再び強調します。

参照

[1] https://zklend.com/

[2] zkLendのセキュリティインシデント事後分析:https://drive.google.com/file/d/10i1dh_J89tPPw7KRcmFIVM6iNrJZAyfi/view