【60億円流出】Solanaステーキングで何が起きたのかーAPI悪用の可能性と対策
ハッキングの概要
2025年9月8日、約60億円相当の$SOLがハッキングされるニュースがありました。(リンク)
事前の 2025年8月31日に送られた「アンステーク(Deactivate)」に見えるTx内へ、Authorize(Withdrawer)
を8本同梱する“権限すり替え”が仕込まれており、2025年9月8日に攻撃者が新しい Withdraw 鍵で Withdraw
を複数回実行しドレイン、という流れです。
ハッキングを受けた企業はユーザー損失を出さない方針を表明。流出のあったStaking Provider は Dashboard/API を停止し、段階的復旧・調査を発表しました。
前提知識と基本構造
事件の全体を理解する上で必要な前提知識をここで整理します。
Stake の運用フロー
まずはStakeがどのように行われるのか?その手順をフロー図で見ていきます。
以下のフロー図よりStakingの通常プロセスが確認できます。
-
Stake Account作成 → Authority設定
Stake Accountを新規作成し、Staker権限とWithdrawer権限を設定する
(任意)Staker権限とWithdrawer権限を一緒にせず、別アカウントに分けることも可能 -
Delegate(委任) → Active(運用)
バリデーターにSOLを委任し、Staking報酬の獲得が開始される -
Deactivate(解除) → Cooldown → 未委任
Stakingを解除し、Cooldown期間を経て引き出し可能な状態になる -
Withdraw(出金)
未委任状態のSOLを任意のアドレスに引き出す
権限とロックアップについて
前提
- Stakeアカウントは以下の2つの 権限アカウント(公開鍵) に分離して設定可能:
- Staker 権限アカウント:ステーク操作に署名する鍵を指す
- Withdrawer 権限アカウント:出金と権限更新に署名する鍵を指す
- Lockup(ロックアップ)を任意で設定でき、その管理者がCustodian(カストディアン)となる。
権限アカウントでできること
-
Staker 権限アカウントでできることは以下の5項目
- Delegate(委任)
- Deactivate(解除)
- Split(分割)
- Merge(統合)
- Authorize: Staker(Staker 権限アカウントの更新)
-
Withdrawer 権限アカウントでできることは以下の2項目
- Withdraw(出金)
- 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権限アカウントを乗っ取られることをいかに防ぐかが重要です。
一般的に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
orWithdrawer
) -
Withdraw
(未委任SOLの引き出し)
-
超重要:Tx内に Authorize(Withdrawer) (または AuthorizeWithSeed で authority=withdrawer )が1本でもあれば最優先で警戒する必要あり(以下に参考画像を記載)。 |
---|
事件の詳細
全体の時系列(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本含まれていた形となります。
参照リンク:Rektの記事に掲載されていたUnstake Txリンク
ドレイン(2025年9月8日)
攻撃者アドレスから Withdraw
が8回実行されており、Txにはテスト送金と大口の移動が混在していました。こちらも参考として参照リンクにドレインTxを記載しておきますので、詳細を確認してみてください。
参照リンク:ドレインTx1、ドレインTx2、ドレインTx3、ドレインTx4、ドレインTx5、ドレインTx6、ドレインTx7、ドレインTx8
なぜトランザクションが改ざんされたのか
今回の事件では、侵入経路については大きく2点に絞られます。
- ユーザーからのUnstakeリクエスト生成時にフロントエンドから改竄されてしまった
考えられる原因としては以下- 端末やブラウザがマルウェアに汚染された状態で操作してしまった
- 偽サイトや偽のUIに気付かずに画面を操作してしまった
- 外部ライブラリ(NPMなど)に悪意のある改変が含まれているアプリから操作してしまった
- ユーザーからのUnstake Tx組成時にAPI側でリクエストが改竄されてしまった
考えられる原因としては以下- APIキーやトークンなどの認証情報が漏れてしまっていた
- ビルド・デプロイ経路やAPIサーバーが乗っ取られてしまっていた
実際にどの部分でTxが改竄されてしまったのかを以下のフロー図で見ていきましょう。(改ざんを許してしまった原因は未公表のため、不明です。そのため、攻撃対象と見られる箇所に赤枠をつけています。)
今回の事案に対する対策の一覧と詳細
セキュリティ対策は大きく二つに分けられます。
- 侵入の予防
- 侵入後の被害最小化
一般的に注目は"1. 侵入の予防"に偏りがちですが、"2. 侵入後の被害最小化"も同等に重要と考えられます。
現時点で、"1. 侵入の予防"に関連する API への侵入経路については未公表のため不明です。そのため本記事では "2. 侵入された場合の被害・情報流出の最小化" に焦点を当て、有効な対策を5つ挙げました。
- UIで可視化:目視確認ができるようにし、誤操作を防ぐための対策
- 送信前デコード:事前チェックし、怪しいTxを飛ばさせないための対策
- 鍵の物理・権限分離 :分離することでより安全に運用が可能となる対策
- 監視の強化:早期の問題発見と問題への対処を早めるための対策
- ロックアップStakeの活用:事前の対策をすり抜けられても防御できるような対策
1) UIで可視化
- 送信前プレビューに全Instructionを必ず表示。
-
Authorize(Withdrawer)
を検出したら赤い警告ラベル+再承認を要求。 - 署名者(どの鍵が必要か)も明示して署名者(鍵)が間違っていないかを発見できる仕組みにする。
2) 送信前デコード
- Tx送信前に必ず全Instructionを自前デコーダで解析を実施する。
-
Stake Program
のAuthorize(Withdrawer)
/AuthorizeWithSeed(Withdrawer)
を全面ブロックする。 - 本当に必要な変更だけホワイトリストで許可する。
@solana/web3.js
)の例
TypeScript(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の可視化等の二重三重の対策で、同種の被害は防止できる可能性が高いと考えられます。
参考リンク
- Rekt: SwissBorg Rekt(事件の時系列と該当Txの分析)
- Kiln Status: Temporary Disablement of Kiln Connect and Dashboard(段階復旧の計画・API状況)
- Solana Docs(Stake/Withdraw権限の仕様・Txの構造・CLI/SDK)
- Stake Accounts / Authorities: https://docs.solana.com/staking/stake-accounts
- Transactions & Messages: https://docs.solana.com/developing/transactions
- CLI(
solana stake-authorize
など): https://docs.solana.com/cli/usage - web3.js(
StakeInstruction.decode*
): https://solana-labs.github.io/solana-web3.js/
Discussion