パスワードリセットが成功しても、攻撃者が去らないとき。
単一の脆弱性クラスに焦点を当てたローカルのレッド/ブルーラボ:本来それを無効化するはずの イベントよりも長く生き残る資格情報。 これにはエクスプロイト、根本原因、修正、3ルールの検出パック、ルールが構造的に見ることのできない ものを対象とした状態監査、フォレンジックコンソール — そして機能しない検出ルールが含まれ、 失敗を実演するためにリポジトリに保持されている。
クイックスタート · 発見事項 · なぜ素朴な検出は失敗するのか · ルールパック · コンソール · 修正 · マトリクス · テスト · ドキュメント
同じ攻撃。同じリクエスト。違いは1つだけ。
コンソールが描画するのと同じペイロードから scripts/figures.py によって生成され — CIで再生成および差分比較されるため、図がコードから乖離することはない。
09:00 alice logs in ┐ refresh credential rt-001 │ the attacker steals rt-001 ┘
10:00 alice changes her password ← the one thing a victim can do alone HTTP 200 · password changed · fresh session issued
10:00:03 attacker: POST /refresh rt-002 → HTTP 200 new access credential at-004, issued 10:00:03
10:01:00 attacker: GET /me at-004 → HTTP 200 {"username": "alice", "authenticated": true}
エラーなし。異常なし。数えるべき認証失敗もなし。攻撃者のアクセス資格情報は*3秒前*に生成されたもので、リセット後にサーバーが要求に応じて発行したものだ。
これが脆弱性のすべてである:```python
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
レビュアーの視点でこれを読んでほしい。失効ロジックはまさにそこにある。正しいスコープで正しい関数を呼び出している。その周囲のエンドポイントはパスワードハッシュを更新し、200 を返し、新しいセッションを発行する — 正しいパスワード変更の観察可能な挙動はすべて揃っている。
そして呼び出し元が device_id を省略した場合、何も失効されず、エンドポイントは依然として成功を報告する。
これは仮定の話ではない。Strapi ≤ 5.33.2 における
CVE-2026-22706
であり、リフレッシュトークンの無効化ステップが呼び出し元が指定する deviceId に条件付けられていた。スコアは 2.1、Low。詳細は
後述。
スコアが低いのは、攻撃者がすでにアクセス権を持っていたからだ — それが侵入条件であり、このバグが新たに与えるものは何もない。このバグが与えるのは持続時間であり、それは被害者自身が操作できる唯一の制御を破壊することによってなされる。
test_persistence_lasts_as_long_as_the_refresh_credential は7日間のシミュレートされた時間を辿ってそれを示す。封じ込めパスにおける Low スコアのバグは、機能パスにおける Low スコアのバグよりもコストが高い。なぜならそのコストはインシデントの最中に支払われ、そのとき誰もアドバイザリを読んでいないからだ。
最初に書くルール:```text IF credential.issued_at < credential_change.timestamp: ALERT
これは愚かなルールではない。安価で、フィールドが1つで済み、問題の定義のように読め、そして**単純なケースについては正しい**——リセット後に盗まれた*アクセス*トークンを使う攻撃者は捕まる。
`test_the_naive_rule_catches_the_simple_case` はそれが機能することを主張している。
どちらのルールも、同じクレデンシャルについて同じ問いを立てている。答えを出すために異なるフィールドを読み、その2つのフィールドは食い違う:

**3秒後、それとも1時間前——同じクレデンシャル、同じ瞬間。** 素朴なルールは攻撃者が制御するフィールドを尋ねており、1回の `POST /refresh` でそれがリセットされる。
これをリクエストログに対して実行しよう。実際にこのクエリが書かれるのはそこであり、手元にあるのはリクエストログだからだ:```text
NAIVE DETECTOR (NAIVE-001), over the request log
Result: NO ALERT
Six requests were served to the attacker after the reset. Every one of them
carried at-004, minted at 10:00:03 -- three seconds *after* the password
change. By its own timestamp it is the newest credential on the account.
そこで止めるのは簡単だが、不誠実でもある。だからこのラボでは、素朴なルールの最も公平なバージョンも実行する — リフレッシュエンドポイントも監視するように拡張したものである:```text NAIVE DETECTOR, widened to include POST /refresh
1 alert at 2026-09-11T10:00:03.000Z: the credential presented to /refresh was rt-002, issued 2026-09-11T09:30:00.000Z. Then it goes blind. 6 events follow that hop and it flags none of them, because every credential from there on carries a post-reset timestamp. Its incident covers 1 accepted request; the lineage rule's covers all of them.
And look at what the alert names: NAIVE-001 revoke rt-002 -- rotated away and already dead at 10:00:03 AFTERLIFE-001 revoke lin-001 -- the live thing every future credential descends from
3amに本当に重要なのはその違いだ。素朴なルールはチェーンが境界を越える
瞬間を一度だけ捉え、残り29日間は痕跡を見失う——そしてそれが名指しする
クレデンシャルは*すでにローテーションされ、サーバーによって失効させられている*。
それを失効させても何の意味もない。殺すべき対象は`lin-001`だ。
**そして素朴なルールはテレメトリに飢えていたわけではない。** 同じイベント
ストリーム、同じリネージインデックス、同じトレランス、同じ重複排除、
同じ有界状態を使用している。ただ1つのメソッドをオーバーライドするだけだ:```python
class Correlator: # AFTERLIFE-001
def _age_reference(self, facts):
return facts.root_issued_at
class NaiveCorrelator(Correlator): # NAIVE-001
def _age_reference(self, facts):
return facts.issued_at
root_issued_at は、すでに参照しているインデックス内に存在している。
test_the_naive_rule_had_the_data_it_needed がそれを証明している。失敗は
比較にあり、ログ記録にあるのではない。
1つのログに対する3つのルール。それぞれ異なる問いに答え、インシデントが実際に展開する順序で発火する。
| ルール | 重大度 | 答える問い | 発火 | |
|---|---|---|---|---|
| AFTERLIFE-002 | リフレッシュ認証情報の再利用 | CRITICAL / MEDIUM | 盗まれたのか? | 再利用時 |
| AFTERLIFE-003 | セキュリティ変更時の不完全な失効 | HIGH / LOW | 封じ込めは実行されたか? | 変更時 — 攻撃者は不要 |
| AFTERLIFE-001 | 失効後の認証情報系譜の使用 | HIGH | 古い系譜が使用されたか? | 最初の受理時 |
| RULE PACK |
10:00:00 HIGH AFTERLIFE-003 Incomplete revocation at a security change 10:00:03 HIGH AFTERLIFE-001 Post-revocation credential lineage use
**その順序付けがこのリポジトリで最も有用なものである。** AFTERLIFE-003
は変更が着地した瞬間、攻撃者が何かに触れる3秒前に発火する。なぜなら、それが
必要とする証拠はすでに完全だからである。ログはどの系統が稼働状態で入ったかを
示しており、それらが失効されたとは示していない。
被害者も悪用も必要としない。ユーザーが最初に実行するパスワードリセットで
欠陥を報告する——つまり、待つべき攻撃者がいないステージング環境で実行する
べきものである。AFTERLIFE-001は侵害が進行中であることを伝え、
AFTERLIFE-003は封じ込め制御が壊れていることを伝える。

`application recorded watermark: no` は、インシデント対応者を症状ではなく
*欠陥*へと導くフィールドである。
### ルールが使用するウォーターマークは、アプリケーションが報告するものではない
資格情報変更イベントは `revocation_watermark` フィールド——アプリケーションが
書き込んだ `credentials_valid_after` 値——を運ぶ。**脆弱な実装ではこれが
`null` である。なぜならアプリケーションは一度も書き込まなかったからである。**
それがバグである。
したがって、そのフィールドに基づくルールは、まさにそれが捕まえるために存在
するケースに対して盲目となる。ルールはイベント自身の `timestamp` に
アンカーしており、これはアプリケーションがその役割を果たしたかどうかに
かかわらず真であり、欠落したフィールドを証拠として報告する。