先週(2026/08/31 - 2026/09/06)は、合計で約940万ドルの推定損失を伴う4件のセキュリティインシデントを確認しました。
| 日付 | インシデント | 種類 | 推定損失 |
|---|---|---|---|
| 2026/08/31 | Ankr FLOW | 状態検証の欠陥 | ~$410K |
| 2026/08/31 | Aquifer | 入力検証の欠陥 | ~$2.47M |
| 2026/08/31 | Injective | 通貨単位検証の欠如 | ~$4.8M |
| 2026/09/03 | Notional Finance | 安全でないキャスト | ~$1.73M |
Web3向けの最高のセキュリティ監査企業
ローンチ前に設計、コード、ビジネスロジックを検証
今週の注目:Injective
このインシデントは、攻撃チェーンの複雑さと損失規模の大きさから今週の注目案件となっています。1回の決済が支払われるまでには、2つの独立した欠陥が揃う必要がありました。両方とも、アプリケーションのコントラクトではなく、チェーン自体の取引所ロジックに存在していました。区切り文字なしで連結されたフィールドから組み立てられた識別子が、本来関連するはずのない2つのオブジェクトを密かに融合させてしまうことがあり、決済経路が資金の保有する通貨単位が裏付けている市場のものと一致するかを一度も確認していない場合、その融合がどれほどの代償を払うことになるかを示しています。
2026/08/31、Injectiveのexchangeモジュール内のバイナリオプションロジックが悪用され、約480万ドル相当のUSDCが奪われました。Injectiveはオーダーブック取引所をチェーン自体に組み込んだレイヤー1チェーンであり、そのため影響を受けたコードは誰かがデプロイしたコントラクトではなく、ノードソフトウェアの一部です。InjectiveのネイティブトークンであるINJを保有する保険基金が、2つの識別子が衝突したためにUSDC建てのバイナリオプション市場に紐づけられてしまい、保険基金を利用する決済経路がその2つを一度も比較していませんでした。攻撃者は、そのような市場内で自身のサブアカウント同士を取引させることで不足額を人為的に作り出し、プロトコルはそれを1セントの何分の一かに相当するINJ残高で補填しました。すべてのポジションが全額返金され、攻撃者は入金額をはるかに上回る額を引き出しました。
背景
Injectiveのexchangeモジュールは、イエスかノーかの結果に対する完全担保付きの賭けであるバイナリオプション市場を上場しています。誰でも上場手数料を支払い、決済を行うオラクルと、失効・決済のタイムスタンプを選択することで市場を上場できます。オラクルはプロバイダーとシンボルの組み合わせとして指定されます。プロバイダーになるにはガバナンス投票が必要ですが、シンボルは上場者が指定する任意の文字列です。トレーダーはまずUSDCなどのクオートトークンをサブアカウントに入金し、その後注文を出すことでサイドを選びます。BUYはそのイベントが起こることに賭け、P * Qを証拠金としてロックします。SELLはそれが起こらないことに賭け、(1 - P) * Qをロックします。ここでQはコントラクトの数量、Pは[0, 1]の範囲のエントリー価格です。したがって、より可能性の高い結果に賭ける側が、より大きな証拠金を差し出すことになります。EndBlocker(チェーンが各ブロックの終わりに実行するフック)は、同じ価格のBUYとSELLをマッチングさせ、LONGとSHORTのポジションを作成します。2つのロックは常に合計でQになるため、新しくマッチングされた帳簿は構造上完全に資金が賄われています。
失効時、オラクルは[0, 1]の範囲の決済価格Sを公開し、各ポジションは市場プールからmargin ± (S - entry) * Qが支払われます。参加者間の支払いは厳密にゼロサムであり、各支払いはゼロで下限が設定されているため、ポジションが負になったり清算が必要になったりすることはありません。ポジションはまた、margin = 0で反対の注文を出すことで早期に決済でき、その証拠金と実現利益を解放します。これは、新たに開設した側がロックした証拠金から支払われます。各ポジションの証拠金はエントリー時にロックした額のままなので、早期決済後は帳簿上の証拠金合計がプールと一致しなくなる場合があります。
どのプロバイダーもシンボルを公開していない市場は、まったく価格のないまま決済時期を迎えます。このモジュールはそのケースに対するフォールバックを備えています:決済はgetBinaryOptionsSocializedLossDataWithRefundFlag()によって実装された返金経路に入り、市場を解決するのではなく解消します。この経路は各ポジションをそのエントリー価格で決済するため、帳簿が各ポジションの主張どおりの資産を保持している限り、いかなるポジションも利益や損失を計上しません。この返金は市場残高から、そして不足がある場合はその市場に紐づく保険基金から賄われます。
市場と保険基金は別個のオブジェクトであり、それぞれ独自のメッセージによって作成され、それぞれが通貨単位を持ちます:市場は1つのトークンで建てられ、基金は作成時に指定されたトークンを保有します。両者は一致するIDによってペアリングされます:基金は自身のIDと等しいIDを持つ市場を裏付けます。両方のIDは、識別フィールドのkeccak256ダイジェストです。exchangeモジュール自体は単一の総合的な銀行口座であり、市場ごと・基金ごとの残高は生の整数による記帳です。
脆弱性の分析
問題のあるコンポーネントは、injective-coreのexchangeモジュール内のバイナリオプション処理であり、コミットb994d6b6[1]で修正されています。2つの連鎖した欠陥により、ある通貨単位を保有する基金が、別の通貨単位で建てられた市場を裏付けることが可能でした。
欠陥1:識別子が区切りのない連結から導出されている。 市場が起動された際にその市場のIDを計算するNewBinaryOptionsMarketID()と、新しい基金が裏付けるべき市場のID(expiry = BinaryOptionsExpiryFlag = -2の場合)を計算するCreateInsuranceFund()は、どちらも同じ式からそのIDを導出します:
return crypto.Keccak256Hash([]byte((BINARY_OPTIONS_MARKET_ID_PREFIX +
oracleType.String() + ticker + quoteDenom + oracleSymbol + oracleProvider)))
フィールドの区切り文字も長さのプレフィックスもないため、フィールド間の境界はハッシュ化されたバイトに何の痕跡も残しません。CreateInsuranceFund()は保険基金自身のフィールドをこれらのスロットにマッピングしており、oracle_baseがoracleSymbolスロットを、oracle_quoteがoracleProviderスロットを占めます。したがって、基金のタプルと市場のタプルは、これらのバイトをフィールドに異なる形で分割しながらも、バイトとして同一のプリイメージを生成する可能性があり、2つのオブジェクトは1つのIDを共有することになります。登録処理はその共有IDを両者の紐付けとして受け入れるため、基金は自身が保有しないquoteDenomの市場の保険基金になり得ます。
欠陥2:支払いは市場の通貨単位に対して一度も確認されていない。 PayDeficitFromInsuranceFund()は、基金が保有する通貨単位のままで基金から生のコインを移動させ、市場の通貨クオートとinsuranceFund.DepositDenomを一度も比較することなく、その同じ生の整数を市場残高に加算します。このモジュールの記帳は単純な整数であるため、INJの生の単位1つとUSDCの生の単位1つは、この経路上では区別がつきません。たとえ同じ整数が10の11乗のオーダーで異なる価値を表していたとしてもです。修正コミットは、この経路に欠けていたこの確認を、流入側と流出側の両方に追加し、基金が自身の保有する通貨単位で建てられた市場のみを裏付けられるようにしました:

同じコミットは、Injectiveメインネットにおけるバイナリオプションの取引と決済も強制的に無効化し、返金経路を攻撃対象領域から除外しています。
攻撃の分析
すべてのステップは、単一のウォレットが自身の3つのサブアカウント(...037c、...037d、...037e)を通じて実行しました。Q = 15,930です。
衝突ペアは、連結されたバイトを同一に保ちながら、フィールドの境界がどこに落ちるかをずらすことで構築されました。基金のtickerとquoteDenom(Xとinj)は市場のティッカーXinjを綴り、基金のoracle_base(コントラクトアドレスとオラクルシンボルが連結されたもの)は市場のquoteDenomに続いてそのoracleSymbolを綴ります:
| 連結内のスロット | 基金(MsgCreateInsuranceFund) |
市場(MsgInstantBinaryOptionsMarketLaunch) |
|---|---|---|
| プレフィックス | -BINARY-OPTIONS-MARKET- |
-BINARY-OPTIONS-MARKET- |
oracleType.String() |
Provider |
Provider |
ticker |
X |
Xinj |
quoteDenom |
inj |
erc20:0xa00C...235a |
oracleSymbol |
erc20:0xa00C...235aNO_PRICE_FOR_REFUND...297、基金のoracle_baseから埋められ、両方の部分が1つのフィールドに詰め込まれている |
NO_PRICE_FOR_REFUND...297 |
oracleProvider |
Frontrunner、基金のoracle_quoteから埋められる |
Frontrunner |
両方とも0x9793a39f82993cdedb0c82c614102d5aba2451f29709dc9a6f7df191020b4efcに解決されます。NO_PRICE_FOR_REFUNDという名前のオラクルは、決して価格を公開しないよう設定されており、これによって決済を返金経路に強制的に流し込みます。
以下の分析は、トランザクション0x6ae9cb...51dcf8に基づいています。
- ステップ1:ブロック
181024772における単一のアトミックなトランザクションで、攻撃者は衝突するINJ建ての保険基金とUSDC建てのバイナリオプション市場を作成し、その基金に12,744,000,000単位の生のINJ(約$0.000000063相当)を注ぎ込み、3つのサブアカウントに合計30,267.02 USDCを入金しました。このダスト入金は、その生の整数が攻撃者が作り出そうと計画していた不足額と一致するようにサイズが調整されていました。各サブアカウントは、後で必要になる証拠金をちょうど受け取りました:
| サブアカウント | 入金額(USDC) |
資金となる証拠金 |
|---|---|---|
037d |
1,593.001593 |
0.10で15,930のBUY、1,593をロック(ステップ2) |
037c |
14,337.001593 |
0.10で15,930のSELL、14,337をロック(ステップ2) |
037e |
14,337.014337 |
0.90で15,930のBUY、14,337をロック(ステップ3) |
| 合計 | 30,267.017523 |
- |
-
ステップ2:同じトランザクション内で、
037dは0.10で15,930のBUY注文を、037cは0.10で15,930のSELL注文を出しました。EndBlockerはそれらをマッチングさせ、037dのための1,593の証拠金を持つLONGと、037cのための14,337の証拠金を持つSHORTを作成しました。帳簿上の証拠金合計は1.0Q = 15,930で、市場プールが保有する額とちょうど一致するため、この帳簿は通常の完全担保市場と見分けがつきません。 -
ステップ3:2ブロックと1.1秒後、ブロック
181024774のトランザクション0x012c17...2af694で、037dはmargin = 0で自身のロングを0.90で決済し、1,593 + (0.90 - 0.10) * 15,930 = 14,337を受け取りました。その支払いは、新たにポジションを開いた037eがロックした証拠金から支払われました。037eは0.90で15,930のBUY注文を出し、14,337をロックしていました。実現利益0.8Q = 12,744は、市場プールの外にある037dの利用可能残高に残り、一方で残る2つのポジションの証拠金はどちらも帳簿上に残ります:負債は1.8Q = 28,674となり、それに対してプールはまだ1.0Qしか保有していません。 -
ステップ4:決済は取引ではなく、市場自体の時計によって駆動されます。各ブロックの開始時に、モジュールは決済タイムスタンプを過ぎた市場を拾い上げ、ポジションごとではなく市場全体として決済します。これは市場作成の18秒後に発動しました。両方のポジションがまだ帳簿上に残っている状態でした:
037cのSHORTと037eのLONGで、それぞれ14,337の証拠金です。オラクルは沈黙したままだったため、返金経路は1.0Q = 15,930の理想化された資産に対して1.8Q = 28,674の負債を計算し、0.8Q = 12,744の不足を報告しました。この不足は返金経路の会計の中にのみ存在します。単一の価格Sの下では、各サイドの支払いはSにのみ依存し、そのエントリー位置には依存しません:ショートは(1 - S) * Qを、ロングはS * Qを受け取り、合計するとプール内のちょうどQになります。返金経路は代わりに各サイドの自身のエントリー価格で支払うため、エントリー同士が相殺されません:ショートは(1 - 0.10) * Qを、ロングは0.90 * Qを支払われ、それぞれ14,337となりました。ショートは、あたかも価格が0.10から一度も動いていないかのように証拠金全額を回収し、それは攻撃者がステップ3ですでに引き出していたのと同じ0.8Qです。 -
ステップ5:
PayDeficitFromInsuranceFund()は、衝突する基金から12,744,000,000単位の生のINJを移動させ、市場プールに12,744 USDCを加算することで不足額を解消しました。不足がカバーされたと報告されたため、残りのポジション全体に対する損失の社会化的な削減は省略され、すべてのポジションが証拠金全額を返金されました。不足額そのものは誰にも損失を与えません:それは市場の保険基金から、あるいはそれが不可能な場合は削減措置によって補填されるからです。今回これが利益を生んだのは、この市場に紐づけられた基金がUSDCではなくINJのダストを保有していたためです。 -
ステップ6:ブロック
181024803のトランザクション0xcb33ad...152effで、攻撃者は入金した30,267.02 USDCに対して43,010,985,663単位の生のUSDC、すなわち43,010.99 USDCを引き出しました。純利益は12,743.97 USDCで、最初のトランザクションから最後のトランザクションまでおよそ21秒でした。
上記のサイクルは代表的な1回分の例です。攻撃者はこれを、19時間の期間にわたって作成した299個の短命なバイナリオプション市場にわたって繰り返しました。それぞれは、決して価格を公開しないよう設定されたオラクルに紐づけられており、失効と決済のタイムスタンプは数秒しか離れていませんでした[2]。これらの回の純利益を合計すると、このインシデントで失われた約480万ドルになります。
結論
このインシデントは、識別子の衝突と、支払い経路における通貨単位確認の欠如を組み合わせたものです。基金は、自身のIDと一致する市場を裏付けることを意図しています。しかし、識別フィールドを端から端まで連結して構築されたIDは、あるフィールドがどこで終わり次のフィールドがどこから始まるかをもはや記録しないため、2つの異なるフィールドの組み合わせが同じIDを生成する可能性があり、そのペアリングは基金を、実際には一致しない市場に紐づけてしまいます。下流の何もこの不一致を捕捉しません。なぜなら、市場の不足額を補うために保険基金を利用する経路は、金額を比較するだけで通貨単位を一度も比較しないからです。攻撃者はこの2つの欠陥を組み合わせて利用し、自身が支配する市場内に幻の不足額を作り出し、1セントの何分の一かに相当する基金残高でそれを決済し、プールが一度も保有していなかった証拠金の全額返金を持ち去りました。
より一般的に言えば、意味を持つ識別子は、可変長の各フィールドに明示的な区切り文字または長さのプレフィックスを持つ、構造を保持するエンコーディングから導出されるべきであり、それによって2つの異なるフィールドの組み合わせが同じダイジェストにマッピングされることがないようにすべきです。
今週のその他のインシデント
Ankr FLOW
2026/08/31、Flow EVM上のAnkrの流動性ステーキングサービスが悪用されました。Ankrは、ネットワークのネイティブトークンであるステーキングされたFLOWに対して2種類の異なるトークンを発行しており、それぞれ独自のエントリーポイントを通じてミントされます。そのうちの一方の経路は無効化されていましたが、そこへの第二の経路がその一時停止を強制するチェックを迂回してしまい、その経路の変換レートはその間に古くなっていました。攻撃者はその経路を通じて、同じトークンが償還可能な額よりもはるかに安くミントし、その差をAnkrの償還バッファ、Uniswap V3プール、MORE Markets貸出プロトコルを通じて循環させました。約15.5M WFLOW(ラップされたFLOW)、当時の価格で約41万ドル相当が、MORE Marketsの準備金から流出し、攻撃者はスリッページ後に約24万6千ドルを実現しました[3]。
背景
Ankr FLOWは、Flow EVM上の流動性ステーキングサービスです。FlowStakingPoolは、バリデータステーキングのためにFLOWをCadenceへ転送し、その結果生じるポジションを2種類のトークンで表現します:リベースしない証明書トークンであるankrFLOWと、リベースする受益トークンであるaFLOWEVMbです。aFLOWEVMb自体はankrFLOWによって裏付けられています。
この2つのトークンは別々のエントリーポイントを持ちます。証明書経路はstakeCerts()とunstakeCerts()を通じて_stakeCerts()と_unstakeCertsFor()に至ります。受益トークン経路はstakeBonds()とunstakeBonds()を通じて_stakeBonds()と_unstakeBondsFor()に至ります。受益トークン経路でのミントには第二の外部エントリーポイントであるstakeBondsWithCode()があり、これはAnkrの紹介プログラム用のパートナーコードを受け取り、同じ内部の_stakeBonds()を呼び出します。各経路は、FLOWと各トークンの間の変換比率を公開するコントラクトであるInternetBondRatioFeedから独自のエントリーを読み取ります。
FlowStakingPoolはまた、即時償還のためのFLOWバッファを保持しています。プールを超えて、ankrFLOWはUniswap V3のankrFLOW/WFLOWプールで取引され、Aave V3スタイルの貸出プロトコルであるMORE Marketsでも担保として受け入れられていました。MORE Marketsは資産ごとに設定された貸出価値比率(LTV)で借入を制限し、eモード(efficiency mode)カテゴリー、すなわち価格が連動すると予想される資産のグループを提供しており、借り手がそのカテゴリーを有効にすると、より高いLTVが適用されます。
脆弱性の分析
問題のあるコントラクトはFlowStakingPool(0xfe81...287a)で、証明書トークンankrFLOW(0x1b97...14bdb)と受益トークンaFLOWEVMb(0xd6fd...f8d4a)を、InternetBondRatioFeed(0x3201...de38f)から読み取った比率に対してミントします。
2つの欠陥が重なっています。第一に、stakeBondsWithCode()は、stakeBonds()が強制するbondStakingUnpaused修飾子なしに_stakeBonds()に到達しており、受益トークン経路は無効化された後も呼び出し可能なままでした。第二に、2025年4月29日から2026年8月27日までの71回の週次比率更新バッチにわたって、アクティブなankrFLOWエントリーのみが更新され、aFLOWEVMbエントリーは1.0のまま放置されていました。
したがって、この2つの比率は同じ基礎となるステークを異なる価格で評価していました:
| 方向 | 関数 | 比率 | 変換 |
|---|---|---|---|
| ミント | _stakeCerts() / stakeCerts() |
0.833437 | 1 FLOWにつき0.833437 ankrFLOW |
| ミント | _stakeBonds() / stakeBondsWithCode() |
1.0 | 1 FLOWにつき1 aFLOWEVMb |
| 償還 | _unstakeCertsFor() / unstakeCerts() |
0.833437 | 1 ankrFLOWにつき約1.19985 FLOW |
| 償還 | _unstakeBondsFor() / unstakeBonds() |
1.0 | 1 aFLOWEVMbにつき1 FLOW |
aFLOWEVMbはankrFLOWによって裏付けられているため、受益トークン経路を通じてミントすると、入金したFLOW1つにつき裏付けられたankrFLOWが1つ生成されるのに対し、証明書経路では0.833437しか生成されませんでした。証明書経路を通じた償還は依然としてankrFLOW1つにつき約1.19985 FLOWを支払っていました。
攻撃の分析
以下の分析は、トランザクション0x2b2e6e...3f66c9に基づいています。
-
ステップ1:攻撃者は、2つの経路間の1回の往復によって資金を確保しました。Uniswap V3の
ankrFLOW/WFLOWプールから5,000 ankrFLOWをフラッシュローンし、unstakeCerts()を通じてそれを約5,999.25 FLOWに償還し、stakeBondsWithCode()を通じて5,000.50 FLOWを入金して同額のankrFLOWに裏付けられた5,000.50 aFLOWEVMbをミントし、unlockShares()を呼び出してそのankrFLOWを解放してローンとその0.50 ankrFLOWのプレミアムを返済しました。約998.75 FLOWが運転資金として残りました。 -
ステップ2:攻撃者はUniswap V3のスワップを50回繰り返しました。各サイクルは、価格制限付きで
ankrFLOWをWFLOWにスワップし、stakeBondsWithCode()とunlockShares()を通じてFLOWをルーティングすることで、スワップコールバック内でプールに支払うべきankrFLOWをミントし、その後次のサイクルに向けてWFLOWの出力をアンラップしました。50回のサイクル全体で、プールは約38,634,755.38 ankrFLOWを受け取り、約46,265,167.78 WFLOWを支払い、攻撃者の残高は約998.75 FLOWから約7,631,411.14 FLOWに増加しました。 -
ステップ3:攻撃者は約
38,601.95 FLOWを受益トークン経路を通じて変換し、その結果得られたankrFLOWをunstakeCerts()を通じて償還し、FlowStakingPoolがまだ保有していた約46,316.57 FLOWの償還バッファを使い果たし、約7,714.62 FLOWを追加しました。 -
ステップ4:攻撃者はMORE Marketsに目を向けました。eモードカテゴリー1(「Wrapped native tokens」)を有効にし、これによって
ankrFLOWとWFLOWが相関するFLOW資産として扱われ、ankrFLOWのLTVが78.5%から97%に引き上げられました。彼らはstakeBondsWithCode()を通じて約7,639,125.76 FLOWを入金し、対応するankrFLOWを解放し、それを担保として供給し、約5,668,483.10 WFLOWを借り入れました。その借入をアンラップし、同じ経路を通じて再び送り込むこと(ループ借入の1ラウンド)で、同額のankrFLOWが追加され、担保が約13,307,608.86 ankrFLOWとなり、それが約9,819,641.05 WFLOWの2度目の借入を支えました。合計負債は約15,488,124.15 WFLOWとなり、これは~1.19985 WFLOWのオラクル比率における担保価値の約97%に相当します。
ステップ4の担保は、それをミントするのにかかったコストよりも多くを返しました:受益トークン経路では1つのFLOWが1つのankrFLOWをミントし、オラクルはそのankrFLOWを約1.19985 WFLOWと評価し、eモードはそれに対して97%の借入を許可したため、供給された約1,331万ankrFLOWは約1,549万WFLOWの負債を支え、投入されたFLOW1つあたり約1.16WFLOWとなりました。攻撃者は、2度目の借入でWFLOW準備金が空になったため、一度しか循環させませんでした。
その約1,549万WFLOWは総借入額であり、これがMORE Markets準備金から流出したと報告された15.5M WFLOWです。そのうち約567万WFLOWはアンラップされ、流動的な収益として保持されるのではなく、追加担保として循環に回されました。約9,819,641.05 WFLOWの2度目の借入がアンラップされると、攻撃者は最終的な現金化として約9,819,641.05 FLOWを保有しました。その現金化がどこから来たかによって帰属させると、そのうち約7,630,412.39 FLOWはUniswap V3プールから、約8,713.37 FLOWはFlowStakingPoolから、そして約2,180,515.29 FLOWはMORE Marketsから来ています。
結論
一方のエントリーポイントは保護するがその兄弟エントリーポイントは保護しない状態チェックが、ここでの根本原因です:無効化されていたはずのミント経路が呼び出し可能なままであり、その比率は16ヶ月にわたる週次更新の間、更新されないままでした。したがって、この経路を通じてルーティングされたすべてのFLOWは、証明書経路が決して提供しなかったであろう価格でankrFLOWを生成し、攻撃者はその不一致を、Uniswap V3プール、ステーキングプールの償還バッファ、そして貸出市場を通じて循環させ、各段階で安価なankrFLOWをWFLOWの流動性に変換しました。
一時停止の保護は、期待される呼び出し元だけでなく、無効化されたロジックに到達するすべてのエントリーポイントで強制されなければなりません。また、休止中の経路に対応する比率フィードは、最新の状態に保つか、リバートするようにすべきです。貸出プロトコルはまた、そのミント価格がオラクル価格とは独立に設定される流動性ステーキングトークンに対して、高いeモードLTVを付与することを避けるべきです。ミントコスト、償還価値、オラクル価格を一緒に監視することで、その乖離が明らかになったでしょう。
Aquifer
2026/08/31、Solana上のプロプライエタリなマーケットメイカーAMMであるAquiferが、USDC、USDT、HYPE、cbBTC、CASHをはじめとする16種類の他のトークンにまたがる212件の成功したスワップにわたって、約247万ドル相当が悪用されました[4]。各スワップは各方向に1回ずつ、計2回のトークン転送として決済されますが、Aquiferは呼び出し元がそれぞれの転送を実行するプログラムを選択できるようにしながら、その選択を一度も確認していませんでした。攻撃者は、本来Aquiferに支払うべき転送に対して自身のプログラムを指定し、それは何も移動させずに成功を報告しました。一方、逆方向の転送は正真正銘のToken Programを通じて実行され、Aquiferの金庫から実際の資産を引き渡しました。
背景
Aquiferはプロプライエタリ・マーケットメイカー(Prop AMM)であり、Uniswap V2スタイルのプールのように定積曲線に沿ってスワップの価格を決めるのではなく、専門のマーケットメイカーが自身の在庫を供給し、買い気配値と売り気配値を維持することを意味します。Aquiferは自身の気配値とリスク状態から価格を導出し、その後ユーザーのトークンアカウントと自身の金庫との間で取引を決済します。
Solanaでは、Token Programは転送、ミント、バーンといった操作を実装する実行可能プログラムです。Tokenkegは元祖SPL Token Programであり、Token-2022はその拡張可能な後継です。それぞれが単一の資産ではなく、多数の異なるトークンを管理します。Mint Accountは1種類のトークンタイプを識別し、その供給量、小数点桁数、権限を保持します。Token Accountは1つのMintに対する1人の保有者の残高を保持します。両方とも、それらを管理するToken Programによって所有されています。Aquiferの金庫は、Aquiferのプログラム派生アドレス(PDA)によって管理されるToken Accountです。
トレーダーは、Aquiferのswap命令を呼び出し、2回の転送それぞれについてそれを実行すべきToken Programを含む、それが触れるアカウントを渡すことで取引します。Aquiferはクロスプログラム呼び出し(CPI)を通じてそれらのトークンを移動させます:転送命令を構築し、Instruction.program_idによって指定されたプログラムにそれを引き渡します。転送形式の命令データは、正しいToken Programがそれを実行した場合にのみ、実際のSPLトークンを移動させます。
脆弱性の分析
問題のあるプログラムはAquifer(AQU1FR...Tz45)です。公開されているソースコードでこのデプロイ済みバイトコードに一致するものはないため、以下の分析はプログラムの逆アセンブルから復元されたものです:fn_という名前は、コードのオフセットによって内部関数にラベルを付けたものであり、ここで使われている名前(swapも含めて)は、開発者による命名ではありません。このプログラムには、TokenkegとToken-2022のみを受け入れるアローリスト関数fn_49740()が含まれていますが、swap経路上でこれを呼び出すものは何もありません。swapを処理する際、プログラムはハードコードされたTokenkeg定数を使ってfn_45f20()とfn_46bf8()の両方を通じて両方の転送命令を構築します。これは必然的にそれらの内部チェックを満たしますが、その後、それを呼び出す前に、各命令のprogram_idを呼び出し元が指定したToken Programで上書きしてしまいます:
// Semantic reconstruction of the code the program runs for a swap; construct_transfer()
// stands for fn_45f20() and fn_46bf8(), and the allowlist fn_49740() is never reached.
let checked_program = TOKENKEG_ID;
let mut output_instruction = construct_transfer(checked_program, output_accounts);
let mut input_instruction = construct_transfer(checked_program, input_accounts);
// Unchecked caller inputs replace the value that was checked.
output_instruction.program_id = caller.token_program_b.key;
input_instruction.program_id = caller.token_program_a.key;
// Each invoke() hands the transfer instruction to whichever program the caller named.
invoke(output_instruction)?;
invoke(input_instruction)?;
ここでTOKENKEG_IDはTokenkegQfeZyiNwAJbNbGKPFXCWuBvf9Ss623VQ5DAを指します。検証を通過する値は、実行される値とは決して一致しません。これに加えて、このプログラムは入力CPIが戻った後に金庫が受け取った金額を検証しないため、何も転送せずに成功するCPIが支払いとして受け入れられてしまいます。
攻撃の分析
このインシデントは212件の成功した攻撃トランザクションで構成されています。以下の分析は、代表的な1例であるトランザクション4pBV1G...T3bfに基づいています。
-
ステップ1:攻撃者は
4,957.497101 USDCという名目上の入力でswapを呼び出し、これに対してAquiferは195,849.433667 KMNOの出力を計算しました。KMNOの脚についてはTokenkegを提供し、USDCの脚については自身のプログラムDMBpPM...NRgb68と、そのプログラムが所有する偽の入力アカウント9gsKJc...を一緒に提供しました。これは、USDCのMint、攻撃者の権限、そしてu64::MAXの残高を持つSPLトークンアカウントを165バイトで模倣したものです。 -
ステップ2:出力CPIはTokenkegに到達し、Aquiferの
KMNO金庫から攻撃者のトークンアカウントEUjkGc...(署名者7fTe9p...4gRk7Jが管理している)へ195,849.433667 KMNOを転送しました。

- ステップ3:入力CPIは
USDC転送形式のデータとともにDMBpPM...NRgb68に到達しました。そのプログラムは、偽の入力元9gsKJc...から本物のAquiferUSDC金庫7ULN1Y...へUSDCを一切移動させることなく、成功を返しました。

- ステップ4:Aquiferは両方のCPIの戻り値を受け入れたため、スワップはアトミックにコミットされました。
このトランザクションによる残高変動は以下のとおりです。
| アカウント | 変更前 | 変更後 | 変化 |
|---|---|---|---|
Aquifer KMNO金庫9BHsZp...FHSqG |
604,968.018277 KMNO |
409,118.584610 KMNO |
-195,849.433667 KMNO |
攻撃者KMNOアカウントEUjkGc... |
0 KMNO |
195,849.433667 KMNO |
+195,849.433667 KMNO |
Aquifer USDC金庫7ULN1Y... |
1,620,342.341679 USDC |
1,620,342.341679 USDC |
0 USDC |
これらの数字はこの例のトランザクションのみを説明するものであり、インシデントの総損失額ではありません。
結論
根本原因は、価格やオラクルの操作ではなく、決済経路における未検証の呼び出し元入力です:スワップ経路はToken Programの定数を検証し、その後それを命令の呼び出し前に呼び出し元が指定したものに置き換えていたため、Aquiferに支払うべきだった転送を実際に実行するプログラムは、呼び出し元の選択次第でした。金庫に対する転送後の残高チェックが存在しないため、トークンを移動させずに成功を返すプログラムは支払いを満たしてしまい、一方で逆方向の転送は実際の資産を引き渡してしまいます。
各CPIは、呼び出し元が指定したアカウントから取得するのではなく、Mint Accountから解決される、移動対象のMintを所有するToken Programに紐づけられるべきです。また、金庫の残高は入力転送の前後で読み取られるべきであり、金庫が実際に指定された量を受け取らない限り、スワップはリバートすべきです。
Notional Finance
2026/09/03-09/04(UTC)、Ethereum上のNotional Finance V1が悪用され、約173万ドル相当が69,257.37 DAIと1,658,524.86 USDCとして流出しました。あるアカウントが債務を負う前に、プロトコルはそのアカウントが保有・負っているすべてのものを評価します。その経路上の安全でない数値変換により、適切な規模の債務がゼロに畳み込まれてしまいました。そのため、このチェックは負債が消滅したアカウントを通過させてしまい、そのアカウントが作り出した大きな請求権は、攻撃者が支配する別のコントラクトに無傷のまま残っていました。攻撃者は、その偽造した請求権がプロトコルの次の満期(UTC真夜中)に来るようタイミングを合わせ、数分後にそれを決済して、プロトコルがまだ保有していたDAIとUSDCを引き出しました。
背景
Notional Finance V1はEthereum上の固定金利貸出プロトコルです。所定の満期におけるキャッシュフローをfCashで表現します:CASH_RECEIVERは満期に資産を受け取る権利を持つポジティブなポジションであり、CASH_PAYERはそれを支払う義務を負うネガティブなポジションです。各アカウントのfCashおよびその他のポジションは、そのPortfolioで追跡されます。ここで資産は、そのキャッシュグループと満期の組み合わせによって識別されます。キャッシュグループは、それが決済される通貨を固定します。単一の資産の想定元本、すなわち満期時に決済される金額はuint128です。
ERC1155Trade.safeTransferFrom()は、2つのアカウント間にfCashのペアを作成します。この呼び出しはERC-1155転送のような形をしていますが、実際には何も移動しません:Portfolios.mintfCashPair()を呼び出して、2つの相殺し合うポジション、すなわち受取人のためのポジティブなポジションと、支払人のための等しいネガティブなポジションを作成します。それぞれのサイドは_upsertAsset()によって対応するPortfolioに書き込まれます。これは、キャッシュグループと満期の両方が一致する既存のエントリにのみ新しいポジションを畳み込み、2つの想定元本をSafeUInt128でチェックされた加算によって加算します。
支払人の支払能力はfreeCollateral()を通じてチェックされ、これはアカウントのEscrowの現金残高とそのPortfolioの評価額を組み合わせ、通貨ごとにエントリを合算して符号付きのint256にします。各通貨残高は、その通貨の為替レートによってETHに変換され、そのレートと小数点スケーリングは整数除算として適用されます。最終的なフリー担保は非負でなければなりません。満期時、fCashポジションはEscrow.portfolioSettleCash()を通じてアカウントの現金残高に決済され、その後、プラスの現金残高は対応する基礎資産としてEscrowから引き出すことができます。
脆弱性の分析
問題のあるコントラクトは、想定元本の上限なしにfCashペアをミントするERC1155Tradeエントリーポイント(0xbba8...ef08)と、その担保評価に_convertToETH()内の2つの算術上の欠陥を抱えるEscrow(0x9abd...f683)で、これらは債務をゼロと評価してしまう可能性があります。

第一に、チェックされていない縮小変換が大きな債務をゼロに切り詰めることがあります。この関数はbalance.abs()を生のキャストで直接uint128に変換し、その値が対象の型に収まるかどうかを一度も確認しません。この2つの型はまったくかけ離れています:符号付きのint256は2^255 - 1まで達しますが、uint128は2^128 - 1で止まります。したがって、ちょうど-2^128の残高はint256内に難なく収まり、balance.abs()は2^128をそのまま生成します。失敗するのはキャストの方です:2^128はuint128が保持できる範囲をわずかに超えたところに着地し、0にラップします。そのため、続く評価はゼロ残高に対して動作し、負債全体がフリー担保計算から抜け落ちます。同じ関数は、計算されたETH値の後続の変換には範囲外の結果でリバートするSafeCast.toUint128()を使用していますが、先の変換は直接キャストのままであり、静かに切り詰めます。
第二に、整数除算が小さな債務を切り捨てゼロにすることがあります。er.rateDecimalsとbaseDecimalsによる除算は余りを切り捨てるため、十分に小さな債務もまた0と評価され、フリー担保から除外されてしまいます。
攻撃の分析
以下の分析は、トランザクション0xe1589a...25d60aに基づいています。

-
ステップ1:2026年9月3日23:58:47 UTCに、攻撃者は
ERC1155Trade上のsafeTransferFrom()を呼び出し、cashGroupId = 2と満期タイムスタンプ1788480000(2026年9月4日00:00 UTC、73秒先で、プロトコルが開いていた2つの満期のうち近い方)を用いて、金額1のfCashペアをミントしました。このミント中、_upsertAsset()は攻撃者のコントラクトにネガティブなfCash負債を、受取人コントラクトにポジティブな請求権を記録し、Portfoliosは即座に攻撃者のコントラクトのフリー担保をチェックしました。そのコントラクトはどの通貨でも残高を保持していなかったため、新しい負債のいかなるプラスの評価も、そのチェックを失敗させたはずでした。第二の欠陥がそれをカバーしました:現在の為替レートでは、1の債務は2回の整数除算を生き延びられず、そのためゼロと評価されてチェックを通過しました。 -
ステップ2:攻撃者は再び
safeTransferFrom()を呼び出し、金額uint128.max(340,282,366,920,938,463,463,374,607,431,768,211,455)で2つ目のペアをミントしました。このペアはcashGroupId = 2を再利用しましたが、満期タイムスタンプ1796256000(2026年12月3日00:00 UTC、もう一方の開いている満期)を持ち、そのポジティブなサイドを異なる受取人コントラクトに送りました。ネガティブなサイドは再び攻撃者のコントラクトに着地し、ステップ1のものの隣に並びました。1回のミントは決して2^128 - 1を超えることができず、常に切り詰めが起こる値の1単位手前に留まるため、そこに到達するには2つのポジションが必要です。異なる満期は、この2つを別々のエントリに保ち、マージ時にリバートしたであろうチェックされた加算の届かないところに置きました。しかし、共有されたキャッシュグループは、依然として評価時にこの2つを同じ通貨スロットに置き、そこでステップ1の1単位がその合計をちょうど-2^128まで押し上げました。 -
ステップ3:担保チェック中、Escrowプロキシは
convertBalancesToETH()を呼び出しました。合算されたネガティブな残高はちょうど-2^128に達し、_convertToETH()に渡され、ゼロに切り詰められました。そのため、そのアカウントのETH建て負債はゼロと報告されました。 -
ステップ4:担保会計から負債が消えたことで、2つ目の受取人コントラクトは、プロトコルが利用可能な担保として扱う大きなプラスのfCashポジションを保有していました。同じトランザクション内でなお、この受取人コントラクトはその担保に対してさらに2つのfCashペアをミントし、そのプラスのサイドをさらに2つの受取人コントラクトに引き渡しました:
cashGroupId = 2(DAIで決済される)の下で69,257.37、cashGroupId = 3(USDCで決済される)の下で1,658,524.86です。両方の数字は、攻撃者がトランザクションの開始時にEscrowに対して行ったbalanceOf呼び出しから来ていたため、各請求権はEscrowが実際に保有していた残高に合わせてサイズが調整されていました。それぞれの満期は1788480000、73秒先でした。 -
ステップ5:2026年9月4日00:01:35 UTC、その満期から95秒後に、攻撃者はトランザクション0xc3f3e3...a24efaで満期を迎えた2つの請求権を決済し、Escrowから約
69,257.37 DAIと約1,658,524.86 USDCを引き出しました。その資金は後に0x8aaf...3be6へ転送されました。
結論
このミント経路には想定元本の上限が課されておらず、担保評価における2つの算術上の欠陥がそれぞれ1つの支払能力チェックを通過させてしまいました:丸め処理は何も保有していないアカウントに最初の負債を負わせることを許し、静かな縮小変換はその後、ちょうど適切な大きさの債務をゼロと評価し、2つ目のチェックは深刻な債務超過状態にあるアカウントを通過させました。受取人コントラクト上の偽造されたプラスのポジションはその後、利用可能な担保として扱われ、それによって攻撃者はそれをEscrowの残高に一致する請求権に分割し、Escrowがまだ保有していたDAIとUSDCを引き出すことができました。
符号付き残高は、いかなる縮小変換の前にも範囲チェックされなければならず、その入力を表現できない変換は、切り詰めるのではなくリバートすべきです。より広く言えば、支払能力チェックは、そのゼロへの喪失がキャストによるものであれ丸め処理によるものであれ、プラスの負債をゼロと報告することは決してあってはなりません。同じ関数がさらに下流ですでに使用しているチェック済みキャストを適用すること、あるいは単一のペアミントが作成できる想定元本に上限を設けることは、それぞれ単独でもこのエクスプロイトを阻止していたでしょう。
参考文献
[3] https://x.com/flow_blockchain/status/2094506622429307061
[4] https://bitquery.io/investigations/aquifer-solana-hack-2-5-million
BlockSecについて
BlockSecは、フルスタックのブロックチェーンセキュリティおよび暗号資産コンプライアンスのプロバイダーです。私たちは、プロトコルおよびプラットフォームのライフサイクル全体にわたって、お客様がコード監査(スマートコントラクト、ブロックチェーン、ウォレットを含む)を実施し、攻撃をリアルタイムで阻止し、インシデントを分析し、不正資金を追跡し、AML/CFT義務を果たすことを支援する製品とサービスを構築しています。
BlockSecは、権威ある会議で複数のブロックチェーンセキュリティに関する論文を発表し、DeFiアプリケーションのゼロデイ攻撃を複数報告し、2,000万ドル以上を救出するために複数のハッキングをブロックし、数十億ドル相当の暗号資産を保護してきました。
-
公式ウェブサイト:https://blocksec.com/
-
公式Twitterアカウント:https://twitter.com/BlockSecTeam



