
Revocation persistence detection lab: when the password reset succeeds but the attacker never leaves. Reproduces the Strapi CVE-2026-22706 conditional-revocation bug, its fix, a three-rule detection pack, and the naive rule that misses it.
When the password reset succeeds but the attacker never leaves.
A local red/blue lab for one vulnerability class: the credential that outlives the event that was supposed to kill it. It ships the exploit, the root cause, the fix, a three-rule detection pack, a state audit for what the rules structurally cannot see, a forensic console — and the detection rule that doesn't work, kept in the repository to be demonstrated failing.
Quick start · The finding · Why naive detection fails · The rule pack · Console · The fix · Matrix · Tests · Docs
The same attack. The same requests. One difference.
Generated by scripts/figures.py from the same payload the
console draws — regenerated and diffed in CI, so a figure cannot drift from the code.
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}
No error. No anomaly. No failed authentication to count. The attacker's access credential is three seconds old and was minted by the server, on request, after the reset.
Here is the entire vulnerability:
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
Read that the way a reviewer would. Revocation logic is right there. It calls
the right function with the right scope. The endpoint around it updates the
password hash, returns 200, and issues a fresh session — every observable
behaviour of a correct password change is present.
And if the caller omits device_id, nothing is revoked, and the endpoint still
reports success.
This is not a hypothetical. It is
CVE-2026-22706
in Strapi ≤ 5.33.2, where the refresh-token invalidation step was conditional
on a caller-supplied deviceId. Scored 2.1, Low. Discussed
below.
The score is low because the attacker already had access — that is the entry condition, and this bug grants nothing new. What it grants is duration, and it does so by breaking the one control the victim can operate themselves.
test_persistence_lasts_as_long_as_the_refresh_credential walks seven days of
simulated time to show it.A Low-scored bug in a containment path costs more than a Low-scored bug in a feature path, because the cost is paid during an incident, when nobody is reading advisories.
The rule you write first:
IF credential.issued_at < credential_change.timestamp:
ALERT
It is not a stupid rule. It is cheap, needs one field, reads like the definition
of the problem, and it is correct about the simple case — an attacker using a
stolen access token after the reset gets caught.
test_the_naive_rule_catches_the_simple_case asserts it works.
Both rules ask the same question about the same credential. They read different fields to answer it, and the two fields disagree:
Three seconds after, or an hour before — the same credential, at the same
instant. The naive rule asks a field the attacker controls, and one
POST /refresh resets it.
Run it over the request log, which is where this query actually gets written, because request logs are what you have:
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.
It would be easy to stop there, and dishonest, so the lab also runs the fairest version of the naive rule — widened to watch the refresh endpoint too:
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
That is the difference that matters at 3am. The naive rule catches the single
instant the chain crosses the boundary, then loses the trail for the remaining
29 days — and the credential it names has already been rotated away and revoked
by the server. Revoking it accomplishes nothing. lin-001 is the object you
have to kill.
And the naive rule was not starved of telemetry. It uses the same event stream, the same lineage index, the same tolerance, the same deduplication and the same bounded state. It overrides one method:
class Correlator: # AFTERLIFE-001
def _age_reference(self, facts):
return facts.root_issued_at