
Lab zur Erkennung persistenter Revocation: wenn das Zurücksetzen des Passworts erfolgreich ist, der Angreifer aber nie geht. Reproduziert den bedingten Revocation-Bug Strapi CVE-2026-22706, seinen Fix, ein Erkennungspaket mit drei Regeln und die naive Regel, die ihn übersieht.
Wenn das Zurücksetzen des Passworts gelingt, aber der Angreifer nie verschwindet.
Ein lokales Rot/Blau-Labor für eine einzige Schwachstellenklasse: die Anmeldedaten, die das Ereignis überleben, das sie eigentlich hätte vernichten sollen. Es liefert den Exploit, die Grundursache, den Fix, ein Erkennungspaket mit drei Regeln, ein Zustands-Audit für das, was die Regeln strukturell nicht sehen können, eine forensische Konsole — und die Erkennungsregel, die nicht funktioniert, im Repository belassen, um ihr Scheitern vorzuführen.
Schnellstart · Der Befund · Warum naive Erkennung scheitert · Das Regelpaket · Konsole · Der Fix · Matrix · Tests · Dokumentation
Derselbe Angriff. Dieselben Anfragen. Ein Unterschied.
Generiert von scripts/figures.py aus derselben Payload, die die Konsole zeichnet — in CI neu generiert und diffed, damit eine Abbildung nicht vom Code abweichen kann.
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}
Kein Fehler. Keine Anomalie. Keine fehlgeschlagene Authentifizierung zum Zählen. Die Zugangsdaten des Angreifers sind *drei Sekunden alt* und wurden vom Server auf Anfrage nach dem Reset erzeugt.
Hier ist die gesamte Schwachstelle:```python
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
Lesen Sie das so, wie es ein Reviewer tun würde. Die Revocation-Logik ist direkt dort. Sie ruft die richtige Funktion mit dem richtigen Scope auf. Der Endpunkt drumherum aktualisiert den Passwort-Hash, gibt 200 zurück und stellt eine frische Session aus — jedes beobachtbare Verhalten einer korrekten Passwortänderung ist vorhanden.
Und wenn der Aufrufer device_id weglässt, wird nichts widerrufen, und der Endpunkt meldet trotzdem Erfolg.
Das ist nicht hypothetisch. Es ist
CVE-2026-22706
in Strapi ≤ 5.33.2, wo der Refresh-Token-Invalidierungsschritt von einem vom Aufrufer gelieferten deviceId abhängig war. Bewertet mit 2.1, Low. Diskutiert
unten.
Die Bewertung ist niedrig, weil der Angreifer bereits Zugriff hatte — das ist die Eintrittsbedingung, und dieser Bug gewährt nichts Neues. Was er gewährt, ist Dauer, und er tut dies, indem er die eine Kontrolle bricht, die das Opfer selbst bedienen kann.
test_persistence_lasts_as_long_as_the_refresh_credential durchläuft sieben Tage simulierte Zeit, um das zu zeigen.Ein Low-bewerteter Bug in einem Containment-Pfad kostet mehr als ein Low-bewerteter Bug in einem Feature-Pfad, weil die Kosten während eines Vorfalls anfallen, wenn niemand Advisories liest.
Die Regel, die man zuerst schreibt:```text IF credential.issued_at < credential_change.timestamp: ALERT
Es ist keine dumme Regel. Sie ist billig, braucht ein Feld, liest sich wie die Definition
des Problems, und sie hat **recht im einfachen Fall** — ein Angreifer, der ein
gestohlenes *Access*-Token nach dem Reset verwendet, wird erwischt.
`test_the_naive_rule_catches_the_simple_case` bestätigt, dass sie funktioniert.
Beide Regeln stellen dieselbe Frage über dieselbe Credential. Sie lesen unterschiedliche
Felder, um sie zu beantworten, und die beiden Felder widersprechen sich:

**Drei Sekunden danach, oder eine Stunde davor — dieselbe Credential, zum selben
Zeitpunkt.** Die naive Regel fragt ein Feld ab, das der Angreifer kontrolliert, und ein
`POST /refresh` setzt es zurück.
Führe sie über das Request-Log aus, wo diese Abfrage tatsächlich geschrieben wird,
denn Request-Logs sind das, was man hat:```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.
Es wäre einfach, dort aufzuhören, und unehrlich, also führt das Labor auch die fairste Version der naiven Regel aus — erweitert, um auch den Refresh-Endpunkt zu überwachen:```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
Das ist der Unterschied, der um 3 Uhr morgens zählt. Die naive Regel erfasst
den einen Augenblick, in dem die Kette die Grenze überschreitet, und verliert
dann die Spur für die verbleibenden 29 Tage — und die Credential, die sie
benennt, wurde *bereits rotiert und vom Server widerrufen*. Sie zu widerrufen
bewirkt nichts. `lin-001` ist das Objekt, das du töten musst.
**Und der naiven Regel fehlte es nicht an Telemetrie.** Sie nutzt denselben
Event-Stream, denselben Lineage-Index, dieselbe Toleranz, dieselbe
Deduplizierung und denselben begrenzten Zustand. Sie überschreibt eine Methode:```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 steht im Index, den er bereits konsultiert.
test_the_naive_rule_had_the_data_it_needed beweist es. Das Scheitern liegt im
Vergleich, nicht im Logging.
Drei Regeln über einem Log. Sie beantworten unterschiedliche Fragen und sie feuern in der Reihenfolge, in der sich ein Vorfall tatsächlich entfaltet.
| Regel | Schweregrad | beantwortet | feuert | |
|---|---|---|---|---|
| AFTERLIFE-002 | Refresh-Credential-Wiederverwendung | CRITICAL / MEDIUM | wurde sie gestohlen? | bei der Wiederverwendung |
| AFTERLIFE-003 | Unvollständiger Widerruf bei einer Sicherheitsänderung | HIGH / LOW | lief die Eindämmung? | bei der Änderung — kein Angreifer nötig |
| AFTERLIFE-001 | Nutzung der Credential-Lineage nach Widerruf | HIGH | wurde die veraltete Lineage genutzt? | bei der ersten Akzeptanz |
| 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
**Diese Reihenfolge ist das Nützlichste in diesem Repository.** AFTERLIFE-003
schlägt in dem Moment an, in dem die Änderung erfolgt, drei Sekunden bevor der Angreifer
irgendetwas berührt, weil die benötigten Beweise bereits vollständig sind: Das Log sagt, welche
Lineages beim Eintritt aktiv waren, und es sagt nicht, dass sie widerrufen wurden.
Es braucht kein Opfer und keine Ausnutzung. Es meldet den Defekt beim ersten
Passwort-Reset, den ein Benutzer durchführt — was es zu dem einen macht, das man im Staging
ausführt, wo es keinen Angreifer gibt, auf den man warten muss. AFTERLIFE-001 sagt einem, dass ein Einbruch
im Gange ist; AFTERLIFE-003 sagt einem, dass die eigene Eindämmungskontrolle defekt ist.

`application recorded watermark: no` ist das Feld, das einen Incident
Responder auf den *Defekt* statt auf das Symptom hinweist.
### Das Wasserzeichen, das die Regeln verwenden, ist nicht das, das die Anwendung meldet
Das Credential-Change-Event trägt ein `revocation_watermark`-Feld — den
`credentials_valid_after`-Wert, den die Anwendung geschrieben hat. **In der verwundbaren
Implementierung ist er `null`, weil die Anwendung nie einen geschrieben hat.** Das ist
der Bug.
Eine Regel, die auf dieses Feld abzielt, wäre also blind für genau den Fall, den sie abfangen soll.
Die Regeln verankern sich am eigenen `timestamp` des Events, der wahr ist, unabhängig davon, ob
die Anwendung ihre Aufgabe erfüllt hat, und melden das fehlende Feld als Beweis.
### Eigenschaften```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
Die Unabhängigkeit der Reihenfolge ist kein „funktioniert größtenteils“:
test_6b_every_permutation_of_the_critical_events_detects führt alle 24 Reihenfolgen
der vier relevanten Ereignisse aus und verlangt genau einen Alarm von jedem.
Die Deduplizierung basiert auf (user_id, credential_change_event_id, lineage_id),
mit event_id-Unterdrückung davor, damit eine erneut eingespielte Datei die
Zählung nicht aufblähen kann.
Vollständige Regelkarten, erforderliche Telemetrie und Response-Runbooks: docs/detection.md.
Eine Leseoberfläche für die eine Frage, um die es im Lab geht. Kein Dashboard von Alarm- Zählungen — ein Sterberegister. Ein Balken pro Credential, von der Ausstellung bis zum Tod, gruppiert nach der Abstammungslinie, von der es abstammt, mit der Passwortänderung als Linie gezeichnet, an der alles davor enden sollte.
Eine bewusste Umkehrung: Wärme bedeutet lebendig, und nach der Linie ist Wärme
falsch. In den meisten Sicherheits-UIs bedeutet Rot, dass ein Fehler aufgetreten ist. Hier
tritt kein Fehler auf — jede Anfrage im verwundbaren Durchlauf liefert 200. Die Farbe folgt
stattdessen der Sterblichkeit: Kalt ist ein Credential, das gestorben ist, als es dazu
angewiesen wurde, warm ist eines, das noch atmet, und jenseits der Guillotine ist das
Weiteratmen der ganze Befund.
(BLACKOUT setzte dieselbe Konvention, wobei 200 das Rote war.)
Die dünnen vertikalen Tropfen sind Genealogie: ein Kind-Credential, das in diesem
Augenblick aus seinem Elternteil erzeugt wird. rt-001 → rt-002 → rt-004 steigt über das
gesamte Fenster nach unten und rechts, und im verwundbaren Modus steigt es nach dem
Schnitt weiter — was das Bild einer Abstammungslinie ist, die neue Credentials jenseits
ihres eigenen Aussterbeereignisses erzeugt.```bash
python scripts/lab.py console
schreibt **[`docs/console-preview.html`](https://github.com/het-p301204/afterlife/blob/main/docs/console-preview.html)** — eine
in sich geschlossene 74 KiB große Datei mit beiden Durchläufen fest eingebaut. Kein Server, kein Netzwerk, keine
Schriftarten zum Abrufen; öffne sie direkt aus dem Dateisystem. Oder führe die Live-Version aus:```bash
python -m console
Ziehen Sie den Scrubber über 10:00:00 hinaus zurück und wieder vorwärts: Die Befunde erscheinen,
wenn das Protokoll sie verdient, AFTERLIFE-003 bei der Änderung und AFTERLIFE-001 drei
Sekunden später. Die Designbegründung, einschließlich dessen, was gestrichen wurde, findet sich in
docs/console-design.md.
Die Konsole ist konstruktionsbedingt schreibgeschützt. Sie führt abgeschlossene Szenarien erneut aus und zeichnet sie auf; sie kann die Implementierung nicht umschalten, die Uhr nicht verschieben oder etwas widerrufen.
test_the_console_has_no_control_surfacestellt sicher, dass die einzige Nicht-GET- Route/api/rebuildist. Eine Leseoberfläche, die ändern kann, was sie liest, ist eine, der man nicht trauen kann.
Sechs Pakete, eine Sicherheitsklasse, keine Infrastruktur.```text
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
Die Anwendung läuft **in-process** unter der Demo, der Konsole und den Tests. Keine
Ports, kein Dev-Server, kein Docker. Die Uhr ist fixiert, sodass jeder Lauf
deterministisch ist und die committete Telemetrie, Konsole, Abbildungen und Berichte alle
byte-identisch zwischen Läufen sind — was CI mit `git diff --exit-code` prüft.
### Serverseitige Durchsetzung
Ein Credential ist eine **Zeile**, kein String. Das JWT, das der Client hält, trägt `cid` —
einen Lookup-Schlüssel — und die Zeile entscheidet, ob das Credential lebendig ist.
Der Ablauf wird gegen das `expires_at` der Zeile geprüft, nicht gegen den `exp`-Claim, den
der Inhaber präsentiert hat. Beide temporalen Claims werden an PyJWT mit `verify=False`
übergeben, was hier bedeutet *„der Server prüft dies selbst"*, und [`app/auth.py`](https://github.com/het-p301204/afterlife/blob/main/app/auth.py)
tut dies bei jeder Anfrage. Der Client hat nie ein Mitspracherecht darüber, ob sein eigenes
Credential noch gültig ist.
---
## Angriffszeitlinie```mermaid
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
Der Refresh-Chain-Zweig in der Mitte ist der springende Punkt. Der Angreifer verwendet keine alte Credential. Er verwendet eine alte Lineage, und die Lineage prägt bei Bedarf etwas Neues.
Eine Session ist die Wurzel einer Lineage. Alles, was darunter geprägt wird, erbt diese Wurzel, für immer.```text 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` ist drei Sekunden alt. Seine Abstammung ist eine Stunde alt. Beide
Tatsachen sind wahr, und nur eine davon ist in einem Request-Log sichtbar.
Zwei Invarianten halten dies zusammen, und beide sind getestet:
1. **Nur Authentifizierung erzeugt eine Abstammungslinie.** `Store.open_session` ist der
einzige Ort, an dem eine `lineage_id` generiert wird, und es macht die Session zu ihrer eigenen
`root_credential_id`.
2. **Minting kopiert die Wurzel nach unten.** `TokenService.mint` liest die Lineage-Felder
vom Parent, anstatt sie neu zu berechnen, sodass eine Credential keine
frischere Abstammung erlangen kann als der Login, von dem sie abstammt.
Ein vom Inhaber präsentiertes Token trägt bewusst **nicht** seine Lineage-Wurzel. Wenn es
das täte, könnte der Inhaber darüber lügen.
---
## Das State-Audit
AFTERLIFE-001 wird ausgelöst, wenn eine veraltete Lineage *verwendet* wird. Das ist der richtige Auslöser für
eine Detection-Regel, und es lässt eine Lücke: **eine veraltete Lineage, die niemand berührt hat, ist
für sie unsichtbar.** Ein Angreifer, der eine Credential stiehlt, beobachtet, wie das Zurücksetzen fehlschlägt,
und dann wartet, erzeugt keine Aktivität zum Korrelieren.
Also stellt das Lab auch die Frage, die eine Regel nicht stellen kann: nicht *was passiert ist*, sondern *was
noch am Leben ist*.```text
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
| --no-color | Deaktiviert farbige Ausgabe |
| --verbose | Aktiviert ausführliche Ausgabe |
| --quiet | Unterdrückt alle Ausgaben außer Fehlern |
| --config | Gibt eine benutzerdefinierte Konfigurationsdatei an |
| --timeout | Legt das Anforderungs-Timeout in Sekunden fest |
| --retry | Anzahl der Wiederholungsversuche bei fehlgeschlagenen Anforderungen |
| --proxy | Gibt einen Proxy für Anforderungen an |
| --header | Fügt einen benutzerdefinierten Header zu Anforderungen hinzu |
| --cookie | Legt ein Cookie für Anforderungen fest |
| --user-agent | Legt den User-Agent-Header fest |
| --output | Schreibt die Ausgabe in eine Datei |
| --format | Legt das Ausgabeformat fest (json, yaml, text) |
| --threads | Anzahl der zu verwendenden Threads |
| --rate-limit | Begrenzt die Anforderungsrate |
| --follow-redirects | Folgt HTTP-Weiterleitungen |
| --insecure | Deaktiviert die SSL-Zertifikatsüberprüfung |
| --debug | Aktiviert den Debug-Modus |```bash
python scripts/lab.py audit
| `--no-color` | Deaktiviert farbige Ausgabe |
| `--verbose` | Aktiviert ausführliche Ausgabe |
| `--quiet` | Unterdrückt alle Ausgaben außer Fehlern |
| `--config` | Gibt den Pfad zur Konfigurationsdatei an |
| `--output` | Gibt das Ausgabeformat an (json, yaml, table) |
| `--timeout` | Legt das Anforderungszeitlimit in Sekunden fest |
| `--retry` | Anzahl der Wiederholungsversuche bei fehlgeschlagenen Anfragen |
| `--proxy` | Gibt den zu verwendenden Proxy an |
| `--header` | Fügt einen benutzerdefinierten Header zu den Anfragen hinzu |
| `--cookie` | Fügt ein Cookie zu den Anfragen hinzu |
| `--user-agent` | Legt den User-Agent-Header fest |
| `--follow-redirects` | Folgt HTTP-Weiterleitungen |
| `--insecure` | Deaktiviert die TLS-Zertifikatsüberprüfung |
| `--rate-limit` | Legt die maximale Anforderungsrate fest |
| `--concurrency` | Legt die Anzahl gleichzeitiger Anforderungen fest |
| `--wordlist` | Gibt die zu verwendende Wortliste an |
| `--threads` | Legt die Anzahl der Threads fest |
| `--silent` | Unterdrückt alle Ausgaben außer Ergebnissen |
| `--debug` | Aktiviert den Debug-Modus |
| `--version` | Zeigt Versionsinformationen an |
| `--help` | Zeigt Hilfemeldungen an |```text
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
Zwei Quellen, bewusst: Der Telemetrie-Audit sieht, was das Log beweisen kann, der Server-State-Audit sieht, was die Datenbank glaubt. Wo sie sich widersprechen, ist das Log kein getreues Abbild des Credential-Zustands, und jede darauf aufgebaute Erkennung ist schwächer, als sie aussieht — deshalb gibt der Befehl beides aus und sagt es, wenn sie voneinander abweichen.
Der Server-State-Audit benötigt die Tabelle security_changes, die beide Modi
schreiben, denn das Festhalten, dass eine Änderung stattgefunden hat, ist eine
eigene Verpflichtung, getrennt vom Handeln danach — und die verwundbare
Implementierung erfüllt genau eine der beiden.
Neun Szenarien gegen beide Implementierungen. python scripts/scenarios.py prüft
seine eigenen zwei Invarianten und beendet sich mit einem Fehlercode, wenn eine
davon verletzt wird.
| Szenario | Modus | Ereignisse | schlimmster Fall | ausgelöste Regeln | stale | dormant |
|---|---|---|---|---|---|---|
legitimate-only | vulnerable | 19 | LOW | AFTERLIFE-003 | 0 | 0 |
legitimate-only | fixed | 19 | – | keine | 0 | 0 |
stolen-refresh | vulnerable | 22 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
stolen-refresh | fixed | 23 | – | keine | 0 | 0 |
stolen-refresh-with-device-id | vulnerable | 19 | LOW | AFTERLIFE-003 | 0 | 0 |
stolen-refresh-with-device-id | fixed | 19 | – | keine | 0 | 0 |
multi-device | vulnerable | 29 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 2 | 0 |
multi-device | fixed | 29 | – | keine | 0 | 0 |
refresh-reuse | vulnerable | 11 | MEDIUM | AFTERLIFE-002 | 0 | 0 |
refresh-reuse | fixed | 15 | MEDIUM | AFTERLIFE-002 | 0 | 0 |
Vier Zeilen tragen ein Argument:
legitimate-only enthält überhaupt keinen Angreifer. Der Fix produziert
nichts; die verwundbare Implementierung produziert ein LOW. Das ist kein
False Positive — es ist enumeration_only: Die Revocation hat diesmal
funktioniert, durch Enumeration, ohne Watermark, die ein Credential abdeckt,
das der Server vergessen hat. Das Pack trennt die beiden Implementierungen
ohne anwesenden Angreifer.dormant-survivor ist der blinde Fleck und seine Antwort in einer Zeile:
AFTERLIFE-001 still, AFTERLIFE-003 HIGH, Audit dormant: 1.multi-device ist der Fall, in dem der funktionierende Zweig der
verwundbaren Implementierung dennoch scheitert — die Revocation ist auf das
Laptop beschränkt, zwei andere Geräte bleiben unberührt.expired-lineage ist die False-Positive-Kontrolle: Eine 40 Tage alte
Session, deren Credentials von selbst abgelaufen sind, ist kein überlebender
Persistenzpfad und wird auch nicht als solcher gemeldet.Und die zentrale Behauptung der Matrix, geprüft durch das Skript und durch
test_the_fix_never_produces_a_control_failure_finding: Der Fix erzeugt in
keinem Szenario einen Control-Failure-Finding. AFTERLIFE-002 wird
durchgelassen — es meldet einen Diebstahl, keinen Control-Failure, und eine
korrekte Implementierung hat weiterhin Diebstähle zu melden.
Eine Regel, die bei jedem Passwort-Reset alarmiert, wird innerhalb einer Woche stummgeschaltet, und eine stummgeschaltete Regel ist schlimmer als keine Regel — sie ist eine Regel, von der alle glauben, dass sie läuft.
| Fall | Ergebnis | Warum |
|---|---|---|
| Passwortänderung, dann wird die Ersatz-Session genutzt | kein Alarm | die Wurzel der neuen Lineage liegt auf oder nach der Watermark |
Reset, dann sofortiges Browsen (/me, /profile, /settings) | kein Alarm | eine frische Lineage, eine frische Wurzel |
| Telefon, Laptop und Tablet, alle korrekt rotiert | kein Alarm | jeder Login ist seine eigene Lineage |
| Ein Gerät, das sich nach der Änderung angemeldet hat und neben dem Angreifer browst | kein Alarm | frische Wurzel — und die stale Lineage alarmiert weiterhin, allein |
| Ein stale Credential, das abgelehnt wurde | kein Alarm | result: failure ist ausgeschlossen; es ist ein Beweis für die Verteidigung |
| Clock Skew bis zu 2s zwischen Komponenten | kein Alarm | die dokumentierte Toleranz |
| Eine fehlgeschlagene Passwortänderung | kein Alarm | kein Anker |
| Aktivität vor der Änderung | kein Alarm | Zeitordnungsprüfung |
Ein rotiertes Credential (reason: rotated) | zählt nicht als Tod | ein verbrauchtes Credential, keine getötete Lineage |
| Eine 40 Tage alte Session, deren Credentials abgelaufen sind | kein Survivor | Ablauf wird pro Lineage verfolgt |
| Eine erste Passwortänderung bei einem brandneuen Konto | kein Alarm | es war nichts live, als es begann |
| Refresh-Token-Rotation aus einer stale Lineage | ALARM | frischer Zeitstempel, stale Abstammung — das ist der Finding |
Jede Zeile hat einen Test in
tests/test_detector_afterlife001.py und
tests/test_detector_rulepack.py.
Zur 2-Sekunden-Toleranz. Jeder Zeitstempel in diesem Lab stammt aus einem
Prozess und einer Uhr, also ist die ehrliche Toleranz null. 2s ist das, was eine
realistische Zwei-Host-Bereitstellung unter NTP benötigt, und es ist drei
Größenordnungen kleiner als die Lücken, die dieser Angriff erzeugt — der ganze
Zweck eines Refresh-Credentials ist es, langlebig zu sein. Die Toleranz ist ein
Fenster, in dem eine tatsächlich stale Lineage ignoriert wird, deshalb wird sie
bewusst klein gehalten:
test_12b_a_gap_beyond_the_tolerance_does_alert legt fest, dass 2,1s weiterhin
auslöst.
self.store.set_watermark(user.user_id, moment) # the guarantee revoked = self.store.revoke_credentials(user.user_id, ...) # defence in depth
Weder hängt von `device_id` ab. Es wird als Kontext erfasst und hat keinen Einfluss auf den
Blast Radius.
**Das Watermark ist die architektonische Garantie.** Ein Zeitstempel pro Benutzer,
`credentials_valid_after`, wird bei jeder Anfrage sowohl gegen die
Ausstellung des Credentials selbst *als auch* gegen dessen Lineage-Root verglichen:```python
watermark = user.credentials_valid_after
if credential.issued_at < watermark: reject # the obvious case
if credential.root_issued_at < watermark: reject # the refresh chain
Die zweite Gegenüberstellung ist diejenige, deren korrekte Umsetzung etwas kostet, und diejenige, die eine naive Implementierung auslässt – bei der Durchsetzung genauso wie bei der Erkennung.
Explizite Widerrufung ist Verteidigung in der Tiefe, und Beweismittel. revoked_at und revocation_reason sind das, was ein Incident Responder liest, und die auth.token.revoked-Events sind das, was beweist, dass eine Eindämmung stattgefunden hat.
test_the_watermark_alone_rejects_a_stale_credential setzt das Watermark ohne irgendetwas zu widerrufen und verlangt, dass die veraltete Credential abgelehnt wird – was feststellt, welche der beiden tragend ist, und welche die Eigenschaft ist, die auch für Credentials gilt, von denen der Server vergessen hat, dass er sie ausgestellt hat.
Keine einzige Anfrage hat sich geändert. Eine Funktion schon.
Der Auslöser ist nicht „das Passwort hat sich geändert". Er ist „etwas hat sich geändert, das zuvor ausgestellte Credentials unglaubwürdig macht". Alle vier Routen teilen _revoke_for_security_change, sodass der Fix und der Bug identisch gelten für:
| Änderung | warum Credentials unglaubwürdig werden |
|---|---|
| Passwortänderung / -reset | das Geheimnis, mit dem die Session etabliert wurde, ist weg |
| MFA-Registrierung oder -Änderung | die Faktoren, mit denen die Session etabliert wurde, sind nicht die Faktoren des Kontos |
| Rollenänderung / Privilegieneskalation | die Credential wurde unter einer anderen Autorisierung geprägt |
| Kontowiederherstellung | konstruktionsbedingt könnte das Konto einen Moment zuvor in jemandes anderer Hand gewesen sein |
| Login | widerruft nicht andere – eine neue Abstammungslinie, keine Aussage, dass die alten nicht vertrauenswürdig sind |
Getestet für jede Art, in beiden Modi, in tests/test_privilege_changes.py – einschließlich der Login-Nicht-Anforderung, dokumentiert, damit der nächste Leser sie nicht „repariert".
Vier Wege, eine Credential aufhören zu lassen zu funktionieren. AFTERLIFE implementiert A + C, und die Reihenfolge ist wichtig.
| Mechanismus | bringt | kostet | |
|---|---|---|---|
| A | Revocation-Watermark pro Benutzer | ein Schreibvorgang widerruft alles, einschließlich Credentials, von denen der Server vergessen hat, dass er sie ausgestellt hat; O(1) Speicher und Prüfung | serverseitiger Zustand auf dem Lesepfad; Timestamp-Semantik muss exakt stimmen; sagt nichts über Credentials aus, die nach dem Watermark ausgestellt wurden |
| B | kurzlebiger Access + widerrufbarer Refresh | begrenzt Access-Token-Schaden ohne Lesepfad-Zustand | ein gestohlenes Access-Token ist gültig, bis es abläuft; die Refresh-Seite braucht trotzdem Zustand – dies ist das Muster, in dem die CVE lebt |
| C | explizite Denylist | präzise; für einen Incident Responder lesbar; erzeugt die Telemetrie, die die Eindämmung beweist | Zustand wächst und braucht Bereinigung; widerruft nur, was man aufzuzählen gedacht hat – was die Abfrage ist, die der Bug falsch gemacht hat |
| D | vollständig zustandsloses JWT | überhaupt kein Lesepfad-Zustand | es gibt keine Widerrufung. TTL und Key-Rotation sind die einzigen Hebel |
Zu D: JWTs sind nicht inhärent unsicher. Die Spannung ist enger und es lohnt sich, sie präzise zu benennen – zustandslose Verifikation und sofortige serverseitige Widerrufung schließen sich gegenseitig aus. Man kann nicht entscheiden „diese Credential ist nicht mehr gültig", ohne etwas zu konsultieren, das das weiß, und es zu konsultieren ist das, was das System zustandsbehaftet macht. Der Fehler ist, JWTs wegen ihrer Zustandslosigkeit einzuführen und dann trotzdem Widerrufung zu brauchen, was jedes Produkt beim ersten gestohlenen Laptop tut. Was man am Ende hat, ist ein signierter Zeiger in serverseitigen Zustand – was app/tokens.py bewusst implementiert, weil es billiger ist, dort absichtlich anzukommen als mitten in einem Incident dort anzukommen.
CVE-2026-22706 bewertet 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), und die Bewertung ist vertretbar: PR:H, weil der Angreifer bereits eine gültige Refresh-Credential besitzen muss, AC:H, weil das Erlangen einer solchen eine vorherige Kompromittierung erfordert, und VC:N/VI:L, weil der Fehler keinen Zugriff gewährt, den der Angreifer nicht bereits hatte. CVSS misst die marginale Auswirkung der Schwachstelle, und die marginale Auswirkung ist die Dauer des Zugriffs, nicht sein Umfang. Mit diesen Eingaben ergibt sich 2.1 korrekt aus der Formel.
Was CVSS nicht modelliert, ist, dass die versagende Kontrolle die Eindämmungsmaßnahme ist – die nützliche Schlussfolgerung betrifft also das Triage-Routing, nicht die Zahl. Ein Low in einem Eindämmungspfad verdient Aufmerksamkeit, die ein Low in einem Feature-Pfad nicht verdient, weil die Kosten während eines Incidents anfallen. Vollständige Diskussion: docs/tradeoffs.md.
290 passed
| Datei | Was sie festhält |
|---|---|
| [`test_fixed_mode.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_fixed_mode.py) | **die Regressionstest-Suite, die niemals rot werden darf** — keine vor einem Widerrufsereignis ausgestellte Berechtigung wird danach akzeptiert, einschließlich einer, die von einer veralteten Lineage abstammt |
| [`test_vulnerable_mode.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_vulnerable_mode.py) | die Schwachstelle **existiert**, ist deterministisch, hält 30 Tage an und wird durch die Bedingung verursacht — wobei die `device_id`-Kontrolle beweist, dass der Widerrufspfad optional und nicht tot ist |
| [`test_lineage.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_lineage.py) | eine Refresh-Kette behält eine Lineage und eine Wurzel; nur der Login erzeugt eine Lineage |
| [`test_detector_afterlife001.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_detector_afterlife001.py) | 7 Erkennungsfälle, 9 Falsch-Positiv-Fälle, 2 blinde Flecken, alle 24 Ereignisreihenfolgen, beschränkter Zustand, fehlerhafte Eingaben |
| [`test_detector_rulepack.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_detector_rulepack.py) | AFTERLIFE-002 und AFTERLIFE-003 — jedes Urteil, das Kulanzfenster, Rotation-ist-nicht-Tod, Ausschluss bei Ablauf und die Reihenfolge der Engine |
| [`test_naive_detector.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_naive_detector.py) | NAIVE-001 schlägt weiterhin **auf die spezifische Weise fehl, die dieses README behauptet** — einschließlich der Tatsache, dass es die benötigten Daten hatte |
| [`test_audit_and_console.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_audit_and_console.py) | der ruhende Überlebende, den keine Regel sehen kann; beide Audit-Quellen stimmen überein; die Konsole berechnet kein eigenes Urteil; die Offline-Vorschau ruft nichts ab; jede Zahl in diesem README ist im gültigen Bereich, stylesheet-frei und byte-stabil |
| [`test_scenarios.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_scenarios.py) | die Matrixzeilen, die ein Argument tragen |
| [`test_privilege_changes.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_privilege_changes.py) | MFA, Rollenwechsel und Kontowiederherstellung, beide Modi |
| [`test_telemetry.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_telemetry.py) | der Redaktionsvertrag und eine Suche im Log nach den tatsächlich ausgegebenen Bearer-Strings |
| [`test_app.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_app.py) | gefälschte Signaturen, falsche Berechtigungstypen, Ablauf aus dem Serverzustand |
| [`test_infrastructure.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_infrastructure.py) | Konfiguration, Uhr, Tailer-Teilzeilen, beide CLIs, jeder Demo-Befehl |
Der stärkste Test in der Suite ist
`test_no_bearer_string_or_password_ever_reaches_the_log`: Er führt den gesamten Angriff aus
und durchsucht dann die Logdatei nach den tatsächlichen Berechtigungen, die die Anwendung ausgegeben
hat, plus beide Passwörter, plus den Signaturschlüssel. Nicht nach Feldnamen — nach den
Werten.
---
## Blinde Flecken
Offen dargelegt, denn sie zu verbergen würde das Lab unehrlich machen. Jeder hat einen Test.
> **Wenn die Anwendung keine Ereignisse zu Berechtigungsänderungen ausgibt, kann das Paket
> Aktivitäten nach dem Reset nicht zuverlässig korrelieren.** Es gibt keinen Anker, also gibt es nichts,
> wonach die Aktivität „danach" sein könnte — und es schaltet zuerst AFTERLIFE-003 stumm, was die
> Regel ist, die Ihnen gesagt hätte, dass die Kontrolle defekt war.
>
> **Wenn die Anwendung Ausstellungs-/Lineage-Metadaten nicht aufbewahrt, kann der Detektor
> nicht feststellen, ob ein frisches Zugriffstoken von einer älteren
> Berechtigung abstammt.** Eine Berechtigung, die vor drei Sekunden ausgestellt wurde, ist nicht von einer
> Berechtigung zu unterscheiden, die vor drei Sekunden von einer einen Monat alten Kette ausgestellt wurde.
Dies sind **keine optionalen Logging-Einstellungen. Sie sind Anforderungen an die
Sicherheitserkennung.** `auth.token.issued` wegzulassen, um das Log-Volumen zu reduzieren, macht
Logging nicht billiger; es schaltet eine Erkennung ab.
Nicht auflösbare Aktivität wird **gezählt**, nicht stillschweigend verworfen —
`stats()["activity_with_unresolved_lineage"]` ist ungleich null, wann immer der Detektor
zu Berechtigungen befragt wurde, die die Telemetrie nie beschrieben hat. Eine Regel, die still ist,
weil nichts falsch ist, und eine Regel, die still ist, weil sie blind ist, sehen von außen
identisch aus, und diese Zahl ist der Unterschied.
Ebenfalls wahr und ebenfalls dokumentiert:
* **Der Detektor kann eine Anfrage nicht ablehnen.** Er verfolgt ein Log. Er sagt Ihnen,
dass die Eindämmung fehlgeschlagen ist; er dämmt nicht ein.
* **Das Audit ist ein Scan, keine Erkennung.** Es schließt die Lücke des ruhenden Überlebenden,
aber es läuft, wenn jemand es laufen lässt. Es kann nicht alarmieren.
* **Der Fix hilft nicht bei Berechtigungen, die *nach* dem Reset gestohlen wurden.** Eindämmung
ist ein Zeitpunkt, keine Eigenschaft.
* **AFTERLIFE-003 verifiziert beobachtete Eindämmung, nicht architektonische Vollständigkeit.**
Ein gerätebezogener Widerruf, der zufällig alles abdeckt, erhält den LOW-Befund,
nicht den HIGH-Befund. Der Bug ist noch da; er hat bei diesem Konto nicht zugeschlagen. Ein Test
kann fragen „Was ist mit zwei Geräten?"; ein Log kann nur berichten, was tatsächlich passiert ist.
* **Andere Persistenz überlebt eine Passwortänderung vollständig** — OAuth-Grants, API-
Schlüssel, Mail-Weiterleitungsregeln, Wiederherstellungskontakte.
Vollständige Diskussion: **[docs/limitations.md](https://github.com/het-p301204/afterlife/blob/main/docs/limitations.md)**.
---
## Das Lab ausführen```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt -r requirements-dev.txt
pytest -q
Dann, in dieser Reihenfolge:```bash python scripts/lab.py vulnerable
| `-s` | `--server` | Server-Modus aktivieren (Standard) |
| `-c` | `--client` | Client-Modus aktivieren |
| `-l` | `--listen` | Auf eingehende Verbindungen warten |
| `-p` | `--port` | Portnummer (Standard: 4444) |
| `-t` | `--target` | Ziel-IP-Adresse |
| `-e` | `--execute` | Befehl ausführen |
| `-u` | `--upload` | Datei hochladen |
| `-d` | `--download` | Datei herunterladen |
| `-h` | `--help` | Hilfe anzeigen |
| `-v` | `--version` | Version anzeigen |
### Beispiele
```bash
# Server-Modus starten
./tool -s -l -p 4444
# Client-Modus starten
./tool -c -t 192.168.1.100 -p 4444
# Befehl ausführen
./tool -e "whoami"
# Datei hochladen
./tool -u /path/to/file
# Datei herunterladen
./tool -d /remote/path/file
Die Konfigurationsdatei befindet sich unter /etc/tool/config.conf. Die folgenden Parameter können angepasst werden:
| Parameter | Beschreibung | Standard |
|---|---|---|
timeout | Verbindungs-Timeout in Sekunden | 30 |
retry | Anzahl der Wiederholungsversuche | 3 |
verbose | Ausführliche Ausgabe aktivieren | false |
log_level | Log-Level (debug, info, warn, error) | info |
Problem: Verbindung wird abgelehnt
Lösung: Stellen Sie sicher, dass die Firewall den angegebenen Port nicht blockiert und der Server läuft.
Problem: Befehl wird nicht gefunden
Lösung: Überprüfen Sie, ob der Befehl im PATH enthalten ist oder verwenden Sie den vollständigen Pfad.
Problem: Berechtigung verweigert
Lösung: Führen Sie das Tool mit sudo aus oder passen Sie die Dateiberechtigungen an.```bash
python scripts/lab.py detect
Ihre Anfrage enthält keinen zu übersetzenden Text – der INPUT-Block ist leer. Bitte senden Sie den Inhalt von Chunk 45, dann übersetze ich ihn ins Deutsche.```bash
python scripts/lab.py fixed
| --no-color | Deaktiviert farbige Ausgabe |
| --verbose | Aktiviert ausführliche Ausgabe |
| --quiet | Unterdrückt alle Ausgaben außer Fehlern |
| --config | Gibt den Pfad zur Konfigurationsdatei an |
| --output | Gibt das Ausgabeformat an (json, yaml, table) |
| --timeout | Legt das Anforderungs-Timeout in Sekunden fest |
| --retry | Anzahl der Wiederholungsversuche bei fehlgeschlagenen Anforderungen |
| --proxy | Gibt den zu verwendenden Proxy an |
| --header | Fügt einen benutzerdefinierten Header zu den Anforderungen hinzu |
| --cookie | Fügt ein Cookie zu den Anforderungen hinzu |
| --user-agent | Legt den User-Agent-Header fest |
| --follow-redirects | Folgt HTTP-Weiterleitungen |
| --insecure | Deaktiviert die TLS-Zertifikatsüberprüfung |
| --rate-limit | Begrenzt die Anzahl der Anforderungen pro Sekunde |
| --threads | Anzahl der zu verwendenden Threads |
| --wordlist | Gibt die zu verwendende Wortliste an |
| --extensions | Zu prüfende Dateierweiterungen |
| --status-codes | Zu prüfende HTTP-Statuscodes |
| --exclude-status | Auszuschließende HTTP-Statuscodes |
| --recursive | Aktiviert rekursives Scannen |
| --depth | Maximale Rekursionstiefe |
| --random-agent | Verwendet bei jeder Anforderung einen zufälligen User-Agent |
| --delay | Verzögerung zwischen den Anforderungen in Sekunden |
| --max-time | Maximale Zeit für den Scan in Sekunden |
| --silent | Unterdrückt die Banner- und Statusausgabe |
| --debug | Aktiviert den Debug-Modus |
| --version | Zeigt die Tool-Version an |
| --help | Zeigt die Hilfemeldung an |```bash
python scripts/lab.py audit
Oder die ganze Geschichte auf einmal, mit der Lektion am Ende:```bash
python scripts/lab.py all
Und das Bild:```bash python scripts/lab.py console
<details>
<summary><b>Alles andere</b> — die Detektor-CLI, die Matrix, der Bericht, die Abbildungen, die Server</summary>
<br>
Spielen Sie die festgeschriebenen Beweise durch das Paket erneut ab. Kein Anwendungslauf nötig — der Detektor ist strikt nachgelagert, und dies ist der Beweis:```bash
python -m detector --once --timeline --events evidence/vulnerable-persistence.jsonl
Eine Regel nach der anderen, oder die naive, oder der Status-Scan:```bash python -m detector --once --rule AFTERLIFE-003 --events evidence/vulnerable-persistence.jsonl
| `--no-color` | Deaktiviert farbige Ausgabe |
| `--debug` | Aktiviert Debug-Protokollierung |
| `--verbose` | Aktiviert ausführliche Protokollierung |
| `--silent` | Unterdrückt alle Ausgaben außer Fehlern |
| `--json` | Gibt Ergebnisse im JSON-Format aus |
| `--csv` | Gibt Ergebnisse im CSV-Format aus |
| `--output <file>` | Schreibt Ergebnisse in die angegebene Datei |
| `--config <file>` | Verwendet die angegebene Konfigurationsdatei |
| `--timeout <seconds>` | Legt das Anforderungs-Timeout fest |
| `--retry <count>` | Legt die Anzahl der Wiederholungsversuche fest |
| `--proxy <url>` | Verwendet den angegebenen Proxy |
| `--user-agent <string>` | Legt den User-Agent-Header fest |
| `--header <header>` | Fügt einen benutzerdefinierten Header hinzu |
| `--cookie <cookie>` | Fügt ein benutzerdefiniertes Cookie hinzu |
| `--threads <count>` | Legt die Anzahl der Threads fest |
| `--rate-limit <count>` | Legt das Ratenlimit fest |
| `--follow-redirects` | Folgt HTTP-Weiterleitungen |
| `--insecure` | Deaktiviert die TLS-Zertifikatsüberprüfung |
| `--version` | Zeigt Versionsinformationen an |
| `--help` | Zeigt Hilfeinformationen an |```bash
python -m detector --once --naive --events evidence/vulnerable-persistence.jsonl
| -s | --server | Server URL (default: http://localhost:8080) |
| -t | --token | Authentication token |
| -o | --output | Output file path |
| -f | --format | Output format: json, yaml, table |
| -v | --verbose | Enable verbose logging |
| -q | --quiet | Suppress non-error output |
| -h | --help | Show help message |
| -V | --version | Show version information |
# Basic scan
scanner scan --target example.com
# Scan with custom output
scanner scan --target example.com --output results.json --format json
# Authenticated scan
scanner scan --target example.com --token "your-api-token"
# Verbose mode
scanner scan --target example.com --verbose
The scanner can be configured using a configuration file or environment variables.
Create a config.yaml file in the current directory:
server:
url: "http://localhost:8080"
timeout: 30
scan:
threads: 10
timeout: 60
user_agent: "Scanner/1.0"
output:
format: "json"
verbose: false
| Variable | Description | Default |
|---|---|---|
SCANNER_SERVER_URL | Server URL | http://localhost:8080 |
SCANNER_TOKEN | Authentication token | - |
SCANNER_THREADS | Number of threads | 10 |
SCANNER_TIMEOUT | Request timeout (seconds) | 30 |
SCANNER_LOG_LEVEL | Log level | info |
All API requests require an authentication token in the Authorization header:
Authorization: Bearer <token>
POST /api/v1/scanStart a new scan.
Request Body:
{
"target": "example.com",
"options": {
"threads": 10,
"timeout": 60
}
}
Response:
{
"scan_id": "abc123",
"status": "running",
"created_at": "2024-01-01T00:00:00Z"
}
GET /api/v1/scan/{scan_id}Get scan status and results.
Response:
{
"scan_id": "abc123",
"status": "completed",
"results": {
"vulnerabilities": [],
"summary": {
"total": 0,
"critical": 0,
"high": 0,
"medium": 0,
"low": 0
}
}
}
DELETE /api/v1/scan/{scan_id}Cancel or delete a scan.
Response:
{
"scan_id": "abc123",
"status": "cancelled"
}
Connection refused:
Error: connection refused to http://localhost:8080
Ensure the server is running and accessible.
Authentication failed:
Error: authentication failed: invalid token
Verify your token is correct and not expired.
Timeout errors:
Error: request timeout after 30s
Increase the timeout value or check network connectivity.
Enable debug mode for detailed logging:
scanner scan --target example.com --verbose --log-level debug
We welcome contributions! Please see CONTRIBUTING.md for details.
# Clone the repository
git clone https://github.com/example/scanner.git
cd scanner
# Install dependencies
pip install -r requirements.txt
# Run tests
pytest tests/
# Run linter
flake8 scanner/
This project is licensed under the MIT License - see the LICENSE file for details.
Die CLI beendet mit `1`, wenn sie etwas findet, sodass sie als CI-Prüfung funktioniert, ohne ihre Ausgabe zu parsen.
Neun Szenarien gegen beide Implementierungen, mit selbstprüfenden Invarianten:```bash
python scripts/scenarios.py
Der Vorfallbericht, den ein Responder erhalten würde, und die Abbildungen in dieser README:```bash python scripts/report.py
| `-s` | `--server` | Server URL (default: `http://localhost:8080`) |
| `-t` | `--token` | Authentication token |
| `-o` | `--output` | Output file path |
| `-f` | `--format` | Output format: `json`, `yaml`, `csv` |
| `-v` | `--verbose` | Enable verbose logging |
| `-q` | `--quiet` | Suppress non-error output |
| `-h` | `--help` | Show help message |
| `-V` | `--version` | Show version information |
### Examples
```bash
# Basic scan
scanner scan --target example.com
# Scan with custom output format
scanner scan --target example.com --format json --output results.json
# Authenticated scan
scanner scan --target example.com --token YOUR_TOKEN
# Verbose mode
scanner scan --target example.com --verbose
The tool can be configured using a configuration file located at ~/.scanner/config.yaml:
server:
url: "http://localhost:8080"
timeout: 30
scan:
threads: 10
timeout: 60
user_agent: "Scanner/1.0"
output:
format: "json"
directory: "./results"
| Code | Description |
|---|---|
| 0 | Success |
| 1 | General error |
| 2 | Invalid arguments |
| 3 | Network error |
| 4 | Authentication failure |
| 5 | Scan timeout |
Connection refused
If you encounter a "connection refused" error, ensure the server is running:
scanner server start
Authentication failed
Verify your token is valid and has not expired:
scanner auth verify --token YOUR_TOKEN
Scan timeout
Increase the timeout value in the configuration file or use the --timeout flag:
scanner scan --target example.com --timeout 120
Log files are stored in ~/.scanner/logs/. To view recent logs:
tail -f ~/.scanner/logs/scanner.log
We welcome contributions! Please follow these steps:
git checkout -b feature/amazing-feature)git commit -m 'Add amazing feature')git push origin feature/amazing-feature)Please ensure your code follows the project's coding standards and includes appropriate tests.
This project is licensed under the MIT License - see the LICENSE file for details.
Disclaimer: This tool is intended for authorized security testing only. Users are responsible for complying with all applicable laws and regulations.```bash python scripts/figures.py
Die Live-Konsole und die Lab-API von Hand — beide nur über Loopback:```bash
python -m console
Ihre Anfrage konnte nicht verarbeitet werden, da kein zu übersetzender Inhalt bereitgestellt wurde. Bitte senden Sie den Markdown-Text, den Sie übersetzen möchten.```bash python -m app --mode vulnerable --port 9101
Regeneriere die committete Telemetrie (fixierte Uhr, byte-identisch zwischen Läufen):```bash
python scripts/lab.py evidence
Task-Runner, gleiche Ziele in beiden Fällen:```bash make demo
Ihre Anfrage konnte nicht verarbeitet werden, da kein zu übersetzender Inhalt bereitgestellt wurde. Bitte senden Sie den Markdown-Text, den Sie übersetzen möchten.```powershell
./make.ps1 demo
alice / Password123! → changed during the demo to Correct-Horse-Battery-9!
Fake, lokal und die einzigen Zugangsdaten in diesem Repository.
---
## Sicherheitshinweise
**Dieses Repository enthält absichtlich verwundbaren Code.**
`app/auth.py:_revoke_for_security_change` schlägt absichtlich fehl beim Widerrufen, wenn
`AFTERLIFE_MODE=vulnerable`.
Der verwundbare Pfad ist **nicht der Standard**. `AFTERLIFE_MODE` ist standardmäßig auf `fixed`
gesetzt, und `test_the_default_mode_is_the_safe_one` fixiert dies — ein Labor, dessen fehlerhaftes
Verhalten das ist, was man bekommt, wenn man vergisst, irgendetwas zu konfigurieren, wird
irgendwann in etwas Reales kopiert.
In keinem der beiden Modi sicher, um es einem Netzwerk auszusetzen: Bearer-Zugangsdaten werden in
Antwortkörpern zurückgegeben, `GET /lab/credentials` gibt die gesamte Sicht des Servers auf den
Zugangsdatenzustand eines Benutzers aus, `GET /lab/audit` ist konstruktionsbedingt nicht
authentifiziert, `POST /security-change` gewährt sich selbst `admin`, und es gibt nirgends TLS,
Ratenbegrenzung, CSRF-Schutz oder Kontosperrung. Alles bindet an `127.0.0.1`, und sowohl
`python -m app` als auch `python -m console` lehnen jede andere Adresse ab.
In diesem Repository erscheint keine echte Zugangsdaten, kein Schlüssel und kein Geheimnis. Der
Signaturschlüssel ist die wörtliche Zeichenkette
`afterlife-lab-signing-key-not-a-secret-do-not-reuse`; die Zugangsdatenbank ist standardmäßig auf
`:memory:` gesetzt; Telemetrie ersetzt Werte, deren Schlüsselnamen wie Zugangsdaten aussehen, und
verweist auf Zugangsdaten per ID und Fingerabdruck.
Details und der Meldevorgang: **[SECURITY.md](https://github.com/het-p301204/afterlife/blob/main/SECURITY.md)**.
---
## Dokumentation
| | |
|---|---|
| [docs/detection.md](https://github.com/het-p301204/afterlife/blob/main/docs/detection.md) | das Regelpaket: alle drei Regelkarten, das Zustands-Audit, erforderliche Telemetrie, Fehlalarme, Begründung der Schweregrade, Response-Runbooks |
| [docs/tradeoffs.md](https://github.com/het-p301204/afterlife/blob/main/docs/tradeoffs.md) | die vier Widerrufsarchitekturen, die JWT-Spannung und die CVSS-Diskussion |
| [docs/limitations.md](https://github.com/het-p301204/afterlife/blob/main/docs/limitations.md) | jeder blinde Fleck, was der Fix nicht behebt, was das Audit schließt und die eigenen Kompromisse des Labors |
| [docs/console-design.md](https://github.com/het-p301204/afterlife/blob/main/docs/console-design.md) | warum die Konsole ein Sterberegister ist, die Entscheidungen zu Palette und Typografie und was gestrichen wurde |
| [docs/console-preview.html](https://github.com/het-p301204/afterlife/blob/main/docs/console-preview.html) | die Konsole, gebacken zu einer einzigen in sich geschlossenen Datei |
| [docs/figures/](https://github.com/het-p301204/afterlife/blob/main/docs/figures) | die Abbildungen in dieser README, generiert aus der Payload |
| [report/AFTERLIFE-report.md](https://github.com/het-p301204/afterlife/blob/main/report/AFTERLIFE-report.md) | ein generierter Vorfallbericht für den verwundbaren Lauf |
| [SECURITY.md](https://github.com/het-p301204/afterlife/blob/main/SECURITY.md) | die Nur-lokal-Grenze, die Labor-Zugangsdaten, der Redaktionsvertrag |
| [evidence/](https://github.com/het-p301204/afterlife/blob/main/evidence) | bereinigte Beispiel-Telemetrie, die daraus erzeugten Alarme und das Zustands-Audit |
---
## Forschungsreferenzen
Verifiziert gegen Primärquellen am 2026-09-11. **VERIFIED** bedeutet, dass die
eigene Advisory des Anbieters gelesen wurde; **REPORTED** bedeutet, dass das Detail aus einer
Schwachstellendatenbank statt vom Anbieter stammt.
**Standards und Klassifikationen**
* [OWASP Top 10:2025 — A07 Authentication Failures](https://top10.owasp.org/2025/A07_2025-Authentication_Failures) — beschreibt genau dieses Versagen: Anwendungen, die Benutzersitzungen oder Authentifizierungstoken nicht „korrekt invalidieren". Die CWE-Zuordnung umfasst alle drei unten.
* [RFC 9700](https://datatracker.ietf.org/doc/rfc9700/) — *Best Current Practice for OAuth 2.0 Security* (BCP 240, Januar 2025). Zwei Dinge, auf denen dieses Labor ruht: die Wiederverwendung eines rotierten Refresh-Tokens signalisiert Diebstahl (AFTERLIFE-002), und **der Widerruf muss die gesamte Token-Familie invalidieren, nicht nur das aktuelle Token** — was die These von AFTERLIFE-001 als kodifizierte IETF-Praxis ist.
* [OWASP Session Management Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Session_Management_Cheat_Sheet.html) — Sitzungsinvalidierung nach Zugangsdaten- und Sicherheitsänderungen
* [CWE-613: Insufficient Session Expiration](https://cwe.mitre.org/data/definitions/613.html) — *„permits an attacker to reuse old session credentials or session IDs for authorization"*. Die Klasse, unter der alle drei untenstehenden CVEs geführt werden.
* [CWE-287: Improper Authentication](https://cwe.mitre.org/data/definitions/287.html)
* [CWE-384: Session Fixation](https://cwe.mitre.org/data/definitions/384.html) — angrenzend: ein Sitzungsbezeichner, der eine Änderung des Authentifizierungszustands überlebt
**Das Muster, das dieses Labor reproduziert**
* **VERIFIED** — [GHSA-hvp3-26wx-g2w4](https://github.com/strapi/strapi/security/advisories/GHSA-hvp3-26wx-g2w4) / [CVE-2026-22706](https://github.com/advisories/GHSA-hvp3-26wx-g2w4), *Strapi: Password Reset Does Not Revoke Existing Refresh Sessions*. `@strapi/admin` und `@strapi/plugin-users-permissions` ≤ 5.33.2; behoben 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`. Veröffentlicht am 2026-05-13.
Die Anbieter-Advisory gibt an, dass der Schritt zur Invalidierung des Refresh-Tokens von einer
vom Aufrufer gelieferten `deviceId` abhängig war, und dass der Patch alle Refresh-Tokens
bei jeder Passwortänderung und jedem Reset invalidiert, unabhängig davon, ob eine `deviceId`
geliefert wird. Diese Bedingung ist das, was `_revoke_for_security_change` modelliert, und dieser
Patch ist das, was der feste Modus implementiert.
Anbieterkontext: [Strapi security disclosure, May 2026](https://strapi.io/blog/security-disclosure-of-vulnerabilities-cve-2025-64526-cve-2026-22599-cve-2026-22706-cve-2026-22707-and-cve-2026-27886).
**Dieselbe Klasse, andere Mechanismen**
* **REPORTED** — [CVE-2026-1163](https://nvd.nist.gov/vuln/detail/CVE-2026-1163), parisneo/lollms. Überhaupt keine Sitzungsinvalidierung beim Passwort-Reset, mit einer standardmäßigen Sitzungslebensdauer von 31 Tagen. Die einfache Version dieses Bugs — und die, die der naive Detektor tatsächlich erfassen würde.
* **REPORTED** — [CVE-2026-40934](https://nvd.nist.gov/vuln/detail/CVE-2026-40934), Jupyter Server ≤ 2.17.0 (behoben 2.18.0). Das Cookie-Signaturgeheimnis wird in einer statischen Datei persistiert und bei Passwortänderung nie rotiert, sodass Cookies, die vor einem Reset ausgestellt wurden, über diesen hinweg kryptografisch gültig bleiben. Ein dritter Weg zum selben Ergebnis: Die *Zugangsdaten* sind hier gültig, weil der *Schlüssel* sich nie geändert hat, was der Fehlermodus von Option D aus [docs/tradeoffs.md](https://github.com/het-p301204/afterlife/blob/main/docs/tradeoffs.md) ist.
Drei Produkte, drei Mechanismen — ein bedingter Parameter, ein fehlender Aufruf, ein
nicht rotierter Schlüssel — ein Ergebnis: Der Reset war erfolgreich und der Angreifer blieb.
---
<div align="center">
**Der Angreifer wird nicht erkannt, weil seine Anfrage bösartig aussieht.**
Jede Anfrage, die er stellte, war wohlgeformt, korrekt signiert und trug
Zugangsdaten, die der Server gerade für ihn ausgestellt hatte.
**Er wird erkannt, weil eine Zugangsdaten-Lineage, die bei einem
Sicherheitsereignis hätte sterben sollen, danach akzeptiert wurde.**
<br>
<sub>MIT · ein lokales Labor · nichts hier ist sicher zum Deployen</sub>
</div>
dormant-survivor | vulnerable | 15 | HIGH | AFTERLIFE-003 | 1 | 1 |
dormant-survivor | fixed | 19 | – | keine | 0 | 0 |
mfa-change | vulnerable | 18 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
mfa-change | fixed | 18 | – | keine | 0 | 0 |
account-recovery | vulnerable | 18 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
account-recovery | fixed | 18 | – | keine | 0 | 0 |
expired-lineage | vulnerable | 17 | LOW | AFTERLIFE-003 | 0 | 0 |
expired-lineage | fixed | 20 | – | keine | 0 | 0 |