
Laboratório de detecção de persistência de revogação: quando a redefinição de senha é bem-sucedida, mas o atacante nunca vai embora. Reproduz o bug de revogação condicional do Strapi CVE-2026-22706, sua correção, um pacote de detecção com três regras e a regra ingênua que não o detecta.
Quando a redefinição de senha é bem-sucedida, mas o atacante nunca vai embora.
Um laboratório local red/blue para uma classe de vulnerabilidade: a credencial que sobrevive ao evento que deveria eliminá-la. Ele entrega o exploit, a causa raiz, a correção, um pacote de detecção com três regras, uma auditoria de estado para o que as regras estruturalmente não conseguem ver, um console forense — e a regra de detecção que não funciona, mantida no repositório para ser demonstrada falhando.
Início rápido · A descoberta · Por que a detecção ingênua falha · O pacote de regras · Console · A correção · Matriz · Testes · Documentação
O mesmo ataque. As mesmas requisições. Uma diferença.
Gerado por scripts/figures.py a partir do mesmo payload que o
console desenha — regenerado e comparado por diff na CI, para que uma figura não possa divergir do código.
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}
Sem erro. Sem anomalia. Sem autenticação falhada para contar. A credencial de acesso do atacante tem *três segundos de vida* e foi emitida pelo servidor, a pedido, após o reset.
Eis a vulnerabilidade completa:```python
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
Leia isso como um revisor leria. A lógica de revogação está bem ali. Ela chama
a função correta com o escopo correto. O endpoint ao redor atualiza o
hash da senha, retorna 200 e emite uma nova sessão — todo comportamento observável
de uma troca de senha correta está presente.
E se o chamador omitir device_id, nada é revogado, e o endpoint ainda
relata sucesso.
Isso não é hipotético. É
CVE-2026-22706
no Strapi ≤ 5.33.2, onde a etapa de invalidação do refresh-token era condicional
a um deviceId fornecido pelo chamador. Pontuado como 2.1, Low. Discutido
abaixo.
A pontuação é baixa porque o atacante já tinha acesso — essa é a condição de entrada, e este bug não concede nada novo. O que ele concede é duração, e o faz quebrando o único controle que a vítima pode operar por conta própria.
test_persistence_lasts_as_long_as_the_refresh_credential percorre sete dias de
tempo simulado para demonstrar isso.Um bug com pontuação Low em um caminho de contenção custa mais do que um bug com pontuação Low em um caminho de funcionalidade, porque o custo é pago durante um incidente, quando ninguém está lendo advisories.
A regra que você escreve primeiro:```text IF credential.issued_at < credential_change.timestamp: ALERT
Não é uma regra estúpida. É barata, precisa de um campo, lê-se como a definição
do problema, e está **correta quanto ao caso simples** — um atacante que use um
token de *acesso* roubado após o reset é apanhado.
`test_the_naive_rule_catches_the_simple_case` afirma que funciona.
Ambas as regras fazem a mesma pergunta sobre a mesma credencial. Leem campos
diferentes para responder, e os dois campos discordam:

**Três segundos depois, ou uma hora antes — a mesma credencial, no mesmo
instante.** A regra ingénua pergunta a um campo que o atacante controla, e um
`POST /refresh` reinicia-o.
Executa-a sobre o registo de pedidos, que é onde esta consulta é realmente escrita,
porque os registos de pedidos são o que tens:```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.
Seria fácil parar por aí, e desonesto, então o laboratório também executa a versão mais justa da regra ingênua — ampliada para observar também o endpoint de refresh:```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
Essa é a diferença que importa às 3 da manhã. A regra ingênua captura o único
instante em que a cadeia cruza a fronteira, e então perde o rastro pelos 29 dias
restantes — e a credencial que ela nomeia *já foi rotacionada e revogada
pelo servidor*. Revogá-la não resolve nada. `lin-001` é o objeto que você
precisa eliminar.