
Labo de détection de persistance de révocation : quand la réinitialisation du mot de passe réussit mais que l'attaquant ne part jamais. Reproduit la faille de révocation conditionnelle Strapi CVE-2026-22706, son correctif, un pack de détection à trois règles, et la règle naïve qui la manque.
Quand la réinitialisation du mot de passe réussit mais que l'attaquant ne part jamais.
Un laboratoire local red/blue pour une seule classe de vulnérabilité : le credential qui survit à l'événement censé le tuer. Il fournit l'exploit, la cause racine, le correctif, un pack de détection à trois règles, un audit d'état pour ce que les règles ne peuvent structurellement pas voir, une console forensique — et la règle de détection qui ne fonctionne pas, conservée dans le dépôt pour être démontrée en échec.
Démarrage rapide · La découverte · Pourquoi la détection naïve échoue · Le pack de règles · Console · Le correctif · Matrice · Tests · Docs
La même attaque. Les mêmes requêtes. Une seule différence.
Généré par scripts/figures.py à partir du même payload que
celui dessiné par la console — régénéré et comparé en CI, afin qu'une figure ne puisse pas diverger du code.
09:00 alice logs in ┐ refresh credential rt-001 │ the attacker steals rt-001 ┘
10:00 alice changes her password ← the one thing a victim can do alone HTTP 200 · password changed · fresh session issued
10:00:03 attacker: POST /refresh rt-002 → HTTP 200 new access credential at-004, issued 10:00:03
10:01:00 attacker: GET /me at-004 → HTTP 200 {"username": "alice", "authenticated": true}
Aucune erreur. Aucune anomalie. Aucune authentification échouée à comptabiliser. Le justificatif d'accès de l'attaquant a *trois secondes* et a été émis par le serveur, sur demande, après la réinitialisation.
Voici la vulnérabilité dans son intégralité :```python
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
Lisez cela comme le ferait un relecteur. La logique de révocation est juste là. Elle appelle
la bonne fonction avec le bon périmètre. Le point de terminaison autour met à jour
le hachage du mot de passe, renvoie 200, et émet une nouvelle session — chaque comportement observable
d'un changement de mot de passe correct est présent.
Et si l'appelant omet device_id, rien n'est révoqué, et le point de terminaison
signale toujours un succès.
Ce n'est pas hypothétique. C'est
CVE-2026-22706
dans Strapi ≤ 5.33.2, où l'étape d'invalidation du refresh-token était conditionnée
à un deviceId fourni par l'appelant. Noté 2.1, Faible. Discuté
ci-dessous.
Le score est faible parce que l'attaquant avait déjà accès — c'est la condition d'entrée, et ce bug n'accorde rien de nouveau. Ce qu'il accorde, c'est la durée, et il le fait en brisant le seul contrôle que la victime peut actionner elle-même.
test_persistence_lasts_as_long_as_the_refresh_credential parcourt sept jours de
temps simulé pour le montrer.Un bug noté Faible dans un chemin de confinement coûte plus qu'un bug noté Faible dans un chemin de fonctionnalité, parce que le coût est payé pendant un incident, quand personne ne lit les avis.
La règle que vous écrivez en premier :```text IF credential.issued_at < credential_change.timestamp: ALERT
Ce n'est pas une règle stupide. Elle est peu coûteuse, nécessite un seul champ, se lit comme la définition
du problème, et elle est **correcte pour le cas simple** — un attaquant utilisant un
jeton d'*accès* volé après la réinitialisation se fait prendre.
`test_the_naive_rule_catches_the_simple_case` affirme que cela fonctionne.
Les deux règles posent la même question sur le même identifiant. Elles lisent des
champs différents pour y répondre, et les deux champs sont en désaccord :

**Trois secondes après, ou une heure avant — le même identifiant, au même
instant.** La règle naïve interroge un champ contrôlé par l'attaquant, et un
seul `POST /refresh` le réinitialise.
Exécutez-la sur le journal des requêtes, qui est l'endroit où cette requête est réellement écrite,
car les journaux de requêtes sont ce que vous avez :```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.
Il serait facile de s'arrêter là, et malhonnête, donc le lab exécute aussi la version la plus équitable de la règle naïve — élargie pour surveiller également le point de terminaison de rafraîchissement :```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.