当密码重置成功,但攻击者从未离开。
一个针对单一漏洞类别的本地红/蓝实验室:凭证的生命周期超出了本应终结它的事件。 它提供了漏洞利用、根本原因、修复方案、三规则检测包、针对规则在结构上无法看到的内容的状态审计、一个取证控制台——以及那条不起作用的检测规则,保留在仓库中以便演示其失败。
同一攻击。同样的请求。唯一的区别。
由 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}
没有错误。没有异常。没有可计数的失败认证。攻击者的访问凭证*只有三秒大*,并且是由服务器在重置后根据请求生成的。
以下是整个漏洞:```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,则什么都不会被撤销,而端点仍然报告成功。
这不是假设。这就是
CVE-2026-22706
在 Strapi ≤ 5.33.2 中的情况,其中刷新令牌失效步骤取决于调用方提供的 deviceId。评分为 2.1,低危。讨论见
下文。
评分之所以低,是因为攻击者已经拥有访问权限——这是进入条件,而这个漏洞并没有授予任何新的东西。它授予的是持续时间,并且是通过破坏受害者自己能够操作的唯一控制来实现的。
test_persistence_lasts_as_long_as_the_refresh_credential 模拟了七天的时间来展示这一点。遏制路径中的低危漏洞比功能路径中的低危漏洞代价更高,因为代价是在事件期间付出的,而那时没人在读安全公告。
你最先写下的规则:```text IF credential.issued_at < credential_change.timestamp: ALERT
这不是一条愚蠢的规则。它成本低,只需要一个字段,读起来就像问题的定义,而且**在简单情况下是正确的**——攻击者在重置后使用窃取的*访问*令牌会被抓住。
`test_the_naive_rule_catches_the_simple_case` 断言它有效。
两条规则针对同一凭证提出同一个问题。它们读取不同的字段来回答,而这两个字段相互矛盾:

**三秒之后,还是一小时之前——同一凭证,同一时刻。** 朴素规则询问的是攻击者控制的字段,而一次 `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
真正重要的是凌晨三点时的差异。朴素规则只捕捉到调用链越过边界的那一瞬间,随后在剩下的29天里丢失了线索——而它所指认的凭证*早已被服务器轮换并吊销*。吊销它毫无意义。`lin-001` 才是你必须清除的对象。
**而且朴素规则并不缺乏遥测数据。** 它使用相同的事件流、相同的血缘索引、相同的容差、相同的去重机制和相同的有限状态。它只覆盖了一个方法:```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 证明了这一点。问题出在
比较上,而不是日志记录。
三条规则作用于同一份日志。它们回答不同的问题,并且按照事件实际展开的 顺序触发。
| 规则 | 严重级别 | 回答 | 触发时机 | |
|---|---|---|---|---|
| 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
在变更落地的瞬间触发,比攻击者触碰任何东西早三秒,因为它所需的证据已经完整:日志说明了哪些谱系在进入时是活跃的,并且没有说明它们已被撤销。
它不需要受害者,也不需要利用。它会在任何用户执行的第一次密码重置时报告该缺陷——这使它成为你在预发布环境中运行的那一个,那里没有攻击者需要等待。AFTERLIFE-001 告诉你入侵正在进行;AFTERLIFE-003 告诉你你的遏制控制已失效。

`application recorded watermark: no` 是引导事件响应人员指向*缺陷*而非症状的字段。
### 规则使用的水印不是应用程序报告的那个
凭据变更事件携带一个 `revocation_watermark` 字段——即应用程序写入的 `credentials_valid_after` 值。**在易受攻击的实现中它是 `null`,因为应用程序从未写入过。** 这就是那个 bug。
因此,以该字段为键的规则将恰好对它存在所要捕获的情况视而不见。规则锚定在事件自身的 `timestamp` 上,无论应用程序是否履行了职责,该时间戳都是真实的,并将缺失的字段作为证据报告。
### 属性```text
deduplication 6 accepted requests on the stale lineage -> 1 alert
event order shuffled stream -> same alert (1 alert)
duplicate telemetry log replayed twice -> 1 alert (24 duplicate events discarded)
false positives the fixed implementation's log -> 0 alerts
bounded state caps at 256 activity/user, 2000 users, 20000 credentials
顺序无关性不是“基本能用”:
test_6b_every_permutation_of_the_critical_events_detects 会运行四个关键事件的全部 24 种排列,并要求每种排列恰好产生一个告警。
去重以 (user_id, credential_change_event_id, lineage_id) 为键,
并在其前面加上 event_id 抑制,这样重放的文件就无法虚增计数。
完整规则卡片、所需遥测数据和响应运行手册: docs/detection.md。
一个用于回答本实验所关注的那个问题的阅读界面。不是告警计数的仪表盘——而是一个死亡登记册。每个凭据一根条形,从签发到死亡,按其所继承的血统分组,密码更改被画成一条线,之前的一切本应终止于此。