
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
class NaiveCorrelator(Correlator): # NAIVE-001
def _age_reference(self, facts):
return facts.issued_at
root_issued_at is sitting in the index it is already consulting.
test_the_naive_rule_had_the_data_it_needed proves it. The failure is the
comparison, not the logging.
Three rules over one log. They answer different questions, and they fire in the order an incident actually unfolds in.
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
That ordering is the most useful thing in this repository. AFTERLIFE-003 fires at the instant the change lands, three seconds before the attacker touches anything, because the evidence it needs is already complete: the log says which lineages were live going in, and it does not say they were revoked.
It needs no victim and no exploitation. It will report the defect on the first password reset any user performs — which makes it the one you run in staging, where there is no attacker to wait for. AFTERLIFE-001 tells you a breach is ongoing; AFTERLIFE-003 tells you your containment control is broken.
application recorded watermark: no is the field that points an incident
responder at the defect rather than at the symptom.
The credential-change event carries a revocation_watermark field — the
credentials_valid_after value the application wrote. In the vulnerable
implementation it is null, because the application never wrote one. That is
the bug.
So a rule keyed off that field would be blind to exactly the case it exists to
catch. The rules anchor on the event's own timestamp, which is true whether or
not the application did its job, and report the missing field as evidence.
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
Order independence is not "mostly works":
test_6b_every_permutation_of_the_critical_events_detects runs all 24 orderings
of the four events that matter and requires exactly one alert from each.
Deduplication is keyed on (user_id, credential_change_event_id, lineage_id),
with event_id suppression in front of it so a replayed file cannot inflate the
count.
Full rule cards, required telemetry and response runbooks: docs/detection.md.
A reading surface for the one question the lab is about. Not a dashboard of alert counts — a mortality register. One bar per credential, from issue to death, grouped into the lineage it descends from, with the password change drawn as a line everything before it was supposed to end at.
One deliberate inversion: warmth means alive, and after the line warmth is
wrong. In most security UIs red means an error occurred. Nothing errors here —
every request in the vulnerable run returns 200. So colour follows mortality
instead: cold is a credential that died when it was told to, warm is one still
breathing, and past the guillotine, still being warm is the whole finding.
(BLACKOUT set the same convention, where 200 was the red one.)
The thin vertical drops are genealogy: a child credential minted from its
parent at that instant. rt-001 → rt-002 → rt-004 steps down and to the right
across the whole window, and in vulnerable mode it keeps stepping after the
cut — which is the picture of a lineage minting new credentials on the far side
of its own extinction event.
python scripts/lab.py console
writes docs/console-preview.html — a
self-contained 74 KiB file with both runs baked in. No server, no network, no
fonts to fetch; open it from the filesystem. Or run the live version:
python -m console
Drag the scrubber back past 10:00:00 and forward through it: the findings appear
when the log earns them, AFTERLIFE-003 at the change and AFTERLIFE-001 three
seconds later. Design reasoning, including what got cut, is in
docs/console-design.md.
The console is read-only by construction. It re-runs finished scenarios and draws them; it cannot switch the implementation, move the clock or revoke anything.
test_the_console_has_no_control_surfaceasserts the only non-GET route is/api/rebuild. A reading surface that can change what it is reading is one you cannot trust.
Six packages, one security class, no infrastructure.
app/ the lab application
config.py mode selection; defaults to `fixed`, deliberately
store.py SQLite credential state + the security-change audit trail
tokens.py minting and decoding; a JWT is a signed pointer to a row
auth.py login / refresh / change-password + THE BUG + the audit
main.py six endpoints
common/
clock.py a rewindable UTC clock, so an hour of history costs nothing
events.py the event vocabulary, shared by app and detector
telemetry.py JSONL emission with credential-name redaction
detector/ strictly downstream: reads a log file, decides nothing
base.py the alert shape, replay suppression, bounded state
lineage.py rebuilds a credential's ancestry from issuance events
ledger.py which lineages are alive, per user
rules.py AFTERLIFE-001 persistence
reuse.py AFTERLIFE-002 theft
containment.py AFTERLIFE-003 the defect itself
naive.py NAIVE-001, kept in order to be demonstrated failing
audit.py the state scan the rules structurally cannot do
engine.py the pack: one shared index, one stream, ranked alerts
tail.py byte-offset JSONL tailer
console/ the mortality register
payload.py one run, shaped for drawing
static/ ~1400 lines of vanilla HTML/CSS/JS, no build step
scripts/
lab.py the demonstration
scenarios.py nine scenarios x both implementations
report.py the incident report
preview.py bake the offline console
figures.py render the console to SVG for this README
tests/ 290 tests
The application runs in-process under the demo, the console and the tests. No
ports, no dev server, no Docker. The clock is pinned, so every run is
deterministic and the committed telemetry, console, figures and reports are all
byte-identical between runs — which CI checks with git diff --exit-code.
A credential is a row, not a string. The JWT the client holds carries cid —
a lookup key — and the row decides whether the credential is alive.
Expiry is checked against the row's expires_at, not against the exp claim the
holder presented. Both temporal claims are handed to PyJWT with verify=False,
which here means "the server checks this itself", and app/auth.py
does, on every request. The client never gets a vote on whether its own
credential is still valid.
flowchart TD
A["Account compromised<br/><i>phishing · XSS · stolen backup</i>"] --> B["Attacker holds refresh credential rt-001<br/>lineage lin-001, root sess-001 @ 09:00"]
B --> C["Legitimate password change @ 10:00<br/>HTTP 200 · password hash updated"]
C --> D{"device_id supplied?"}
D -->|"yes"| E["revoke_credentials(user, device_id)<br/>rt-001 revoked"]
D -->|"no — the exploit"| F["nothing revoked<br/>no watermark written"]
E --> G["Attacker refresh → 401<br/><b>contained</b>"]
F --> AF3["<b>AFTERLIFE-003 · HIGH</b> @ 10:00:00<br/>containment did not run<br/><i>no attacker action required</i>"]
F --> H["POST /refresh rt-002 → 200<br/>mints at-004 @ 10:00:03"]
H --> I["GET /me at-004 → 200<br/>well-formed · correctly signed · <b>no anomaly</b>"]
I --> J["at-004.issued_at > watermark<br/>NAIVE-001: no alert"]
I --> K["lineage lin-001 root @ 09:00 < watermark<br/><b>AFTERLIFE-001 · HIGH</b>"]
F --> L["attacker holds and never spends<br/>AFTERLIFE-001 silent — correctly<br/><b>state audit: dormant survivor</b>"]
style F fill:#7f1d1d,color:#fff
style H fill:#7f1d1d,color:#fff
style I fill:#7f1d1d,color:#fff
style J fill:#78350f,color:#fff
style K fill:#14532d,color:#fff
style AF3 fill:#14532d,color:#fff
style L fill:#1e3a5f,color:#fff
style E fill:#14532d,color:#fff
style G fill:#14532d,color:#fffThe refresh-chain branch in the middle is the whole point. The attacker does not use an old credential. They use an old lineage, and the lineage mints something new on demand.
A session is the root of a lineage. Everything minted beneath it inherits that root, forever.
sess-001 session lineage lin-001 root sess-001 issued 09:00:00
│
├── rt-001 refresh parent sess-001 root_issued_at 09:00:00
│ └── at-001 access parent rt-001 root_issued_at 09:00:00
│
├── rt-002 refresh parent rt-001 root_issued_at 09:00:00 ← 09:30 rotation
│ └── at-002 access parent rt-002 root_issued_at 09:00:00
│
└── rt-004 refresh parent rt-002 root_issued_at 09:00:00 ← 10:00:03 rotation
└── at-004 access parent rt-004 root_issued_at 09:00:00
issued_at 10:00:03
at-004 is three seconds old. Its ancestry is an hour old. Both facts are true,
and only one of them is visible in a request log.
Two invariants hold this together, and both are tested:
Store.open_session is the sole
place a lineage_id is generated, and it makes the session its own
root_credential_id.TokenService.mint reads the lineage fields
off the parent rather than recomputing them, so a credential cannot acquire a
fresher ancestry than the login it descends from.A holder-presented token deliberately does not carry its lineage root. If it did, the holder could lie about it.
AFTERLIFE-001 fires when a stale lineage is used. That is the right trigger for a detection rule, and it leaves a hole: a stale lineage nobody has touched is invisible to it. An attacker who steals a credential, watches the reset fail, and then waits, produces no activity to correlate.
So the lab also asks the question a rule cannot: not what happened, but what is still alive.
scenario: dormant-survivor (the attacker holds the credential and never spends it)
AFTERLIFE-001 silent — correct; nothing was accepted
AFTERLIFE-003 HIGH — the change revoked nothing
state audit 1 stale lineage, 1 dormant
python scripts/lab.py audit
VULNERABLE
server state 1 lineage(s) outlived the change (0 dormant)
telemetry 1 lineage(s) -- the same question, asked of the log instead of the database
lin-001 root 2026-09-11T09:00:00.000Z 7 credentials in use
FIXED
server state clean
telemetry clean
Two sources, deliberately: the telemetry audit sees what the log can prove, the server-state audit sees what the database believes. Where they disagree, the log is not a faithful record of credential state, and every detection built on it is weaker than it looks — so the command prints both and says so if they differ.
The server-state audit needs the security_changes table, which both modes
write, because recording that a change happened is a separate obligation from
acting on it — and the vulnerable implementation meets exactly one of the two.
Nine scenarios against both implementations. python scripts/scenarios.py asserts
its own two invariants and exits non-zero if either breaks.
Four rows carry an argument:
legitimate-only has no attacker in it at all. The fix produces nothing;
the vulnerable implementation produces a LOW. That is not a false positive — it
is enumeration_only: revocation worked this time, by enumerating, with no
watermark to cover a credential the server has forgotten. The pack separates
the two implementations with no attacker present.dormant-survivor is the blind spot and its answer in one row:
AFTERLIFE-001 silent, AFTERLIFE-003 HIGH, audit dormant: 1.multi-device is where the vulnerable implementation's working branch
still fails — revocation scoped to the laptop, two other devices untouched.expired-lineage is the false-positive control: a 40-day-old session whose
credentials expired on their own is not a surviving persistence path, and is not
reported as one.And the matrix's central claim, checked by the script and by
test_the_fix_never_produces_a_control_failure_finding: the fix raises no
control-failure finding in any scenario. AFTERLIFE-002 is allowed through — it
reports a theft, not a control failure, and a correct implementation still has
thefts to report.
A rule that pages on every password reset gets muted within a week, and a muted rule is worse than no rule — it is a rule everyone believes is running.
Every row has a test in
tests/test_detector_afterlife001.py and
tests/test_detector_rulepack.py.
On the 2-second tolerance. Every timestamp in this lab comes from one process
and one clock, so the honest tolerance is zero. 2s is what a realistic two-host
deployment under NTP needs, and it is three orders of magnitude smaller than the
gaps this attack produces — a refresh credential's entire purpose is to be
long-lived. The tolerance is a window in which a genuinely stale lineage is
ignored, so it is kept small on purpose:
test_12b_a_gap_beyond_the_tolerance_does_alert pins that 2.1s still fires.
# THE FIX. Two mechanisms, deliberately redundant.
self.store.set_watermark(user.user_id, moment) # the guarantee
revoked = self.store.revoke_credentials(user.user_id, ...) # defence in depth
Neither depends on device_id. It is recorded as context and has no say in the
blast radius.
The watermark is the architectural guarantee. One timestamp per user,
credentials_valid_after, compared on every request against both the
credential's own issuance and its lineage root:
watermark = user.credentials_valid_after
if credential.issued_at < watermark: reject # the obvious case
if credential.root_issued_at < watermark: reject # the refresh chain
The second comparison is the one that costs something to get right, and the one a naive implementation leaves out — in enforcement exactly as in detection.
Explicit revocation is defence in depth, and evidence. revoked_at and
revocation_reason are what an incident responder reads, and the
auth.token.revoked events are what proves containment happened.
test_the_watermark_alone_rejects_a_stale_credential sets the watermark without
revoking anything and requires the stale credential to be refused — which
establishes which of the two is load-bearing, and which is the property that
still holds for credentials the server has forgotten it issued.
Not one request changed. One function did.
The trigger is not "the password changed". It is "something changed that makes
previously issued credentials untrustworthy". All four routes share
_revoke_for_security_change, so the fix and the bug apply identically to:
Tested for every kind, in both modes, in
tests/test_privilege_changes.py — including
the login non-requirement, documented so the next reader does not "fix" it.
Four ways to make a credential stop working. AFTERLIFE implements A + C, and the ordering matters.
On D: JWTs are not inherently insecure. The tension is narrower and worth stating
precisely — stateless verification and immediate server-side revocation are
mutually exclusive. You cannot decide "this credential is no longer valid"
without consulting something that knows that, and consulting it is what makes the
system stateful. The mistake is adopting JWTs for their statelessness and then
needing revocation anyway, which every product does the first time a laptop is
stolen. What you end up with is a signed pointer into server-side state — which is
what app/tokens.py implements deliberately, because arriving
there on purpose is cheaper than arriving there mid-incident.
CVE-2026-22706 scores 2.1, Low (CVSS v4.0
AV:N/AC:H/AT:N/PR:H/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N), and the score is
defensible: PR:H because the attacker must already hold a valid refresh
credential, AC:H because getting one requires a prior compromise, and
VC:N/VI:L because the flaw grants no access the attacker did not already have.
CVSS is measuring the marginal impact of the vulnerability, and the marginal
impact is the duration of access, not its scope. Given those inputs, 2.1
falls out of the formula correctly.
What CVSS does not model is that the failing control is the containment action — so the useful conclusion is about triage routing, not about the number. A Low in a containment path deserves attention a Low in a feature path does not, because the cost is paid during an incident. Full discussion: docs/tradeoffs.md.
290 passed
The strongest test in the suite is
test_no_bearer_string_or_password_ever_reaches_the_log: it runs the whole attack
and then searches the log file for the actual credentials the application handed
out, plus both passwords, plus the signing key. Not for field names — for the
values.
Stated plainly, because hiding them would make the lab dishonest. Each has a test.
If the application does not emit credential-change events, the pack cannot reliably correlate post-reset activity. There is no anchor, so there is nothing for activity to be "after" — and it silences AFTERLIFE-003 first, which is the rule that would have told you the control was broken.
If the application does not retain issuance/lineage metadata, the detector cannot determine whether a fresh access token descended from an older credential. A credential minted three seconds ago is indistinguishable from a credential minted three seconds ago by a month-old chain.
These are not optional logging preferences. They are security detection
requirements. Dropping auth.token.issued to reduce log volume does not make
logging cheaper; it turns off a detection.
Unresolvable activity is counted, not silently dropped —
stats()["activity_with_unresolved_lineage"] is non-zero whenever the detector
was asked about credentials the telemetry never described. A rule that is quiet
because nothing is wrong and a rule that is quiet because it is blind look
identical from the outside, and that number is the difference.
Also true, and also documented:
Full discussion: docs/limitations.md.
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt -r requirements-dev.txt
pytest -q
Then, in order:
python scripts/lab.py vulnerable
python scripts/lab.py detect
python scripts/lab.py fixed
python scripts/lab.py audit
Or the whole story at once, with the lesson at the end:
python scripts/lab.py all
And the picture:
python scripts/lab.py console
Replay the committed evidence through the pack. No application run needed — the detector is strictly downstream, and this is the proof:
python -m detector --once --timeline --events evidence/vulnerable-persistence.jsonl
One rule at a time, or the naive one, or the state scan:
python -m detector --once --rule AFTERLIFE-003 --events evidence/vulnerable-persistence.jsonl
python -m detector --once --naive --events evidence/vulnerable-persistence.jsonl
python -m detector --audit --events evidence/vulnerable-persistence.jsonl
The CLI exits 1 when it finds something, so it works as a CI check without
parsing its output.
Nine scenarios against both implementations, with self-checking invariants:
python scripts/scenarios.py
The incident report a responder would be handed, and the figures in this README:
python scripts/report.py
python scripts/figures.py
alice / Password123! → changed during the demo to Correct-Horse-Battery-9!
Fake, local, and the only credentials in this repository.
This repository contains intentionally vulnerable code.
app/auth.py:_revoke_for_security_change fails to revoke on purpose when
AFTERLIFE_MODE=vulnerable.
The vulnerable path is not the default. AFTERLIFE_MODE defaults to fixed,
and test_the_default_mode_is_the_safe_one pins it — a lab whose broken behaviour
is what you get by forgetting to configure anything will eventually be copied
into something real.
Not safe to expose to a network in either mode: bearer credentials are returned in
response bodies, GET /lab/credentials dumps the server's whole view of a user's
credential state, GET /lab/audit is unauthenticated by design,
POST /security-change will grant itself admin, and there is no TLS, rate
limiting, CSRF protection or account lockout anywhere. Everything binds to
127.0.0.1, and both python -m app and python -m console refuse any other
address.
No real credential, key or secret appears in this repository. The signing key is
the literal string afterlife-lab-signing-key-not-a-secret-do-not-reuse; the
credential database defaults to :memory:; telemetry replaces values whose key
names look like credentials and refers to credentials by id and fingerprint.
Details and the reporting process: SECURITY.md.
Verified against primary sources on 2026-09-11. VERIFIED means the vendor's own advisory was read; REPORTED means the detail comes from a vulnerability database rather than the vendor.
Standards and classifications
The pattern this lab reproduces
@strapi/admin and @strapi/plugin-users-permissions ≤ 5.33.2; fixed in 5.33.3. CVSS v4.0 2.1 (Low), AV:N/AC:H/AT:N/PR:H/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N. Published 2026-05-13.
The vendor advisory states the refresh-token invalidation step was conditional on
a caller-supplied deviceId, and that the patch invalidates all refresh tokens
on every password change and reset regardless of whether a deviceId is
supplied. That conditional is what _revoke_for_security_change models, and that
patch is what the fixed mode implements.
Vendor context: Strapi security disclosure, May 2026.The same class, different mechanisms
Three products, three mechanisms — a conditional parameter, a missing call, an unrotated key — one outcome: the reset succeeded and the attacker stayed.
The attacker is not detected because their request looks malicious.
Every request they made was well-formed, correctly signed, and carried a credential the server had just minted for them.
They are detected because a credential lineage that should have died at a security event was accepted after it.
MIT · a local lab · nothing here is safe to deploy
| rule | severity | answers | fires |
|---|
| AFTERLIFE-002 | Refresh credential reuse | CRITICAL / MEDIUM | was it stolen? | at the reuse |
| AFTERLIFE-003 | Incomplete revocation at a security change | HIGH / LOW | did containment run? | at the change — no attacker needed |
| AFTERLIFE-001 | Post-revocation credential lineage use | HIGH | was the stale lineage used? | at the first acceptance |
| scenario | mode | events | worst | rules fired | stale | dormant |
|---|
legitimate-only | vulnerable | 19 | LOW | AFTERLIFE-003 | 0 | 0 |
legitimate-only | fixed | 19 | – | none | 0 | 0 |
stolen-refresh | vulnerable | 22 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
stolen-refresh | fixed | 23 | – | none | 0 | 0 |
stolen-refresh-with-device-id | vulnerable | 19 | LOW | AFTERLIFE-003 | 0 | 0 |
stolen-refresh-with-device-id | fixed | 19 | – | none | 0 | 0 |
multi-device | vulnerable | 29 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 2 | 0 |
multi-device | fixed | 29 | – | none | 0 | 0 |
refresh-reuse | vulnerable | 11 | MEDIUM | AFTERLIFE-002 | 0 | 0 |
refresh-reuse | fixed | 15 | MEDIUM | AFTERLIFE-002 | 0 | 0 |
dormant-survivor | vulnerable | 15 | HIGH | AFTERLIFE-003 | 1 | 1 |
dormant-survivor | fixed | 19 | – | none | 0 | 0 |
mfa-change | vulnerable | 18 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
mfa-change | fixed | 18 | – | none | 0 | 0 |
account-recovery | vulnerable | 18 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
account-recovery | fixed | 18 | – | none | 0 | 0 |
expired-lineage | vulnerable | 17 | LOW | AFTERLIFE-003 | 0 | 0 |
expired-lineage | fixed | 20 | – | none | 0 | 0 |
| case | outcome | why |
|---|
| Password change, then the replacement session is used | no alert | the new lineage's root is at or after the watermark |
Reset then immediate browsing (/me, /profile, /settings) | no alert | one fresh lineage, one fresh root |
| Phone, laptop and tablet, all correctly rotated | no alert | each login is its own lineage |
| A device that logged in after the change, browsing alongside the attacker | no alert | fresh root — and the stale lineage still alerts, alone |
| A stale credential that was rejected | no alert | result: failure is excluded; it is evidence for the defence |
| Clock skew up to 2s between components | no alert | the documented tolerance |
| A failed password change | no alert | not an anchor |
| Activity before the change | no alert | time-ordering check |
A rotated credential (reason: rotated) | not counted as death | a credential spent, not a lineage killed |
| A 40-day-old session whose credentials expired | not a survivor | expiry tracked per lineage |
| A first password change on a brand-new account | no alert | nothing was live going in |
| Refresh-token rotation from a stale lineage | ALERT | fresh timestamp, stale ancestry — this is the finding |
| change | why credentials become untrustworthy |
|---|
| password change / reset | the secret the session was established with is gone |
| MFA enrolment or change | the factors the session was established with are not the account's factors |
| role change / privilege escalation | the credential was minted under a different authorisation |
| account recovery | by construction, the account may have been in someone else's hands a moment ago |
| login | does not revoke others — a new lineage, not a statement that the old ones are untrusted |
| mechanism | buys | costs |
|---|
| A | per-user revocation watermark | one write revokes everything, including credentials the server has forgotten; O(1) storage and check | server-side state on the read path; timestamp semantics must be exactly right; says nothing about credentials issued after the watermark |
| B | short-lived access + revocable refresh | bounds access-token damage without read-path state | a stolen access token is valid until it expires; the refresh side still needs state — this is the pattern the CVE lives inside |
| C | explicit denylist | precise; legible to an incident responder; produces the telemetry that proves containment | state grows and needs cleanup; only revokes what you remembered to enumerate — which is the query the bug got wrong |
| D | fully stateless JWT | no read-path state at all | there is no revocation. TTL and key rotation are the only levers |
| file | what it pins |
|---|
test_fixed_mode.py | the regression suite that must never go red — no credential issued before a revocation event is accepted after it, including one descended from a stale lineage |
test_vulnerable_mode.py | the vulnerability exists, is deterministic, lasts 30 days, and is caused by the conditional — with the device_id control proving the revocation path is optional rather than dead |
test_lineage.py | a refresh chain keeps one lineage and one root; only login creates a lineage |
test_detector_afterlife001.py | 7 detection cases, 9 false-positive cases, 2 blind spots, all 24 event orderings, bounded state, malformed input |
test_detector_rulepack.py | AFTERLIFE-002 and AFTERLIFE-003 — every verdict, the grace window, rotation-is-not-death, expiry exclusion, and the engine's ordering |
test_naive_detector.py | NAIVE-001 keeps failing in the specific way this README claims — including that it had the data it needed |
test_audit_and_console.py | the dormant survivor no rule can see; both audit sources agreeing; the console computing no verdict of its own; the offline preview fetching nothing; every figure in this README in bounds, stylesheet-free and byte-stable |
test_scenarios.py | the matrix rows that carry an argument |
test_privilege_changes.py | MFA, role change and account recovery, both modes |
test_telemetry.py | the redaction contract, and a grep of the log for the literal bearer strings issued |
test_app.py | forged signatures, wrong credential types, expiry from server state |
test_infrastructure.py | config, clock, tailer partial lines, both CLIs, every demo command |
The live console, and the lab API by hand — both loopback only:
python -m console
python -m app --mode vulnerable --port 9101
Regenerate the committed telemetry (pinned clock, byte-identical between runs):
python scripts/lab.py evidence
Task runners, same targets either way:
make demo
./make.ps1 demo
| docs/detection.md | the rule pack: all three rule cards, the state audit, required telemetry, false positives, severity rationale, response runbooks |
| docs/tradeoffs.md | the four revocation architectures, the JWT tension, and the CVSS discussion |
| docs/limitations.md | every blind spot, what the fix does not fix, what the audit closes, and the lab's own compromises |
| docs/console-design.md | why the console is a mortality register, the palette and type decisions, and what got cut |
| docs/console-preview.html | the console, baked to one self-contained file |
| docs/figures/ | the figures in this README, generated from the payload |
| report/AFTERLIFE-report.md | a generated incident report for the vulnerable run |
| SECURITY.md | the local-only boundary, the lab credentials, the redaction contract |
| evidence/ | sanitized sample telemetry, the alerts it produces, and the state audit |