過去1週間(2026年10月12日–2026年10月18日)に発生した注目すべきセキュリティインシデントのうち、以下の3件を取り上げます。推定損失額の合計は約387万 USDです。
| 日付 | インシデント | 種類 | 推定損失額 |
|---|---|---|---|
| 2026年10月12日 | Autheo Network | 秘密鍵の漏洩 | 約176万 USD |
| 2026年10月14日 | UNCX Network | 会計処理上の誤り | 約80.7万 USD |
| 2026年10月18日 | Qubic Wallet | 脆弱なシード生成 | 約130万 USD |
損失額は実現価値に基づく推定値です。
- UNCX Network: 集計シェアの計算に欠陥があり、今週のインシデントの中でコントラクトの会計処理に関する教訓を最も明確に示しているため、詳しく分析します。
今週の注目インシデント:UNCX Network
2026年10月14日、攻撃者はEthereum、BNB Chain、Base、Robinhood Chain上のUNCX NetworkのSmart Token Vesting V2コントラクトを悪用しました。このインシデントでは、確認された11件の資産流出トランザクションで10種類の資産が被害を受け、実現または移転された価値は約80.7609万 USDに上りました [1]。根本原因は、トークンとシェアの交換比率が異なる時点で作成されたベスティングのトランシェを混ぜ合わせた、集計シェアの計算上の欠陥でした。その結果、ポジション単位の記録とグローバルなシェア供給量が乖離し、攻撃者が管理するポジションから、攻撃者が拠出した量を超えるトークンを引き出せるようになりました。
背景
UNCX NetworkはマルチチェーンのDeFiインフラプロバイダーです。同社のSmart Token Vesting V2では、プロジェクトがERC-20トークンを期間に応じたベスティングスケジュールに従ってロックできます。各スケジュールはベスティングIDで識別され、トークン、作成者、受益者、総量、リリース済みの量、1つ以上のアンロック対象トランシェが記録されます。追加拠出が可能なソフトポジションには、作成後にトランシェを追加できます。また、その作成者は未ベスト分をキャンセルできます。
同じトークンのポジションはVestingManager内の残高を共有し、プロトコルはシェアを使って各ポジションがその残高のどれだけを保有するかを決定します。したがって、会計処理には関連する2つの層があります。
Global pool, per token
create / topUp: receive tokens and mint shares
release: convert releasable shares into tokens
_totalTokenShares[token]: active shares across all positions
Position record, per vesting ID
_depositedShares[id]: share basis assigned to the position
totalAmount: token amount represented by its tranches
released: shares already paid to the beneficiary
リリース時、マネージャーはまずスケジュールのうちベスト済みの割合を計算します。_depositedShares[vestingId]を使って、その割合に相当するtotalAmountをベスト済みシェアに換算し、releasedを差し引いた結果をreleasableSharesとします。以下の式で、tokenBalanceは、当該スケジュールのトークンについてマネージャーが現在保有するERC-20残高であり、そのトークンの全ポジションで共有されます。一方、_totalTokenShares[token]は対応する有効シェアのグローバル供給量です。支払われるトークン量は、両者の比率によって決まります。
amount = releasableShares * tokenBalance / _totalTokenShares[token]
ベスティング関数を通じた入金では、トークン残高とシェア供給量の両方が増えます。マネージャーへのERC-20の直接送金ではトークン残高だけが変わるため、各シェアが表すトークン量が変化します。このシェアモデルでプールされたポジションを扱うには、ライフサイクルのすべての操作でポジションの記録とグローバル供給量の整合性を保つ必要があります。
脆弱性分析
影響を受けたEthereum上のVestingManager(0xA98F...99F5)には、release()、topUpVesting()、cancel()を通じて悪用可能になる、集計シェアの計算上の欠陥があります。ほかの3つのネットワークにも、同等の影響を受けるデプロイが存在します。
以下の分析では、状態の変化を比較しやすくするため、D、R、Vを略記号として導入します。これらの記号はコントラクトのコードには登場しません。表では、各記号に対応するコントラクトの変数または式を示します。対応する値は、2種類の数量に分かれます。
| 記号 | コントラクトの変数/式 | 数量の種類 | 意味 |
|---|---|---|---|
D |
_depositedShares[vestingId] |
シェア数 | ポジションについて記録されたすべての入金に割り当てられた累積シェア基準値 |
R |
schedule.released |
シェア数 | 受益者に支払われた累積シェア数 |
| - | schedule.totalAmount |
トークン量 | ポジションの現在のトランシェ全体について記録されたトークン総量 |
| - | vestedAmount |
トークン量 | 現時点で、それらのトランシェに従ってベスト済みとなったtotalAmountの部分 |
V |
vestedShare |
シェア数 | 単一の混合されたベスト済みトークン比率をDに適用して計算した累積ベスト済みシェア数 |
release()では、コントラクトはreleasableSharesを計算し、schedule.releasedを増やし、同じ数量を_totalTokenShares[schedule.token]から差し引きます。_depositedShares[vestingId]は減らしません。したがって、リリース後、そのポジションがグローバル供給量に寄与する有効シェアはD - Rだけですが、保存された基準値はDのままです。
function release(uint256 vestingId) external nonReentrant {
...
uint256 releasableShares = _getReleasableShares(vestingId);
uint256 balance = IERC20(schedule.token).balanceOf(address(this));
uint256 amount =
(releasableShares * balance) / _totalTokenShares[schedule.token];
schedule.released += releasableShares;
_totalTokenShares[schedule.token] -= releasableShares;
IERC20(schedule.token).safeTransfer(msg.sender, amount);
}
cancel()は、ポジションの集計シェア基準値に単一の混合されたベスティング比率を適用します。まずgetVestedAmount(vestingId)を通じてvestedAmountを計算し、次にトークン建てのベスト済み割合をV = (vestedAmount * D) / totalAmountとしてシェアに換算します。最後に、この関数はD - Vを、そのポジションから除去できる未ベスト済みシェアとして扱います。Vは累積値であり、過去の支払い後に残っているベスト済みシェアではありません。
function cancel(uint256 vestingId) external nonReentrant {
...
uint256 vestedAmount = getVestedAmount(vestingId);
uint256 remainingAmount = schedule.totalAmount - vestedAmount;
uint256 vestedShare =
vestedAmount * _depositedShares[vestingId] / schedule.totalAmount;
uint256 sharesToRemove =
_depositedShares[vestingId] - vestedShare;
_totalTokenShares[schedule.token] -= sharesToRemove;
_depositedShares[vestingId] = vestedShare;
schedule.totalAmount = vestedAmount;
schedule.cancelled = true;
IERC20(schedule.token).safeTransfer(schedule.creator, remainingAmount);
}
欠陥は、この混合計算そのものにあります。各トランシェには、それぞれ固有のトークン量、ベスティングの進捗、そして追加時に適用されたプール比率で発行されたシェア数があります。そのため、ベスト済みシェアはトランシェごとに計算してから合計すべきです。しかしコントラクトは、まずトークン量とシェア基準値を集計し、その後、結合されたポジションに単一のベスティング比率を適用します。保存するDとtotalAmountがそれぞれ1つだけだからです。この2つの方法が同じ結果になるのは、各トランシェのトークン当たりのシェア比率か、ベスティングの進捗が同じ場合に限られます。追加拠出前にトークンを直接送金するとプール比率が変わるため、新しいトランシェに付与されるトークン当たりのシェア数は、元のトランシェよりはるかに少なくなります。その状態で両トランシェに単一の混合ベスティング比率を適用すると、両者のベスト済みシェアの合計を表さないVが算出され得ます。
Rはポジションの累積ベスト済みシェアからすでに支払われているため、整合性のあるスケジュールはR <= Vを満たす必要があります。この不等式に違反することが、混合計算の問題を直接示しています。コードはポジションごとの有効シェア残高を別途管理せず、D - Vをグローバルな_totalTokenSharesから直接差し引きます。ほかのポジションが1000シェアを拠出し、このポジションの値がD = 100とR = 80であるとします。キャンセル前のグローバル供給量には1,000 + (D - R) = 1,020シェアが含まれます。混合計算の結果がV = 60になった場合、cancel()はD - V = 40を差し引き、グローバルなシェアを980だけ残します。これは、ほかのポジションが拠出した1000シェアより20少ない数です。グローバル供給量にこのポジションの有効シェア20しか含まれていなければ、40を差し引くとアンダーフローが発生して処理がリバートします。過大な差し引きが成功するのは、共有プールにほかのポジションが拠出したシェアが含まれているからです。その後、関数は_depositedShares[vestingId] = 60を保存しますが、schedule.releasedは80のままです。その結果、ポジションの記録はD < Rというあり得ない状態になります。
このコードはR <= Vを強制せず、sharesToRemoveをポジションの有効シェア以下にも制限しません。このため、キャンセルによって、すでにグローバル供給量に含まれていない過去のシェアを差し引けます。同時に、プールのシェア比率を使って返金額を換算することなく、totalAmount - vestedAmountをトークン単位で直接送金します。キャンセル自体で過大な支払いが発生するわけではありません。キャンセルはグローバルなシェア供給量を、各ポジションに記録された有効シェアの合計より少なくするだけであり、後の過大な償還が起こる条件を作ります。
攻撃分析
以下では、Ethereum上の代表的なトランザクション0x352065...c23823を分析します。攻撃コントラクトは、このトランザクション内で5種類のトークンに対して同じ基本手順を実行し、それぞれにフラッシュ流動性を使用しました。
-
ステップ1: 攻撃者は、作成時点ですぐにベスト済みとなる小規模なポジション
#5058と、同じくすぐにベスト済みとなる、はるかに大規模な追加拠出可能なソフトポジション#5059および#5060を作成しました。小規模なポジションは最後の引き出しに使うために残し、大規模なポジションではグローバルな会計処理を歪めるために必要な過去のシェア記録を作りました。 -
ステップ2: 攻撃者は2つの大規模なポジションをリリースしました。当初のプール比率はほぼ1:1で、各リリースでは純入金額とほぼ同量が戻りました。対応するシェアは
_totalTokenSharesから除かれましたが、元の値はポジション#5059と#5060の_depositedSharesの記録に残りました。リリース済みの量が記録されたのは、各スケジュールに別途設けられたreleasedフィールドだけでした。 -
ステップ3: 攻撃者は
VestingManagerにトークンを直接送金しました。これにより、リリース済みのシェアを戻さずにトークン残高を回復させたため、残存する各シェアが表すトークン量が急増しました。 -
ステップ4: 攻撃者は、リリース済みの大規模なポジションに追加拠出しました。追加拠出には、上昇後のシェア当たりトークン比率が適用されたため、相当量のトークンを入金しても、発行された新しいシェアは少数でした。この時点で、これらのポジションには、過去に記録された大きな基準値と、少量の有効シェアの拠出が併存していました。
-
ステップ5: 攻撃者は、追加拠出後の2つのポジションをキャンセルしました。各キャンセルでは、入金に対応するシェアの履歴を集計した値に、単一の混合ベスティング比率が適用されました。再計算された
vestedShareはリリース済みのシェア数を下回ったため、sharesToRemoveがそのポジションの有効シェアの拠出量を上回りました。各ポジションが追加拠出時に加えた有効シェアは少量だったにもかかわらず、グローバル供給量はポジション当たり約5.0e25の実体のないシェア分だけ減少しました。 -
ステップ6: 分母が大幅に過小評価された状態で、攻撃者は残しておいた小規模なポジションをリリースしました。例に挙げたトークンでは、約
5.73e22の生の単位での純入金額から、約1.07e26の生の単位が引き出されました。これは入金額の約1,873倍です。攻撃者は、歪められた比率を利用して作成とリリースを繰り返し、フラッシュ流動性を返済しました。

公式インシデントレポートでは、確認された11件の資産流出トランザクション全体で同じパターンが確認されました。一部のパターンは単一のトランザクション内で完結し、ほかのパターンでは関連する別のトランザクションで最後のリリースが行われました。UNCXは、攻撃者の元本、一時的なフラッシュ流動性の取引量、返還された資産を除外した後の、実現または移転された収益から約80.7609万 USDという数値を算出しました [1]。
結論
根本原因は、集計シェアの会計処理における欠陥でした。プロトコルは、トークンとシェアの交換比率が異なる時点で作成されたトランシェを結合し、その集計シェア基準値に単一のベスティング比率を適用しました。これにより累積ベスト済みシェアが誤算され、ポジションが現在拠出している量を超えるシェアを除去できるようになり、ポジション単位の記録とグローバルなシェア供給量が乖離しました。攻撃者は過小評価されたグローバル供給量を悪用し、ほかのベスティングポジションへの支払いに必要な共有残高からトークンを引き出しました。
変動するシェア比率で後から入金できる会計モデルでは、各トランシェを単一の比率で混合するのではなく、それぞれのトークン量、シェア基準値、ベスティングの進捗を保持すべきです。また、累積支払量が累積ベスト済みシェアを決して超えないこと、およびポジションが自身の有効シェア拠出量を超えてシェアを除去できないことを強制すべきです。
参考資料
[1] UNCX Network Smart Token Vesting V2インシデントレポート
Web3に最適なセキュリティ監査機関
ローンチ前に設計、コード、ビジネスロジックを検証



