著者:九九
編集:77
背景
2026 年 9 月 4 日、有名な分散型レンディング・プラットフォーム Notional Finance が攻撃を受け、損失は約 173 万ドルに上りました。以下は、スローミスト セキュリティ チームによる本件攻撃に関する具体的な分析です:
事前知識
Notional Finance V1 では、fCash は満期日付きの現金債権として理解できます。cash receiver は満期時に回収し、cash payer は満期時に支払います。プロトコルは両方のポジションを Portfolio に記録し、さらに自由担保(フリーチャージ)の確認によって、口座がポジションを建てることができる健全性の度合いを判断します。
この箇所で ERC1155Trade の safeTransferFrom が資産の台帳(記帳)機能を担っている。渡されるアセットの種類が cash receiver に該当する場合、コントラクトは Portfolios.mintfCashPair を呼び出す。その呼び出しにより、payer には 1 件の負債が計上され、receiver には同額の債権が計上される。この呼び出しは ERC1155 の転送のように見えるが、実際には fCash のペア鋳造が行われる。
新しいポジションが Portfolio に書き込まれた後は、フリーコラテラルの計算とチェックが必要になる。RiskFramework コントラクトはまず payer の fCash 負債を符号付き int256 に集計し、各通貨残高を ETH に換算して合算する。負数は支払う必要がある現金、正数はアカウントが持つ現金または債権を表す。この計算結果が、取引を許可してよいかどうかを決める。
ポジションの満期後、Portfolio は Escrow の portfolioSettleCash 関数を呼び出し、満期を迎えた fCash を現金残高へ変換する。その後、アカウントは引き出し(withdraw)関数によって、Escrow から該当する DAI または USDC アセットを取り出す。
根本原因
今回の攻撃の核心的な脆弱性は、Escrow 実装コントラクトが使用している ExchangeRate._convertToETH の中にあり、符号付き整数の絶対値を直接 uint128 に変換してしまう箇所が存在することだ。

攻撃者はまず同一 payer の負債ポジションを 2 回作成し、金額はそれぞれ 1 と uint128.max である。2 つの負債を合計するとちょうど 2^128 になる。リスク計算において payer の支払いは負数として計上されるため、Escrow の convertBalancesToETH に渡される重要なパラメータは -2^128 となる。
しかし、このコントラクトで使用されている Solidity のバージョンは 0.6.x である。そのため絶対値 2^128 が uint128 に強制キャストされるとオーバーフローして 0 に変わってしまい、この負債は ETH の評価結果に含まれない。
最後に mintfCashPair 関数では、payer のフリーコラテラルが 0 以上であることだけを要求するため、結果が 0 にオーバーフローした後はこのチェックを通過できる。十分な資産で負債をカバーできないアカウントでもポジションを作成できてしまう。
攻撃手順の分析
1. 前置トランザクション(0xe1589a19…d60a)において、攻撃者はまず複数の補助コントラクトを作成し、ERC1155Trade コントラクトの setApprovalForAl 関数を呼んで補助コントラクトへ承認を付与する。さらに事前に 2 つの CashMarket の満期日を照会し、以降の操作で渡す必要がある 3 種類の AssetId パラメータの値を計算しておく。

2. 続いて、攻撃者は ERC1155Trade コントラクトの safeTransferFrom 関数を呼び出して、金額 1 の fCash ペアを鋳造する。このペアは債券アセットの cashGroupId が 2 で、満期日のタイムスタンプは 1788480000(2026 年 9 月 4 日 8 時ちょうど)である。ここでは内部関数 _upsertAsset により、from アドレス(攻撃コントラクト)と to アドレス(受信コントラクト 1)それぞれの負債と見込み収益を更新する。

ペア鋳造が完了した後、Portfolios はすぐに攻撃コントラクトのフリーコラテラルをチェックする。最初の鋳造量が小さすぎ、現在の為替レートと精度のもとで ETH に換算すると 0 に丸められるため、最初のチェックは通過できる。
3. 攻撃者はその後、ERC1155Trade コントラクトの safeTransferFrom 関数を再度呼び出し、uint128.max の金額に相当する fCash ペアを 1 つ鋳造する。このとき、債券アセットの cashGroupId は 2 だが、満期日のタイムスタンプは 1796256000(2026 年 12 月 3 日 8 時ちょうど)となっている。
ここには細かい点がある。攻撃者は満期日が異なる 2 つの異なるアドレスにそれぞれ、異なる満期日の債券アセットを鋳造している。これは from アドレスの負債を更新する際、債券アセットが同一であれば単純に累積されるためである。そうすると加算処理の段階でオーバーフローが発生し revert してしまう(コントラクトでは safeMath ライブラリを使用している)。

4. 2 回目の鋳造で、from アドレス(攻撃コントラクト)と to アドレス(受信コントラクト 2)の負債および見込み収益を更新した後、mintfCashPair 関数は freeCollateral 関数を呼び出して from アドレスのネット・コラテラル・ポジションを計算し、その値が 0 以上である必要があることをチェックする。

RiskFrameork コントラクトの getRequirement 関数内で、from アドレスに対する 2 回の鋳造の負債合計をまず加算する。

その最終結果は -2^128 となり、続いて Escrow コントラクトの convertBalancesToETH 関数がこの値を ETH 建てに変換する。その convertBalancesToETH 関数はさらに ExchangeRate ライブラリの _convertToETH 関数を呼び出し、計算処理を行う:


_convertToETH 関数の中身を追うと、まず balance を絶対値にしてから uint128 を用いて 256 ビットを 128 ビットへ変換していることが分かる。上記の from における負債合計値が -2^128 であるため、絶対値を取った 2^128 を uint128() に強制キャストすると上限を超えてオーバーフローし、0 に丸め込まれる。つまりプロトコルは、from アドレスのフリーコラテラル・ポジションが 0 であると誤認し、その結果 mintfCashPair 関数内の最終チェックに通る。

5. 次に、受信コントラクト 2 は自分のポジションをさらに 2 つの別の補助コントラクトへ分割する。これらの safeTransferFrom でも引き続き mintfCashPair 関数が呼び出される。しかしこの時点で payer は 2 回目の鋳造時の受信コントラクト 2 であり、前段で得られた巨大な見込み収益ポジションを保有している。リスク計算ではそれを正の債権として扱うため、担保チェックを通過する。その結果、2 つの補助コントラクトは満期後に現金へ換算できる receiver の fCash を得る。

6. その後、攻撃者は 2 回目の正式な利益獲得トランザクションを開始する。この取引では、前の取引で作成した 2 つの保有ポジションに対応する補助コントラクトをそれぞれ決済し、対応するアセットの現金残高を増やす。続いて Escrow コントラクトの withdraw 関数を呼び出して資産引き出しによる利益を得る。

まとめ
今回の攻撃イベントの核心は、攻撃者が 2 つの別々のポジションを利用して payer の負債合計を 2^128 に揃えたうえで、Escrow にその負債を ETH に換算させる点にある。型変換におけるオーバーフローの脆弱性により結果を切り詰めて 0 にしてしまうため、リスクチェックが通ってしまう。
SlowMist セキュリティチームは、型変換を行う前に必ず範囲チェックを実施し、type(uint128).max を超える結果は直ちに拒否すべきだとプロジェクト側に提案している。さらに、有符号金額、精度スケーリング、アセットの額面に関わる境界については、極値テストを追加し、特に uint128.max、uint128.max + 1、負数の絶対値といった入力をカバーすることが重要である。Openzeppelin の SafeCast ライブラリを参照または使用して、変換処理を行うこともできる。

