2025年2月12日、StarkNet上の貸出プロトコルであるzkLend [1]が、そのアキュムレータ・メカニズムを巧妙に操作することにより、およそ1,000万ドルの不正利用を受けました。攻撃者は、フラッシュ・ローンと丸め誤差の脆弱性を利用して担保価値を人為的につり上げ、他の資産をプロトコルから借りて利益を得ました。
しかし、セキュリティの観点からの詳細かつ正確な技術的分析はまだ不足している。zkLendが後に発表した公式の事後分析[2]は、簡略化された説明を提供していますが、詳細な技術的分析には欠けています。このブログでは、インシデントを明らかにするための包括的な検証を行うことを目的としています。
主要な要点 (TL;DR)
-
このインシデントの根本的な原因は、以下の3つの問題**の組み合わせに起因している:
- **** 空の市場の初期化***は、任意の資産の預け入れを可能にする。
- zkLendのフラッシュローンにおける特定の寄付メカニズム***は、ユーザーの担保残高を動的に調整するためのスケーリン グ係数として、グローバル変数であるアキュムレータの操作を可能にします。
- 切り捨て***による精度損失が発生します。割り算における古典的な精度損失とは異なり、分母は1から始まるが、非常に大きな値に膨張し、シェアトークンの燃焼中に過小評価の原因となった。
-
攻撃者は他のユーザーから預かったwstETHから利益を得なかった**。その代わり、攻撃者は脆弱性を利用して担保残高を操作し、少額のwstETHを初期資本として使用して担保残高を7,000wstETH以上まで増加させ、市場から他の資産を借り入れることを可能にした。
以下では、まず zkLend に関する重要な背景情報を提供する。続いて、zkLendの問題点と関連する攻撃について詳しく分析する。
0x1 背景zkLend のコアプロトコルを理解する
zkLend は StarkNet 上の融資プロジェクトで、担保ローンやフラッシュローンといった一般的な融資プロトコルを サポートしています。この 2 つのプロトコルの実装の詳細に飛び込んでみましょう。
0x1.1 担保付きローン
担保ローンとは、ユーザーが他の資産を借りる代わりに、特定の資産を担保としてプロトコルに預けるプロセスを指す。担保の価値は、借入能力を決定するために使用される。貸出プロトコルは通常、担保の資産価値を直接保存しません:
担保残高 = 貸出残高 * 未加工残高
具体的には、lending_accumulator
は各ユーザーの担保価値を動的に調整するスケーリングファクターであり、raw_balance
はユーザーが市場で保有する実際のシェアを表します。raw_balanceは
lending_accumulatorを使って
collateral_balance` から導き出される。
この設計の目的は何ですか? プロトコルが効率的に担保価値を管理することを可能にすると同時に、ユーザーに資産を預けるインセンティブを与えることです。プロトコルの収益の一部を担保提供者に配分することで、lending_accumulator
が増加し、それによってすべてのユーザーの担保価値が比例して同時に増幅されます。
0x1.2 zkLend におけるフラッシュローン
フラッシュローンは無担保ローンの一種であり、ユーザーは非常に短い期間、通常は 1 回のトランザクショ ン内でプロトコルから資産を借りることができます。借り手がローンを返済しなかったり、指定された条件を満たさなかったりすると、トランザクショ ン全体が差し戻され、ローンは実行されません。
zkLendのフラッシュローンの実装では、独自の寄付メカニズムがあります。具体的には、ユーザーが資産を返済する際、必要な最低額を返すだけでなく、寄付として余分な資金を提供することもできる。プロトコルはこれらの寄付された資金を追跡し、それに応じて lending_accumulator
を更新する。この処理は thesettle_extra_reserve_balance()
関数で実装されている。lending_accumulator` の更新式は以下の通りである:
new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply > new_accumulator = (reserve_balance + totaldebt - amount_to_treasury) / ztoken_supply
- reserve_balance
: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マーケット契約では、誰でも任意の量の資産を空のマーケットに預けることができます。攻撃者は、
lending_accumulator
の値を大きく膨らませるために少量の資産を預け入れた。 - 寄付メカニズム:zkLend Market コントラクトの
flash_loan()
関数は、ユニークな 寄付 メカニズムを備えています。具体的には、ユーザーがフラッシュローンを返済すると、Market コントラクトは返却された余剰資金を計算し、グローバルなlending_accumulator
変数を増加させます。
- 空の市場攻撃前、wstETH トークンの zkLend 市場は空であり、操作のための完璧な条件を提供していた。さらに、zkLendマーケット契約では、誰でも任意の量の資産を空のマーケットに預けることができます。攻撃者は、
- raw_balance`**の操作
- 丸め動作**:シェアトークンバーニング処理中の除算は切り捨てを使用するため、引き出し中のユーザーの
raw_balance
の変化が過小評価される。
- 丸め動作**:シェアトークンバーニング処理中の除算は切り捨てを使用するため、引き出し中のユーザーの
この2つの変数を操作することで、攻撃者は担保残高を7,000wstETH以上に増やし、他の資産をマーケットから借りて利益を得ることができた。
0x2.1 `lending_accumulator` 変数の操作
0x2.1.1 空のマーケットの初期化
攻撃前のMarketコントラクトの取引記録を調べると、攻撃者は最初 にwstETHのweiをwstETH Marketコントラクトに入金していることがわかる。この取引の内部コールを確認すると、wstETH Market契約は0個のwstETHを保有しており、zwstETHの総供給量も0個であることがわかる。

したがって、zkLend wstETH市場において事前に預け入れや借り入れがなかったことが確認できる。この空の市場シナリオは、攻撃者が最小限のwstETHでlending_accumulator
を大幅に増幅することを可能にし、その後の攻撃のための条件を作り出しました。
0x2.1.2 フラッシュローンによる lending_accumulator
の操作
次に、このトランザクション において、攻撃者は flash_loan()
関数を呼び出し、1wei wstETH を借りて、1000wei wstETH を返済する。余った 999 wei は 寄付 として扱われ、コントラクトの reserve_balance
に記録される。

lending_accumulatorの計算式によると、この取引によって
lending_accumulator` は 1 から 851.0 に増加します。

0x2.1.3 `flash_loan()` の繰り返し実行
攻撃者は合計 10 回の flash_loan()
呼び出しを実行し、毎回 wstETH を 1 Wei だけ借りて、それ以上の金額を返済する。その結果、lending_accumulator
は4,069,297,906,051,644,020 (4.069 × 10^18)という天文学的な値にまでエスカレートし、これは偶然にもwstETHの10進数精度と一致する。
0x2.2 `raw_balance` 変数の操作
lending_accumulatorを約4.069 × 10^18に操作した後、攻撃者は*4.069297906051644020* wstETHでMarketコントラクトの
deposit()関数を呼び出した。lending_accumulator
の最新値に基づき、攻撃コントラクトの raw_balance
は 2 となった。
0x2.2.1 `raw_balance`を操作する最初のトランザクション
このトランザクション](https://voyager.online/tx/0x001e3c2ebb5b4eafc4800c178dcbd6aa36233d40733bc419d6bce47f8c48d6e6#overview)では、攻撃者は攻撃契約書の関数 callflashloandraaan()
を呼び出した。このコントラクトはオープンソースではないが、内部のコールトレースから、この関数のロジッ クには以下のアクションを実行するループが含まれていると推測できる:
- 預金**:預け入れ**:攻撃者は一定額のwstETHを市場契約に預け入れる。
- 出金**:出金**:攻撃者は指定された量の wstETH を出金する。

トークン転送レコード分析
攻撃者が預けるwstETHの量は常にlending_accumulator
の整数倍、例えばlending_accumulator
の値(例えば8.13859)の2倍であることが観察できる。
しかし、wstETHの引き出し量はlending_accumulator
の値(例えば、6.10394)の1.5倍である。

計算すると、wstETHの引き出し額が預け入れ額を上回っていることがわかる。なぜこのようなことが起こるのか?
丸めの動作
deposit()メソッドとwithdraw()
メソッドの実装を見直すと、これら2つのメソッドはそれぞれzwstETHの鋳造と燃焼を伴うことがわかる。これがどのように動作するかを以下に示す:

マーケット契約における`ミント()`関数

マーケット契約における`burn()`関数
。mint()と
burn()`の処理はどちらもスケールダウンロジックを含んでいる。このスケールダウン・ロジックには、floor rounding (四捨五入から最も近い整数への切り捨て)を伴う整数除算が含まれており、これがエクスプロイトで重要な役割を果たします。

攻撃者が一定量のzwstETHを消費すると、縮小ロジックが適用される。操作されたlending_accumulator
の値が特別に高いため(約* 4,069,297,906,051,644,020)* 、この分割は6zwstETH以上燃やしているにもかかわらず、攻撃者のraw_balance
が1単位だけ減少する原因となる。
攻撃側の raw_balance
の変化は以下の表にまとめられている:

このトランザクションにおいて、攻撃者は withdraw()
関数中の精度損失を悪用してDeposit - Withdrawロジックを繰り返し実行し、その結果raw_balance
の差を過小評価していることが観察できる。最終的に、ユーザーの raw_balance
は2から3に増加し、さらに1ユニットを獲得した。
0x2.2.2 その後の攻撃処理
その後の攻撃トランザクションは最初の攻撃と同じパターンをたどった。攻撃者は預金 - 出金トランザクションを繰り返し循環させてwstETHを獲得する。
獲得したwstETHはマーケットに再預託され、raw_balance
をさらに増加させ、攻撃者の担保価値を上昇させ続ける。

説明例
次の取引を例として説明する。

- 合計30回の入金が行われ、毎回4.069wstETHが入金された。
- 合計30回の引き出しが行われ、毎回6.104wstETHが引き出された。
- このサイクルの後、計算上、攻撃者は61.39wstETHの引き出しに成功した。
さらに、これらの攻撃トランザクションの間に、いくつかの increase()
メソッドが呼び出されたことも注目に値する。これらのメソッドは、攻撃者の口座から攻撃契約へ特定量のwstETHを送金するために使用され、その後のMarket契約への入金のための資金を提供した。

これらのアクションは raw_balance
の値を押し上げ、攻撃者が担保価値を増やし続けることを可能にする。最終的に、攻撃者のraw_balance
は1,724に達し、7,015.4 wstETHの値となり、市場から他の資産を借りるのに十分な値となった。
0x3 利益分析
0x3.1 他の資金を借りる
担保価値を操作した後、攻撃者は他の種類の資金を市場から借り入れ、以下の取引を行った(抜粋):

攻撃者の契約](https://voyager.online/contract/0x04d7191dc8eac499bac710dd368706e3ce76c9945da52535de770d06ce7d3b26#bridgeTxns?ps=100&p=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