2023年11月23日、KyberSwapを標的とした一連の攻撃が観測されました。これらの攻撃により、合計で4,800万ドル以上の損失が発生しました。我々の初期分析では、このエクスプロイトはティック操作と流動性二重計上によるものだと示唆されていました。しかし、スペースの制約上、その投稿内の詳細な内容を掘り下げることはできませんでした。その後の他のセキュリティ研究者による洞察に富む分析にもかかわらず、問題の根本原因である精度損失は未だ露呈されていませんでした。
興味深いことに、数日後に事態はさらに複雑化しました。2023年11月30日、関係者との複数回の議論を経て、攻撃者は外部から見ると挑発的なメッセージメッセージを送信し、完全な支配を要求しました。それをさておき、攻撃者はまた、以下の図に示すように、問題が実際に精度損失に関連しているという重要な情報も明らかにしました。この暴露は、我々の調査の証拠を強化するものです。そのため、本レポートでは包括的な分析を提示することを目標とします。

主要なポイント(TL;DR)
-
我々の調査により、根本的な問題はKyberSwapの再投資プロセスにおける不正な丸め方向に起因することが明らかになりました。これにより、不適切なティック計算と最終的な流動性二重計上が引き起こされます。
-
このインシデントは、DeFiプロトコルにおける精度損失問題の複雑かつ巧妙な性質を浮き彫りにし、コミュニティ全体に大きな課題を提示しています。
-
これらの攻撃の頻度は、プロアクティブな脅威防止策の重要な必要性を痛感させ、将来の損失を大幅に削減するのに役立つ可能性があります。
続くセクションでは、まずKyberSwapに関する重要な背景情報を提供します。その後、脆弱性と関連する攻撃について詳細な分析を行います。
0x1 背景
KyberSwap[1]は、分散型自動マーケットメーカー(CLAMM)プラットフォームです。 集中流動性の市場需要に応えるため、KyberSwap Elastic[3]はUniswap V3[2]をベースにローンチされ、流動性提供の収益を自動複利化する再投資カーブなど、いくつかの改善が施されています。
0x1.1 ティックと平方根価格
Uniswap V3ライクなCLAMMにおけるティックは、価格を離散的にマークするために使用され、LPが全範囲ではなく固定範囲内で流動性を提供できるようにします(そのため「集中」という用語が使われます)[4]。
LPがカスタマイズされた価格間隔で流動性ポジションを指定できるようにするため、プロトコルはさまざまな価格ポイントでの集計流動性を追跡する方法を必要としました。Uniswap V3は、可能な価格空間を離散的な「ティック」に分割することでこれを達成し、LPは任意の2つのティック間に流動性を提供できるようになりました。
[5]によると、流動性は任意の2つのティック(隣接している必要はない)間の範囲に配置できます。つまり、ティックインデックスのペア(下限ティックと上限ティック)です。具体的には、各ティックの価格(整数インデックスi)は以下のように定義されます。
実際には、平方根価格(sqrtPまたはsqrtPriceと表記)が使用されます。
現在の平方根価格から現在のティックを計算することも可能です。
平方根価格を流動性Lとともに使用することは、同時変更を回避するための実用的な方法です。具体的には、ティック内でスワップしているときに価格が変化し、ティックを越えるとき、または流動性をミントまたはバーンするときに流動性が変化します。詳細については、Uniswap V3のホワイトペーパー[5]を参照してください。
明らかに、特定のティックに対して単一の平方根価格が計算される一方で、複数の平方根価格が同じティックを指す可能性があります。
0x1.2 再投資カーブ
Uniswap V3ベースのCLAMMは、LP手数料のプール利用率と、再投資に必要な多額のガス代という問題を抱えています。そのため、KyberSwapは問題を解決するために再投資カーブ[6]を採用しました。
再投資カーブは、集中流動性モデルで本来利用されないLP手数料をネイティブに再投資することのみを目的として設計されました。これは、集中流動性ポジションのLP手数料が、ガス代や手動管理のオーバーヘッドなしに自動的に複利計算されることを意味しました。さらに、LPはいつでも自動複利計算された手数料収益を個別に回収するオプションも保持していました。
再投資カーブの鍵は、各スワップで収集された手数料が、無限範囲内の再投資流動性としてプールに追加される流動性として蓄積されることです。再投資トークンはLPにミントされ、蓄積された再投資流動性はLPにそれに応じて割り当てられます。さらに、再投資流動性もスワップおよび価格計算プロセスに参加します。
正確には、定数積の公式の代わりに:
各スワップで手数料がΔLに蓄積されます。
ΔLの計算は、(価格偏差が閾値よりも小さいという仮定の下で)以下のように簡略化できます。
次に、スワップ量と最終価格は、修正された定数積の公式から導き出されます。
上記の計算に対応するコードは、以下のコードスニペットのcomputeSwapStep関数に示されており、これは対応するプールのものです。

再投資流動性のため、この関数におけるliquidityは2つのコンポーネントの合計であることを注意する必要があります。baseLはベース流動性、reinvestLは再投資のために蓄積された流動性です。
0x1.3 KyberSwapでのスワップ
Uniswap V3でのスワップの制御フローは、以下のように図示できます[5]。

したがって、前述のKyberSwapのプールのswap関数の実装は、以下の図に抽象化できます。

ティック計算に関する重要なロジックは、スワップのwhileループ内にあり、青い長方形で強調されています。具体的には、主要なロジックはcomputeSwapStep関数と_updateLiquidityAndCrossTick関数に関係しています。前者は、与えられたスワップの入力および出力量やnextSqrtPなどの主要な状態を計算し、後者はティックを越えるケースを処理します。
伝統的に、価格が上昇した場合、これをティックを右/上向きにシフトすると呼びます。そうでなければ、ティックは左/下向きに移動すると言います。
後述する脆弱性をよりよく理解するためには、以下の図に示すcomputeSwapStep関数の関連コードロジックを調べることが不可欠です。

まず、50行目から57行目では、calcReachAmount関数が呼び出され、targetSqrtP(次のティックまたはユーザー指定のターゲット価格)に到達するために必要な入力トークンの量を計算します。
次に、59行目から62行目では、ティックを越えるべきかどうかを判断するためのテストが行われます。
具体的には、使用された量(usedAmount)が、正確な入力スワップ(攻撃で使用されたケース)でユーザーが指定した量(specifiedAmount)よりも大きい場合、ティックを越えるべきではなく、nextSqrtPは増分流動性(deltaL、つまりデルタ流動性)から導き出される必要があります。
- その後、70行目から79行目では、ΔL(
deltaL)が、入力量、現在の流動性、および価格を使用してestimateIncrementalLiquidity関数から導き出されます。最後に、スワップ後の最終価格nextSqrtPは、deltaL、入力量、現在の価格、および流動性を使用してcalcFinalPrice関数から計算されます。
逆に、必要とされる量がユーザー指定の量よりも少ない場合(これはnextSqrtP > 0を意味します)、deltaLは現在のsqrtPとターゲットsqrtPを使用して計算され、nextSqrtPは次のティックのsqrtPになります。この分岐は攻撃では使用されないため、詳細は省略します。
上記の手順により、ティックが越えられない場合、computeSwapStep関数が返すnextSqrtPは次のティックのsqrtPを超えてはならないことが明確になります。しかし、価格が流動性(ベース流動性およびデルタ流動性)および精度損失に依存しているため、攻撃者はティックが越えられていない間にnextSqrtPをより大きく操作することが可能になります。
0x2 脆弱性分析
根本原因は、SwapMathコントラクト(computeSwapStep関数から呼び出される)のデルタ流動性計算(つまり、estimateIncrementalLiquidity関数)における不正な丸め方向による、不具合のあるティック計算にあります。これが、その後のティック計算に不適切に影響します。

興味深いことに、188行目のコメント(青い長方形で強調)を見ると、deltaLはnextSqrtPを丸めるために切り上げる意図があったことがわかります。しかし、189行目のmulDivFloor関数の使用により、deltaLは誤って切り捨てられます。その結果、nextSqrtPは不正確に切り上げられます。
0x3 攻撃分析
攻撃者は複数の攻撃トランザクションを開始し、各トランザクションで複数のプールから資金を抜き取りました。簡単にするために、以下の議論は、最初の攻撃トランザクション攻撃トランザクションに基づいています。
コア攻撃ロジックは、以下の6つのステップで構成されています。
-
AAVEからフラッシュローンで2,000 WETHを借り入れます。
-
標的プール0xfd7bで6.850 WETHを6.371 frxETHにスワップします。このステップは、現在のティックと
currentSqrtPを、現在流動性が存在しない場所に移動するために使用されます。
currentSqrtPは攻撃者によってランダムに選択されたように見え、スワップはこの価格で正確に停止します。- このステップの後、ベース流動性(
baseL)はゼロですが、再投資流動性(reinvestL)はゼロではありません。
- プールに流動性を追加してから、一部の流動性を削除します。このステップは、範囲と総流動性を望ましい量に制御するために使用されます。
- ティック範囲は
currentSqrtPに基づいて選択されます。 - 望ましい流動性はティック範囲から導き出すことができますが、対応する計算ロジックについてはさらに検討が必要です。
- プールで387.170 WETHを0.06 frxETHにスワップします。このステップは、現在のティックを操作して**
nextTick==currentTick**にするために使用されます。
- 入力量は、流動性と
currentSqrtPに基づいて選択されます。
-
プールで0.06 frxETHを396.244 WETHにスワップします。前のステップとは逆の方向のスワップに注意してください。このステップでは、流動性が二重計上され、スワップが収益を上げ、結果としてプールから資金を抜き取ります。
-
フラッシュローンを返済し、6.364 WETHと1.117 frxETHを回収します。
明らかに、最後の2つのスワップ(ステップ4とステップ5)は、ティック計算を操作してスワップを収益化し、プールを枯渇させるための主要な攻撃ステップです。これらについては、以下のサブセクションで詳細を説明します。
ステップ3は、流動性を操作するために重要です。丸め操作による正確なティック操作が必要なため、直接流動性を追加して目標を達成することは実行不可能です。流動性削除は、攻撃者が望むように、範囲内の流動性を正確に制御するためです。
0x3.1 ステップ4:現在のティックとcurrentSqrtPの操作
前のステップ(ステップ1および2)の後、攻撃者は操作のためのティック範囲と流動性を準備しました。具体的には、
currentSqrtPは望ましい位置にあります。- 現在のティックは110,909、次のティックは111,310で、
currentSqrtPを囲んでいます。
このステップはWETHをfrxETHにスワップします。computeSwapStep関数では、以下の実行トレースが得られます。

上記の図に示すように、ターゲット(つまり、次のティック)に到達するための量は、calcReachAmount関数を呼び出すことによって計算されます。
usedAmount=calcReachAmount(liquidity,currentSqrtP,targetSqrtP)
この計算はスワップの前に導き出せることに注意してください。specifiedAmount(usedAmount = specifiedAmount + 1)を慎重に選択することで、攻撃者はターゲット(つまり、次のティック111,310)に到達しないようにスワップを制御し、その結果nextSqrtP = 0となります。
この状況では、ティックは越えられないため、nextSqrtP(つまり、最終価格)はデルタ流動性(スワップ手数料として蓄積される)から導き出す必要があります。
まず、手数料からの増分流動性deltaLは以下のように計算されます。
deltaL=estimateIncrementalLiquidity(absDelta,currentSqrtP)
次に、最終価格nextSqrtPは、
nextSqrtP=calcFinalPrice(absDelta,liquidity,deltaL,currentSqrtP)
前のセクションで説明した丸め方向のエラーを再確認すると、ここではdeltaLが誤って切り捨てられ、nextSqrtPが切り上げられます。具体的には、この場合、同じabsDelta(387,170,294,533,119,999,999)に基づいて、丸め方向が異なるため、計算結果は異なります。

したがって、ステップ4でのティック操作後、現在の状態は次のように要約されます。
currentSqrtPは20,693,058,119,558,072,255,665,971,001,964であり、ティック111,310のsqrtP(ティック111,310でのsqrtP = 20,693,058,119,558,072,255,662,180,724,088)よりわずかに大きいです。- 現在のティック= 111,310、次のティック= 111,310

上記の図に示すように、ステップ4のスワップは、巧妙にプールを欺き、ティック111,310が越えられていないと信じ込ませます。しかし、実際には、currentSqrtPはティック111,310のsqrtPよりも大きくなっています。
0x3.2 ステップ5:流動性の二重計上
ステップ4での操作に基づいて、ステップ5の攻撃ロジックは非常に単純です。この段階で、攻撃者はfrxETHからWETHへの逆スワップを仕掛け、ティックとcurrentSqrtPを左にシフトさせます。具体的には、computeSwapStep関数がループ内で2回呼び出され、最終的に予期せぬ方法で二重計上[7]を引き起こし、結果として追加の利益を生み出します。

上記のトレースに示すように:
-
computeSwapStep関数の最初の呼び出しでは、currentSqrtPはティック111,310のsqrtPにシフトされました。これは、実際にはステップ4でティック111,310を右/上方向に横断していなかったにもかかわらず、わずか3 weiのfrxETHを使用してティック111,310に到達するだけの小さなスワップです。その後、_updateLiquidityAndCrossTick関数内で、現在のティックはティック111,310を越える(左/下向きに移動する)はずです。これにより、ティック111,310の流動性が二重に計上されます。 -
computeSwapStep関数の2回目の呼び出しでは、前回の流動性の二重計上が追加の利益の可能性につながる可能性があります。具体的には、この流動性の二重計上を利用することで、最終ステップのスワップ価格が歪み、より多くのWETHがスワップアウトされ、利益が生み出されます。
0x4 攻撃と利益の概要
執筆時点では、複数のチェーン(Ethereum、Optimism、Polygon、Arbitrum、Avalanche、Baseを含む)で複数の攻撃が実世界で観測されており、4,800万ドルを超える損失が発生しています。これらの攻撃は、以下のように異なる攻撃者によって実行されました。
これらの攻撃トランザクションの完全なリストは、我々が準備したドキュメントにまとめられています。詳細については、そちらを参照してください。
0x5 結論
結論として、これは不適切な丸めロジックに起因する微妙な脆弱性です。このエクスプロイトは非常に洗練されています。実際、今年、我々は精度損失問題に関連する一連のセキュリティインシデントを観測しており、コミュニティに大きな課題を投げかけています。
これらの継続的な攻撃は、プロアクティブな脅威防止策の重要性を改めて示しており、この戦略は潜在的な損失を効果的に軽減するのに役立つ可能性があります。
参考文献
[1] https://docs.kyberswap.com/
[2] https://blog.uniswap.org/uniswap-v3
[3] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic
[4] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/tick-range-mechanism
[5] https://uniswap.org/whitepaper-v3.pdf
[6] https://docs.kyberswap.com/liquidity-solutions/kyberswap-elastic/concepts/reinvestment-curve



