2025年2月12日、StarkNet上のレンディングプロトコルであるzkLend[1]が、そのアキュムレーターメカニズムの巧妙な操作により、約1000万ドルの被害に遭いました。攻撃者はフラッシュローンと丸め誤差を利用して、担保価値を人為的に高め、プロトコルから他の資産を借り入れて利益を得ました。
しかし、セキュリティの観点からの詳細かつ正確な技術分析は依然として不足しています。他のセキュリティ研究者による既存の分析は貴重な洞察を提供しましたが、特に攻撃分析に関して、いくつかの誤解が残っています。zkLendが後で公式の事後分析[2]を公開しましたが、そこでは単純化された説明が提供されているものの、詳細な技術分析は欠けています。本ブログでは、このインシデントを明確にするための包括的な検討を提供することを目的とします。
主要なポイント(TL;DR)
-
このインシデントの根本原因は、以下の3つの問題の組み合わせに起因しています。
- 空のマーケット初期化により、任意の資産の預け入れが可能になりました。
- zkLendのフラッシュローンにおける特定の寄付メカニズムにより、グローバル変数であり、ユーザーの担保残高を動的に調整するスケーリングファクターであるアキュムレーターの操作が可能になりました。
- 切り捨てによる精度損失が発生します。従来の除算における精度損失とは異なり、分母は1から始まりましたが、非常に大きな値にまで膨れ上がったため、シェアトークンをバーンする際の過小評価を引き起こしました。
-
**攻撃者は、他のユーザーが預け入れたwstETHから利益を得たわけではありません。**代わりに、攻撃者は脆弱性を悪用して担保残高を操作し、少量のwstETHを初期資本として使用して担保残高を7,000以上のwstETHまで増加させ、それによって市場から他の資産を借り入れることを可能にしました。
次のセクションでは、まずzkLendに関する重要な背景情報を提供します。その後、問題点と関連する攻撃について詳細な分析を行います。
0x1 背景:zkLendのコアプロトコルの理解
zkLendは、担保ローンやフラッシュローンといった一般的なレンディングプロトコルをサポートするStarkNet上のレンディングプロジェクトです。これらの2つのプロトコルの実装詳細を掘り下げてみましょう。
0x1.1 担保ローン
担保ローンとは、ユーザーが特定の資産をプロトコルに担保として預け入れ、その代わりに他の資産を借り入れるプロセスを指します。担保の価値は、借入能力を決定するために使用されます。レンディングプロトコルは通常、担保の資産価値を直接保存するのではなく、次の式を使用して計算することを覚えておくことが重要です。
collateral_balance = lending_accumulator * raw_balance
具体的には、lending_accumulatorは各ユーザーの担保価値を動的に調整するスケーリングファクターであり、raw_balanceはユーザーがマーケットで保有する実際のシェアを表します。raw_balanceはlending_accumulatorを使用してcollateral_balanceから派生します。
この設計の目的は何でしょうか? それは、プロトコルが担保価値を効率的に管理し、ユーザーが資産を預け入れることを奨励することを可能にします。プロトコルの収益の一部を担保提供者に割り当てることで、lending_accumulatorが増加し、それによってすべてのユーザーの担保価値が比例して同時に増幅されます。
0x1.2 zkLendのフラッシュローン
フラッシュローンは、ユーザーが単一のトランザクション内で、通常は非常に短期間、プロトコルから資産を借り入れることができる無担保ローンの一種です。借り手がローンを返済できなかったり、指定された条件を満たせなかったりした場合、トランザクション全体がロールバックされ、ローンは実行されません。
zkLendのフラッシュローン実装には、ユニークな寄付メカニズムがあります。具体的には、ユーザーが資産を返済する際、必要な最低額を返済するだけでなく、追加の資金を寄付として提供することもできます。プロトコルはこれらの寄付された資金を追跡し、それに応じてlending_accumulatorを更新します。このプロセスは thesettle_extra_reserve_balance() 関数で実装されています。lending_accumulatorを更新するための式は次のとおりです。
new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply
reserve_balance: コントラクトに保持されている基盤となるトークン(例:wstETH)の総量。これには、ユーザーが寄付したトークンの量が含まれます。totaldebt: すべての借りているユーザーの総負債。amount_to_treasury: プロトコルの収益額。ztoken_supply: シェアトークン(例:zwstETH)の総供給量。ユーザーがwstETHを預け入れると、zkLend ztokenコントラクトは同量のzwstETHを発行します。
zkLendのコアプロトコルを理解したところで、攻撃者がlending_accumulatorとraw_balance変数を操作して担保資産をどのように操作したかを正式に説明します。
0x2 攻撃分析
攻撃者は、zkLendコントラクトの以下のメカニズムと脆弱性を悪用して、担保の価値を操作しました。
lending_accumulatorの操作- 空のマーケット: 攻撃前、wstETHトークンのzkLendマーケットは空であり、操作に最適な条件を提供しました。さらに、zkLend Marketコントラクトは、誰でも空のマーケットに任意の量の資産を預け入れることを許可しています。攻撃者は少量の資産を預け入れることで、
lending_accumulatorの値を大幅に膨張させました。 - 寄付メカニズム: zkLend Marketコントラクトの
flash_loan()関数には、ユニークな寄付メカニズムがあります。具体的には、ユーザーがフラッシュローンを返済する際、Marketコントラクトは返却された超過分を計算し、グローバルなlending_accumulator変数を増加させ、それによってコントラクト内のすべてのユーザーの担保価値を増幅させます。
- 空のマーケット: 攻撃前、wstETHトークンのzkLendマーケットは空であり、操作に最適な条件を提供しました。さらに、zkLend Marketコントラクトは、誰でも空のマーケットに任意の量の資産を預け入れることを許可しています。攻撃者は少量の資産を預け入れることで、
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マーケットに事前の預け入れや借り入れがなかったことを確認できます。reserve_balanceと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_accumulatorは1から851.0に増加しました。
0x2.1.3 flash_loan()の繰り返し実行
攻撃者は合計10回のflash_loan()呼び出しを実行し、毎回1 weiのwstETHのみを借り入れましたが、より大きな金額を返済しました。その結果、lending_accumulatorは天文学的な値である4,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_balanceは2になりました。
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_balanceは2から3に増加し、追加の単位を獲得しました。
0x2.2.2 後続の攻撃プロセス
後続の攻撃トランザクションは、最初の攻撃と同様のパターンをたどりました。攻撃者は預け入れ - 引き出しトランザクションを繰り返し実行してwstETHを入手しました。
入手したwstETHはマーケットに再預け入れされ、raw_balanceがさらに増加し、攻撃者の担保価値が上昇し続けました。
例の説明
次のトランザクションを例として説明します。
- 合計30回の預け入れが行われ、毎回4.069 wstETHが預け入れられました。
- 合計30回の引き出しが行われ、毎回6.104 wstETHが引き出されました。
- このサイクル後、計算によると、攻撃者は61.39 wstETHを正常に抽出しました。
さらに、これらの攻撃トランザクションの間に、いくつかのincrease()メソッドが呼び出されたことは注目に値します。これらのメソッドは、攻撃者のアカウントから攻撃コントラクトに特定の量のwstETHを転送するために使用され、その後、マーケットコントラクトへの後続の預け入れのための資金を提供しました。
これらの操作はraw_balanceの値を増加させ、攻撃者が担保価値をさらに高めることを可能にしました。最終的に、攻撃者のraw_balanceは1,724に達し、7,015.4 wstETHの価値となり、これは市場から他の資産を借り入れるのに十分でした。
0x3 利益分析
0x3.1 他の種類の資金を借り入れる
担保価値を操作した後、攻撃者は市場から他の種類の資金を借り入れ、以下のトランザクション(抜粋)を実行しました。
0x3.2 借り入れた資金をLayer1にブリッジする
攻撃者のコントラクトのブリッジトランザクションを調査すると、攻撃者が借り入れた資金の一部をLayer 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



