Skip to content
KitploitKITPLOIT
ToolsExploitsBlog
Log in
Submit
ToolsExploitsBlog
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.

FeedsContactPrivacy© 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.

1920 days 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 →
View Repository
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

   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.


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:

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:

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
Download Tool