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関数を使用して、入力量、現在の流動性、および価格から導き出されます。最後に、calcFinalPrice関数を使用して、deltaL、入力量、現在の価格、および流動性に基づいて、スワップ後の最終価格nextSqrtPが計算されます。
逆に、必要な量がユーザー指定の量より少ない場合(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.364WETHと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にシフトされました。これは、実際にティック111,310に到達するために3 weiのfrxETHのみを使用した小さなスワップです。その後、_updateLiquidityAndCrossTick関数内で、現在のティックはティック111,310を横断するはずです(左/下方向に移動)。ステップ4で右/上方向に真に通過していなかったにもかかわらず。これにより、ティック111,310の流動性が二重に計上されます。 -
computeSwapStep関数の2回目の呼び出しでは、以前の流動性の二重計上が追加利益の可能性につながります。具体的には、この流動性の二重計上を利用することで、最終ステップのスワップ価格が歪み、より多くのWETHがスワップアウトされ、利益が生まれます。
0x4 攻撃と利益の概要
執筆時点では、イーサリアム、オプティミズム、ポリゴン、アービトラム、アバランチ、ベースを含むさまざまなチェーンで、実際の攻撃が複数観測されており、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



