Mythicのハードニング。
1週間、私たちは Mythic の構築を止めて、壊すことに専念した —— 稼働中のサーバーに対する ブラックボックス、ブラックボックスで足りないところはホワイトボックスで。 12件の発見が生まれた。すべて修正し、回帰テストで固定し、完了と呼ぶ前に 元の再現手順で再検証した。本エントリでは、同じパスで着地したM1 ロードマップの2項目 —— 鍵ローテーションとRedisストア —— も記録する。
ダウンタイムなしの鍵ローテーション
今週までMythicはすべての決定トークンを単一のEd25519鍵で署名して
いた。ローテーションは再起動を意味し、生きている全トークンを
無効化した。サーバーは現在、アクティブな署名者を正確に1つ持つ
キーリングを保持する
(ADR-0006)。
ローテーションは時間ベースでプロセス内で動く
(MYTHIC_KEY_MAX_AGE、既定720h);引退した鍵は
MYTHIC_KEY_RETENTION(既定24h)の間JWKSに残る ——
その鍵が署名したトークンの寿命より意図的に長く、飛行中のトークンが
ローテーション途中で壊れることは決してない:
図1 — 引退した鍵は署名したトークンより長く生きる。ローテーションで有効なトークンが無効になることはない。
キーストアはアトミックに書き込まれるJSONファイルである;旧来の
32バイトシードファイルも読み込み可能で、最初の保存時に移行する。
オリジンは引き続きトークンをローカルで検証し、
kid クレームで鍵を選択する ——
調整のための呼び出しは不要。
単一ノードを超えて生き残るストア
インメモリストアはmythicdを単一ノードに縛り付けていた
(ADR-0009)。
MYTHIC_STORE=redisでRedisバックエンドを選択する:
使い捨てチャレンジはGETDEL(アトミックな取得と削除)、
プレッシャーカウンタは1つのLuaスクリプトで刈り込むソート済みセット。
両バックエンドはスライディングウィンドウカウンタを実装し、
固定ウィンドウの境界バーストを塞ぐ。
Storeインターフェースはエラーを返すようになった:
到達不能なRedisは起動を拒否し、実行時の障害は/v1で
503 store_unavailableとして表出する一方、
/healthzはロードバランサ向けに開いたままになる。
共有の契約スイートが両バックエンド(memoryとminiredis)に対して走り、
セマンティクスが乖離できないようにする。
明記しておくべき注意点が1つ:共有されるのはストアであって、 署名鍵ではない。ノードAのトークンはノードBのJWKSでは検証できない。 マルチノードのトークン検証には共有キーストア(KMS/HSM)が必要 —— M5であり、この変更では解決していない。
12件の発見、グループ別
各行:攻撃が示したこと、次に変えたこと。すべての修正に回帰テストがあり、 すべての修正は元の再現手順で再検証した。
| 領域 | 原因 | 修正 |
|---|---|---|
| トランスポートと制限 | ||
| レート制限バジェット | バジェットは検証の後で消費された —— 不正JSONや未知のサイトキーの洪水がスロットルなしで通った(600リクエスト、225 req/s、429ゼロ)。 | バジェットは検証の前にカウントされるようになった。 |
| IPv6プレッシャーキー | リミッターはIPv6を/128でキー化 —— 1つの/64が2⁶⁴のIDを支配した(単一ブロックから5,000リクエスト、ヒットゼロ)。 | カウンタはIPv6を/64に集約する(ADR-0007)。 |
| 接続タイムアウト | body/read/idle タイムアウトがなかった —— 遅いクライアントが接続を無期限に保持した。 | ReadTimeout、WriteTimeout、IdleTimeout を設定した。 |
| チャレンジの経済性 | ||
| ソルブ時間フロア | フロアは50k H/sのワーカーをモデル化;実測の crypto.subtle ワーカーは約400k H/s —— 誠実なクライアントが非現実的と判定された。 |
フロアは誠実なソルブ時間の低い分位数になった(2^d / (2M × 32)):最悪でも誤検出 ≤ ~6%、GPU級の回答は約96%で引っかかる(ADR-0008)。 |
| 検証の遅延 | クライアントは検証を壁時計フロアの後に遅らせるだけでよい —— フロアは忍耐を捕まえられない。 | 脅威モデルに行動層(M2)の仕事として文書化した。フロアの仕事ではない。 |
| トークンと設定 | ||
| トークンTTL | “トークンはチャレンジより長生きしない”という文書化された不変条件は強制されていなかった —— 5秒のチャレンジから1時間のトークンが発行された。 | TokenTTLはChallengeTTLにクランプされるようになった。 |
| スコアクランプ | 敵意あるチューニング(MYTHIC_FAST_SOLVE_PENALTY=500)が署名済みトークンにリスクスコア500を漏らした。 |
スコアはエンジン境界で0–100にクランプ;100超のペナルティは起動を拒否する。 |
| 設定の検証 | 負の MYTHIC_BASE_DIFFICULTY は任意のnonceで通るチャレンジを発行した。 |
設定は起動時に検証され、範囲外の値は提供を拒否する。 |
| Base64正規形 | Go の base64 デコーダは末尾文字の未使用下位ビットを黙って捨てるため、異なるトークン文字列が同じバイト列にデコードされて検証を通過した —— 文字列レベルのトークン同一性が壊れていた。改ざん耐性ミューテーションスイートの初回実行で検出。 | VerifyToken は正規 base64 を強制する:デコードしたバイト列の再エンコードが入力と完全一致しなければトークンを拒否する。 |
| 状態管理 | ||
| キーストアの並行性 | 1つのキーストアを共有する2プロセスは、同じテンポラリファイルで衝突するか、署名鍵を黙ってスプリットブレインした。 | テンポラリ名はプロセスごとに一意;保存はロード後に変更されたファイルへの上書きを拒否する。 |
| JSON末尾バイト | JSON値の後の末尾バイトは黙って無視された。 | 末尾バイトは拒否されるようになった。 |
| カウンタの飽和 | スライディングウィンドウカウンタはヒットごと(レート制限済みも含む)に1エントリ保存した —— 1つのIDからの百万ヒットで約16.4 MBのヒープと595秒のCPUを消費した。 | カウンタは IPLimit+1 で飽和する:同じ洪水でも +0.00 MB、714 ms しかかからない。 |
スコアクランプの修正は見せる価値がある。信頼境界に位置するからだ —— トークンの中に署名されて入る値:
// The score is a 0-100 contract (token claim `risk`,
// dashboards, tuning docs). Hostile tuning must clamp
// here, not leak 500 into tokens (red-team H3).
if score > 100 {
score = 100
}
if score < 0 {
score = 0
}
検証 —— そして残された課題
すべての発見に再現手順があり、すべての修正に回帰テストがある
(go test ./... -race、加えてSDKのタイプチェックとテスト)。
そしてすべての修正は元の再現手順で再検証した。未解決のまま残るものは、
私たちの頭の中ではなく脅威モデルに文書化されている:検証を遅らせる
辛抱強いソルバー(M2、行動シグナル)、新しいIDを使った分散攻撃
(その後M1のネットワークフィンガープリントで部分的に対処 ——
003参照)、ノードごとのキーストア
(M5、共有KMS/HSM)。
— XEY LABS