⚠️

【60億円流出】Solanaステーキングで何が起きたのかーAPI悪用の可能性と対策

に公開

ハッキングの概要

2025 年 9 月 8 日、約 60 億円相当の$SOL がハッキングされるニュースがありました。(リンク)
事前の 2025年8月31日に送られた「Unstake(Deactivate)」に見える Tx でした。そこに Authorize(Withdrawer) を8本同梱する“権限すり替え”が仕込まれていました。そして2025年9月8日に攻撃者が新しい Withdraw 鍵で Withdraw を複数回実行し、ドレインという流れです。
ハッキングを受けた企業はユーザー損失を出さない方針を表明。流出のあった Staking Provider は Dashboard/API を停止し、段階的復旧・調査を発表しました。


前提知識と基本構造

事件の全体を理解するうえで必要な前提知識をここで整理します。

Stake の運用フロー

まずは「Stake がどのように行われるのか」その手順をフロー図で見ていきます。
以下のフロー図より Staking の通常プロセスが確認できます。

  1. Stake Account 作成 → Authority 設定
    Stake Account を新規作成し、Staker 権限と Withdrawer 権限を設定します。
    (任意)Staker 権限と Withdrawer 権限を一緒にせず、別アカウントに分けることも可能です。

  2. Delegate(委任)→ Active(運用)
    バリデーターに SOL を委任し、Staking 報酬の獲得が開始されます。

  3. Deactivate(解除)→ Cooldown → 未委任
    Staking を解除し、Cooldown 期間を経て引き出し可能な状態になります。

  4. Withdraw(出金)
    未委任状態の SOL を任意のアドレスに引き出す。

Stakeのフロー図

権限とロックアップについて

前提

  • Stake アカウントは以下の 2 つの 権限アカウント(公開鍵) に分離して設定可能:
    1. Staker 権限アカウント:ステーク操作に署名する鍵を指す
    2. Withdrawer 権限アカウント:出金と権限更新に署名する鍵を指す
  • Lockup(ロックアップ)を任意で設定でき、その管理者が Custodian(カストディアン)となる。

権限アカウントでできること

  • Staker 権限アカウントでできることは以下の 5 項目

    1. Delegate(委任)
    2. Deactivate(解除)
    3. Split(分割)
    4. Merge(統合)
    5. Authorize: Staker(Staker 権限アカウントの更新)
  • Withdrawer 権限アカウントでできることは以下の 2 項目

    1. Withdraw(出金)
    2. Authorize: Staker / Withdrawer(両方の権限アカウントの更新)

Lockup(ロックアップ)とは

Stake アカウントにロック期限(時刻/エポック)と Custodian(管理者公開鍵)を設定する仕組みです。

ロック期間中の制約について

Withdraw(出金)と Authorize (権限更新) は Custodian の署名が必要となります(最後の防御壁)。
Delegate/Deactivate/Split/Merge/Authorize: Staker などのステーク操作は通常どおり Staker 権限アカウントで実行が可能です。
今回の事案では Deactive 操作に過剰な権限を持つ Withdrawer 権限アカウントで署名してしまいました。さらにロックアップも未設定だったため、混入された Withdrawer 更新操作まで成功してしまいました。

補足:withdrawal address を事前に指定しておくことのできる ethereum stake と違って、solana では withdraw する際に送付先を指定する必要があります。そのため、Withdrawer 権限アカウントを乗っ取られることをいかに防ぐかが重要です。

Lockup効果フロー図

一般的にAPI/自動化は Stakerのみに限定し、WithdrawerはHSM/マルチシグで隔離Custodianはさらに別管理、とするのが安全。

Solana のトランザクション構造

1 つのトランザクションに複数の Instruction(処理ステップ)を並べられるため、見た目は Deactivate(解除) にしか見えません。しかし、同じ Tx の中に Authorize:Withdrawer(権限の変更)同梱できてしまいます。
(事件発生時の Tx 構成については後述している Tx の詳細を参照)

Transaction {
  // 誰が承認したかが記載される
  signatures[]
  message: {
    header
    // 使う全アカウント
    accountKeys[]
    recentBlockhash
    // 命令の情報を格納
    // 今回のハッキング事件だと、このinstructionsに
    // Deactivateと合わせて、Authorizeが含まれていた
    instructions[]:
    [
        // Deactivate(解除)の命令
        {
          "program": "stake",
          "programId": "STAKE_PROGRAM_ID",
          "parsed": {
            "type": "deactivate",
            "info": {
              "clockSysvar": "SYSVAR_CLOCK",
              "stakeAccount": "STAKE_ACCT_DEACTIVATE",
              "stakeAuthority": "CURRENT_AUTHORITY_KEY"
            }
          }
        },
        // Authorizeの命令
        // このサンプルでは命令が1つだが、今回の事件はこれが8本同梱されていた
        {
          "program": "stake",
          "programId": "STAKE_PROGRAM_ID",
          "parsed": {
            "type": "authorize",
            "info": {
              "authority": "CURRENT_AUTHORITY_KEY",
              "authorityType": "Withdrawer",
              "clockSysvar": "SYSVAR_CLOCK",
              "newAuthority": "NEW_WITHDRAWER_KEY",
              "stakeAccount": "STAKE_ACCT_AUTHZ_1"
            }
          }
        }
    ]
  }
}
  • Stake Program の主な命令
    • Deactivate(解除)
    • Authorize / AuthorizeWithSeed(権限付け替え: Staker or Withdrawer
    • Withdraw(未委任 SOL の引き出し)
超重要:Tx内に Authorize(Withdrawer)(または AuthorizeWithSeedauthority=withdrawer)が1本でもあれば最優先で警戒する必要あり(以下に参考画像を記載)。

authorize参考画像


事件の詳細

全体の時系列(2025年8月31日 → 2025年9月8日)

全体の時系列をフロー図で記載しています。どのような流れで流出に至ったのかを見ていきましょう。
全体の時系列図


原因となったトランザクションの詳細

Skeleton Key Tx(2025年8月31日)

署名前プレビューなどでは、一見 Unstake の Tx で Deactivate の命令だけが入っているように見えるため、同じ Tx 内に不正な命令が入っていることに気づきにくい状態となります。
今回の事件では、同じTx内に Authorize(Withdrawer) が8本含まれていました。その結果、 8 つの Stake 口座の Withdraw 権限が攻撃者側へ移るように構成されていました(詳細な Tx 構造については参照リンク記載)。
参考として Deactivate の Tx(1 枚目)と Authorize(Withdrawer)の Tx(2 枚目)のうちの1つを以下に記載しています。これと同等のものが他に 7 本含まれていた形となります。

DeactivateのTx

AuthorizeのTx

参照リンク:Rektの記事に掲載されていたUnstake Txリンク

ドレイン(2025年9月8日)

攻撃者アドレスから Withdraw が 8 回実行されており、Tx にはテスト送金と大口の移動が混在していました。こちらも参考として参照リンクにドレイン Tx を記載しておきますので、詳細を確認してみてください。

参照リンク:ドレインTx1ドレインTx2ドレインTx3ドレインTx4ドレインTx5ドレインTx6ドレインTx7ドレインTx8

なぜトランザクションが改ざんされたのか

今回の事件では、侵入経路については大きく 2 点に絞られます。

  • ユーザーからの Unstake リクエスト生成時にフロントエンドから改竄されてしまった
    考えられる原因としては以下です。
    1. 端末やブラウザがマルウェアに汚染された状態で操作してしまった
    2. 偽サイトや偽の UI に気付かず画面を操作してしまった
    3. 外部ライブラリ(NPM など)に悪意のある改変が含まれているアプリから操作してしまった
  • ユーザーからの Unstake Tx 組成時に API 側でリクエストが改竄されてしまった
    考えられる原因としては以下です。
    1. API キーやトークンなどの認証情報が漏れてしまっていた
    2. ビルド・デプロイ経路や API サーバーが乗っ取られてしまっていた

実際にどの部分で Tx が改竄されてしまったのかを以下のフロー図で見ていきましょう。(改ざんを許してしまった原因は未公表のため、不明です。そのため、攻撃対象と見られる箇所に赤枠をつけています。)

リクエスト送信のフロー図


今回の事案に対する対策の一覧と詳細

セキュリティ対策は大きく 2 つに分けられます。

  1. 侵入の予防
  2. 侵入後の被害最小化

一般的に注目は"1. 侵入の予防"に偏りがちですが、"2. 侵入後の被害最小化"も同等に重要と考えられます。
現時点で、"1. 侵入の予防"に関連する API への侵入経路については未公表のため不明です。そのため本記事では "2. 侵入された場合の被害・情報流出の最小化" に焦点を当て、有効な対策を 5 つ挙げました。

  1. UIで可視化:目視確認ができるようにし、誤操作を防ぐための対策
  2. 送信前デコード:事前チェックし、怪しい Tx を飛ばさせないための対策
  3. 鍵の物理・権限分離 :分離することでより安全に運用が可能となる対策
  4. 監視の強化:早期の問題発見と問題への対処を早めるための対策
  5. ロックアップStakeの活用:事前の対策をすり抜けられても防御できるような対策

リクエスト送信のフロー図対策編

1) UIで可視化

  • 送信前プレビューに全Instruction必ず表示。
  • Authorize(Withdrawer) を検出したら赤い警告ラベル再承認を要求。
  • 署名者(どの鍵が必要か)も明示して署名者(鍵)が間違っていないかを発見できる仕組みにする。

2) 送信前デコード

  • Tx 送信前に必ず全 Instruction を自前デコーダで解析を実施する。
  • Stake ProgramAuthorize(Withdrawer) / AuthorizeWithSeed(Withdrawer)全面ブロックする。
  • 本当に必要な変更だけホワイトリストで許可する。

TypeScript(@solana/web3.js)の例

import {
  VersionedTransaction,
  TransactionMessage,
  PublicKey,
  StakeInstruction,
} from "@solana/web3.js";

const STAKE_PROGRAM_ID = new PublicKey(
  "Stake11111111111111111111111111111111111111",
);

type AllowedChange = { from: string; to: string };
const WITHDRAW_AUTH_WHITELIST: AllowedChange[] = [
  // { from: "OldWithdrawPubkey...", to: "NewWithdrawPubkey..." }
];

export function assertNoMaliciousStakeAuth(tx: VersionedTransaction) {
  const legacy = TransactionMessage.decompile(tx.message);
  for (const ix of legacy.instructions) {
    if (!ix.programId.equals(STAKE_PROGRAM_ID)) continue;
    const t = StakeInstruction.decodeInstructionType(ix);
    if (t === "Authorize" || t === "AuthorizeWithSeed") {
      const p =
        t === "Authorize"
          ? StakeInstruction.decodeAuthorize(ix)
          : StakeInstruction.decodeAuthorizeWithSeed(ix);

      const isWithdrawer = p.authority === 1; // 1 = Withdrawer
      if (isWithdrawer) {
        const newAuth = p.newAuthorizedPubkey.toBase58();
        const from = "(read via stake-account RPC)";
        const allowed = WITHDRAW_AUTH_WHITELIST.some(
          (x) => x.from === from && x.to === newAuth,
        );
        if (!allowed)
          throw new Error(`BLOCKED: withdraw authority change to ${newAuth}`);
      }
    }
  }
}

なぜ“自前デコード”が必要なのか?
エクスプローラやUIは簡略表示があり得るため。本番は自前パーサで全命令を検査する必要があります。

3) 鍵の物理・権限分離

  • Withdraw鍵はHSM/マルチシグに隔離し、Custodian鍵はさらに別保管(ロックアップを導入する場合に限る)する。通常のオペレーションはStake鍵だけで完結させるように構成する。
  • Withdraw 権限の変更は専用の承認フロー(複数承認+オフライン確認)を通るように限定する。

4) 監視の強化

  • 監視対象: 管理下にある全ての Stake 口座
  • 条件: programId = Stake1111... かつ Authorize(Withdrawer)Deactivate と同梱の場合も含む)
  • 検知したら即アラートを発報緊急移管やロールバックで即時対応できるように準備をしておく。

5) ロックアップStakeの活用

  • 重要 Stake にはロックアップを設定し、解除期日まで出金とWithdraw権限更新が不可となるようにしてリスクを構造的に減らす。

以上、考えられる対策を列挙してみました。
自社でアプリなどを運用されているほうは今一度こういった対策が行われているかどうかも確認してみると良いのではないでしょうか。


まとめ

今回原因となった脆弱性は対策でも記載していますが、API 側の署名前Tx検証不足により本来作成禁止にすべきTxが作れてしまうという穴を狙われた形となります。
事件の核となっているのが、上記でも説明している、表面上Unstakeの Tx に Authorize(Withdrawer)同梱できる本来作成禁止にすべき Tx です。
送信前デコード+ホワイトリスト鍵の物理分離(Staker/Withdraw/Custodian)ロックアップの活用監視の強化UIの可視化といった対策を重ねます。これらを組み合わせることで同種の被害は防止できる可能性が高いと考えられます。


参考リンク


GitHubで編集を提案
Omakaseテックブログ

Discussion