
Лаборатория по обнаружению сохранения отзыва: когда сброс пароля проходит успешно, но атакующий не уходит. Воспроизводит баг условного отзыва Strapi CVE-2026-22706, его исправление, набор из трёх правил обнаружения и наивное правило, которое его пропускает.
Когда сброс пароля проходит успешно, но атакующий так и не уходит.
Локальная red/blue-лаборатория для одного класса уязвимостей: учётные данные, которые переживают событие, которое должно было их уничтожить. Она поставляет эксплойт, первопричину, исправление, пакет обнаружения из трёх правил, аудит состояния для того, что правила структурно не могут увидеть, криминалистическую консоль — и правило обнаружения, которое не работает, сохранённое в репозитории, чтобы продемонстрировать его отказ.
Быстрый старт · Находка · Почему наивное обнаружение не работает · Пакет правил · Консоль · Исправление · Матрица · Тесты · Документация
Одна и та же атака. Одни и те же запросы. Одно отличие.
Сгенерировано scripts/figures.py из той же полезной нагрузки, которую
рисует консоль — перегенерируется и сравнивается в CI, поэтому рисунок не может разойтись с кодом.
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}
Ни ошибки. Ни аномалии. Ни одной неудачной аутентификации, которую можно было бы посчитать. Учётные данные доступа атакующего существуют *всего три секунды* и были выпущены сервером по запросу после сброса.
Вот и вся уязвимость:```python
def _revoke_for_security_change(self, user, kind, device_id):
if device_id:
revoke_credentials(user, device_id=device_id) # ← the finding
Прочитайте это так, как это сделал бы рецензент. Логика отзыва прямо здесь. Она вызывает
нужную функцию с нужной областью действия. Окружающий её эндпоинт обновляет
хеш пароля, возвращает 200 и выдаёт новую сессию — всё наблюдаемое
поведение корректной смены пароля присутствует.
А если вызывающая сторона опускает device_id, ничего не отзывается, и эндпоинт всё равно
сообщает об успехе.
Это не гипотетический случай. Это
CVE-2026-22706
в Strapi ≤ 5.33.2, где шаг инвалидации refresh-токена был обусловлен
передаваемым вызывающей стороной deviceId. Оценка 2.1, Low. Обсуждается
ниже.
Оценка низкая, потому что у атакующего уже был доступ — это входное условие, и эта ошибка не даёт ничего нового. Что она даёт — это длительность, и делает она это, ломая единственный контроль, которым жертва может управлять сама.
test_persistence_lasts_as_long_as_the_refresh_credential проходит семь дней
смоделированного времени, чтобы показать это.Ошибка с низкой оценкой в пути локализации стоит дороже, чем ошибка с низкой оценкой в пути функциональности, потому что цена платится во время инцидента, когда никто не читает рекомендации.
Правило, которое вы пишете первым:```text IF credential.issued_at < credential_change.timestamp: ALERT
Это не глупое правило. Оно дешёвое, требует одного поля, читается как определение
проблемы и **верно для простого случая** — злоумышленник, использующий
украденный *access*-токен после сброса, будет пойман.
`test_the_naive_rule_catches_the_simple_case` подтверждает, что оно работает.
Оба правила задают один и тот же вопрос об одном и том же учётном данных. Они читают разные
поля, чтобы ответить на него, и эти два поля противоречат друг другу:

**Три секунды после или за час до — одни и те же учётные данные, в один и тот же
момент.** Наивное правило спрашивает поле, которое контролирует злоумышленник, и один
`POST /refresh` его сбрасывает.
Запустите его по журналу запросов, где этот запрос на самом деле и пишется,
потому что журналы запросов — это то, что у вас есть:```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.
Было бы легко на этом остановиться, но это было бы нечестно, поэтому в лаборатории также применяется самая справедливая версия наивного правила — расширенная для отслеживания также эндпоинта обновления:```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.