Skip to content
KitploitKITPLOIT
ToolsBlog
Submit
ToolsBlog
Submit

Hacking, PenTest, and Cybersecurity Tools for Your Security Arsenal!

Kitploit is a directory of hacking, cybersecurity, and pentesting tools. Discover the latest project updates to find vulnerabilities, analyze systems, automate testing, and strengthen your security.

··Feeds·Contact·Privacy·© 2026 Kitploit

Tool Directory

Categories

View all categories
Loading categories
AfterLife — 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. | Kitploit
Tools/GitHubGitHub/het-p301204/afterlife
Defensive ToolsVulnerability AnalysisWeb SecurityAuthenticationLearning & EducationRed TeamingIncident ResponseLabs & Practice
GitHubhet-p301204/afterlife

AfterLife

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.

View Repository
12h 41m agoNot yet reviewed

Most Popular

View all →

Discover the most used tools by our community.

Explore all tools

Browse our collection of tools

View all tools →
Share

AFTERLIFE

Revocation Persistence Detection Lab

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.

CI tests python rules OWASP CWE license

Quick start · The finding · Why naive detection fails · The rule pack · Console · The fix · Matrix · Tests · Docs


The same attack against both implementations. One bar per credential, from issue to death, grouped into lineages. The dashed line is the password change. In vulnerable mode five bars cross it and keep going; in fixed mode every bar in the stolen lineage stops there.

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.


The containment action that doesn't contain

root@kitploit:~
   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:

root@kitploit:~
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.


Why this matters

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.

  • Every account-takeover runbook begins with reset the password. Every product tells the user the same thing.
  • When it silently fails, the victim is told the problem is solved and stops looking — which switches off the detection signal that matters most in account takeover: the user noticing.
  • The persistence window is the refresh credential's lifetime: 30 days by default, renewable indefinitely by rotating. test_persistence_lasts_as_long_as_the_refresh_credential walks seven days of simulated time to show it.
  • There is no second action the user can take. A second password change does exactly as much as the first.

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 naive detector

The rule you write first:

root@kitploit:~
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:

Two rows, one per field. credential.issued_at reads 10:00:03, three seconds after the change, and concludes the credential looks clean. lineage.root_issued_at reads 09:00:00, an hour before the change, and alerts.

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:

root@kitploit:~
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:

root@kitploit:~
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:

root@kitploit:~
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.


The rule pack

Three rules over one log. They answer different questions, and they fire in the order an incident actually unfolds in.

root@kitploit:~
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.

Two alert cards. AFTERLIFE-003 at 10:00:00 reports verdict no_containment, watermark not recorded, scope none, and lin-001 surviving. AFTERLIFE-001 at 10:00:03 reports credential rt-002 with a root issued an hour before the change.

application recorded watermark: no is the field that points an incident responder at the defect rather than at the symptom.

The watermark the rules use is not the one the application reports

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.

Properties

root@kitploit:~
  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.


The console

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.

The full AFTERLIFE console in vulnerable mode: masthead, the verdict in serif, a five-cell counter strip, the two-field comparison, the lifeline chart with five bars crossing the guillotine, and both alert cards.

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.

root@kitploit:~
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:

root@kitploit:~
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_surface asserts the only non-GET route is /api/rebuild. A reading surface that can change what it is reading is one you cannot trust.


Architecture

Six packages, one security class, no infrastructure.

root@kitploit:~
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.

Server-side enforcement

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.


Attack timeline

root@kitploit:~
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:#fff

The 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.


Token lineage

A session is the root of a lineage. Everything minted beneath it inherits that root, forever.

root@kitploit:~
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:

  1. Only authentication creates a lineage. Store.open_session is the sole place a lineage_id is generated, and it makes the session its own root_credential_id.
  2. Minting copies the root down. 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.


The state audit

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.

root@kitploit:~
  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
root@kitploit:~
python scripts/lab.py audit
root@kitploit:~
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.


Scenario matrix

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.


False positives

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

root@kitploit:~
# 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:

root@kitploit:~
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.

The same attack, against the fix

The console in fixed mode: the verdict reads "Nothing issued before the change works after it", the counters are all green, every bar in lin-001 terminates at the guillotine, and the findings panel reports no findings with five passing checks.

Not one request changed. One function did.

Not just passwords

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.


Architectural tradeoffs

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.

Why a 2.1 Low is the wrong thing to argue about

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.


Tests

root@kitploit:~
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.


Blind spots

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:

  • The detector cannot refuse a request. It tails a log. It tells you containment failed; it does not contain.
  • The audit is a scan, not a detection. It closes the dormant-survivor gap, but it runs when someone runs it. It cannot page.
  • The fix does not help with credentials stolen after the reset. Containment is a point in time, not a property.
  • AFTERLIFE-003 verifies observed containment, not architectural completeness. A device-scoped revocation that happens to cover everything gets the LOW finding, not the HIGH one. The bug is still there; it did not bite on that account. A test can ask "what about two devices?"; a log can only report what did happen.
  • Other persistence survives a password change entirely — OAuth grants, API keys, mail-forwarding rules, recovery contacts.

Full discussion: docs/limitations.md.


Running the lab

root@kitploit:~
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:

root@kitploit:~
python scripts/lab.py vulnerable
root@kitploit:~
python scripts/lab.py detect
root@kitploit:~
python scripts/lab.py fixed
root@kitploit:~
python scripts/lab.py audit

Or the whole story at once, with the lesson at the end:

root@kitploit:~
python scripts/lab.py all

And the picture:

root@kitploit:~
python scripts/lab.py console
Everything else — the detector CLI, the matrix, the report, the figures, the servers

Replay the committed evidence through the pack. No application run needed — the detector is strictly downstream, and this is the proof:

root@kitploit:~
python -m detector --once --timeline --events evidence/vulnerable-persistence.jsonl

One rule at a time, or the naive one, or the state scan:

root@kitploit:~
python -m detector --once --rule AFTERLIFE-003 --events evidence/vulnerable-persistence.jsonl
root@kitploit:~
python -m detector --once --naive --events evidence/vulnerable-persistence.jsonl
root@kitploit:~
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:

root@kitploit:~
python scripts/scenarios.py

The incident report a responder would be handed, and the figures in this README:

root@kitploit:~
python scripts/report.py
root@kitploit:~
python scripts/figures.py

The lab account

root@kitploit:~
alice / Password123!      →  changed during the demo to  Correct-Horse-Battery-9!

Fake, local, and the only credentials in this repository.


Security notes

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.


Documentation


Research references

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

  • OWASP Top 10:2025 — A07 Authentication Failures — describes exactly this failure: applications that do not "correctly invalidate user sessions or authentication tokens". Its CWE mapping includes all three below.
  • RFC 9700 — Best Current Practice for OAuth 2.0 Security (BCP 240, January 2025). Two things this lab rests on: reuse of a rotated refresh token signals theft (AFTERLIFE-002), and revocation must invalidate the entire token family, not just the current token — which is AFTERLIFE-001's thesis as codified IETF practice.
  • OWASP Session Management Cheat Sheet — session invalidation after credential and security changes
  • CWE-613: Insufficient Session Expiration — "permits an attacker to reuse old session credentials or session IDs for authorization". The class all three CVEs below are filed under.
  • CWE-287: Improper Authentication
  • CWE-384: Session Fixation — adjacent: a session identifier that survives an authentication state change

The pattern this lab reproduces

  • VERIFIED — GHSA-hvp3-26wx-g2w4 / CVE-2026-22706, Strapi: Password Reset Does Not Revoke Existing Refresh Sessions. @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

  • REPORTED — CVE-2026-1163, parisneo/lollms. No session invalidation on password reset at all, with a 31-day default session lifetime. The simple version of this bug — and the one the naive detector would actually catch.
  • REPORTED — CVE-2026-40934, Jupyter Server ≤ 2.17.0 (fixed 2.18.0). The cookie-signing secret is persisted to a static file and never rotated on password change, so cookies issued before a reset stay cryptographically valid across it. A third route to the same outcome: the credential here is valid because the key never changed, which is Option D's failure mode from docs/tradeoffs.md.

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

Download Tool
ruleseverityanswersfires
AFTERLIFE-002Refresh credential reuseCRITICAL / MEDIUMwas it stolen?at the reuse
AFTERLIFE-003Incomplete revocation at a security changeHIGH / LOWdid containment run?at the change — no attacker needed
AFTERLIFE-001Post-revocation credential lineage useHIGHwas the stale lineage used?at the first acceptance
scenariomodeeventsworstrules firedstaledormant
legitimate-onlyvulnerable19LOWAFTERLIFE-00300
legitimate-onlyfixed19–none00
stolen-refreshvulnerable22HIGHAFTERLIFE-001, AFTERLIFE-00310
stolen-refreshfixed23–none00
stolen-refresh-with-device-idvulnerable19LOWAFTERLIFE-00300
stolen-refresh-with-device-idfixed19–none00
multi-devicevulnerable29HIGHAFTERLIFE-001, AFTERLIFE-00320
multi-devicefixed29–none00
refresh-reusevulnerable11MEDIUMAFTERLIFE-00200
refresh-reusefixed15MEDIUMAFTERLIFE-00200
dormant-survivorvulnerable15HIGHAFTERLIFE-00311
dormant-survivorfixed19–none00
mfa-changevulnerable18HIGHAFTERLIFE-001, AFTERLIFE-00310
mfa-changefixed18–none00
account-recoveryvulnerable18HIGHAFTERLIFE-001, AFTERLIFE-00310
account-recoveryfixed18–none00
expired-lineagevulnerable17LOWAFTERLIFE-00300
expired-lineagefixed20–none00
caseoutcomewhy
Password change, then the replacement session is usedno alertthe new lineage's root is at or after the watermark
Reset then immediate browsing (/me, /profile, /settings)no alertone fresh lineage, one fresh root
Phone, laptop and tablet, all correctly rotatedno alerteach login is its own lineage
A device that logged in after the change, browsing alongside the attackerno alertfresh root — and the stale lineage still alerts, alone
A stale credential that was rejectedno alertresult: failure is excluded; it is evidence for the defence
Clock skew up to 2s between componentsno alertthe documented tolerance
A failed password changeno alertnot an anchor
Activity before the changeno alerttime-ordering check
A rotated credential (reason: rotated)not counted as deatha credential spent, not a lineage killed
A 40-day-old session whose credentials expirednot a survivorexpiry tracked per lineage
A first password change on a brand-new accountno alertnothing was live going in
Refresh-token rotation from a stale lineageALERTfresh timestamp, stale ancestry — this is the finding
changewhy credentials become untrustworthy
password change / resetthe secret the session was established with is gone
MFA enrolment or changethe factors the session was established with are not the account's factors
role change / privilege escalationthe credential was minted under a different authorisation
account recoveryby construction, the account may have been in someone else's hands a moment ago
logindoes not revoke others — a new lineage, not a statement that the old ones are untrusted
mechanismbuyscosts
Aper-user revocation watermarkone write revokes everything, including credentials the server has forgotten; O(1) storage and checkserver-side state on the read path; timestamp semantics must be exactly right; says nothing about credentials issued after the watermark
Bshort-lived access + revocable refreshbounds access-token damage without read-path statea stolen access token is valid until it expires; the refresh side still needs state — this is the pattern the CVE lives inside
Cexplicit denylistprecise; legible to an incident responder; produces the telemetry that proves containmentstate grows and needs cleanup; only revokes what you remembered to enumerate — which is the query the bug got wrong
Dfully stateless JWTno read-path state at allthere is no revocation. TTL and key rotation are the only levers
filewhat it pins
test_fixed_mode.pythe 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.pythe 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.pya refresh chain keeps one lineage and one root; only login creates a lineage
test_detector_afterlife001.py7 detection cases, 9 false-positive cases, 2 blind spots, all 24 event orderings, bounded state, malformed input
test_detector_rulepack.pyAFTERLIFE-002 and AFTERLIFE-003 — every verdict, the grace window, rotation-is-not-death, expiry exclusion, and the engine's ordering
test_naive_detector.pyNAIVE-001 keeps failing in the specific way this README claims — including that it had the data it needed
test_audit_and_console.pythe 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.pythe matrix rows that carry an argument
test_privilege_changes.pyMFA, role change and account recovery, both modes
test_telemetry.pythe redaction contract, and a grep of the log for the literal bearer strings issued
test_app.pyforged signatures, wrong credential types, expiry from server state
test_infrastructure.pyconfig, clock, tailer partial lines, both CLIs, every demo command

The live console, and the lab API by hand — both loopback only:

root@kitploit:~
python -m console
root@kitploit:~
python -m app --mode vulnerable --port 9101

Regenerate the committed telemetry (pinned clock, byte-identical between runs):

root@kitploit:~
python scripts/lab.py evidence

Task runners, same targets either way:

root@kitploit:~
make demo
root@kitploit:~
./make.ps1 demo
docs/detection.mdthe rule pack: all three rule cards, the state audit, required telemetry, false positives, severity rationale, response runbooks
docs/tradeoffs.mdthe four revocation architectures, the JWT tension, and the CVSS discussion
docs/limitations.mdevery blind spot, what the fix does not fix, what the audit closes, and the lab's own compromises
docs/console-design.mdwhy the console is a mortality register, the palette and type decisions, and what got cut
docs/console-preview.htmlthe console, baked to one self-contained file
docs/figures/the figures in this README, generated from the payload
report/AFTERLIFE-report.mda generated incident report for the vulnerable run
SECURITY.mdthe local-only boundary, the lab credentials, the redaction contract
evidence/sanitized sample telemetry, the alerts it produces, and the state audit