
Лаборатория по обнаружению сохранения отзыва: когда сброс пароля проходит успешно, но атакующий не уходит. Воспроизводит баг условного отзыва 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.
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
Вот в чём разница, которая имеет значение в три часа ночи. Наивное правило ловит единственное мгновение, когда цепочка пересекает границу, а затем теряет след на оставшиеся 29 дней — и учётные данные, которые оно называет, *уже были ротированы и отозваны сервером*. Их отзыв ничего не даёт. `lin-001` — это объект, который нужно уничтожить.
**И наивное правило не было обделено телеметрией.** Оно использует тот же поток событий, тот же индекс происхождения, тот же допуск, ту же дедупликацию и то же ограниченное состояние. Оно переопределяет один метод:```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 находится в индексе, который уже используется.
test_the_naive_rule_had_the_data_it_needed доказывает это. Ошибка в
сравнении, а не в логировании.
Три правила по одному журналу. Они отвечают на разные вопросы и срабатывают в том порядке, в котором инцидент действительно разворачивается.
| правило | серьёзность | отвечает на вопрос | срабатывает | |
|---|---|---|---|---|
| AFTERLIFE-002 | Повторное использование обновлённых учётных данных | CRITICAL / MEDIUM | были ли они украдены? | при повторном использовании |
| AFTERLIFE-003 | Неполный отзыв при изменении в системе безопасности | HIGH / LOW | сработала ли локализация? | при изменении — атакующий не нужен |
| AFTERLIFE-001 | Использование цепочки учётных данных после отзыва | HIGH | использовалась ли устаревшая цепочка? | при первом принятии |
| 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
**Этот порядок — самое полезное в этом репозитории.** AFTERLIFE-003
срабатывает в момент, когда изменение вступает в силу, за три секунды до того, как атакующий
что-либо коснётся, потому что необходимые ему доказательства уже полны: журнал говорит, какие
линии были активны на входе, и не говорит, что они были отозваны.
Ему не нужна ни жертва, ни эксплуатация. Он сообщит о дефекте при первом
сбросе пароля, который выполнит любой пользователь, — что делает его тем самым, который вы запускаете в staging,
где нет атакующего, которого нужно ждать. AFTERLIFE-001 сообщает вам, что вторжение
продолжается; AFTERLIFE-003 сообщает вам, что ваш контроль сдерживания сломан.

`application recorded watermark: no` — это поле, которое указывает специалисту по реагированию на инциденты на *дефект*, а не на симптом.
### Watermark, который используют правила, — не тот, о котором сообщает приложение
Событие изменения учётных данных несёт поле `revocation_watermark` — значение
`credentials_valid_after`, которое записало приложение. **В уязвимой
реализации оно равно `null`, потому что приложение его никогда не записывало.** Это и есть
баг.
Таким образом, правило, привязанное к этому полю, было бы слепо именно к тому случаю, для перехвата которого оно существует. Правила привязываются к собственному `timestamp` события, который верен независимо от того, выполнило ли приложение свою работу, и сообщают об отсутствующем поле как о доказательстве.
### Свойства```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
Независимость от порядка — это не «в основном работает»:
test_6b_every_permutation_of_the_critical_events_detects прогоняет все 24 порядка
четырёх значимых событий и требует ровно одного оповещения от каждого.
Дедупликация выполняется по ключу (user_id, credential_change_event_id, lineage_id),
с подавлением по event_id перед ней, чтобы воспроизведённый файл не мог завысить
счётчик.
Полные карточки правил, необходимая телеметрия и инструкции по реагированию: docs/detection.md.
Поверхность для чтения, отвечающая на единственный вопрос, вокруг которого построена лаборатория. Не дашборд с количеством оповещений — а реестр смертности. Одна полоса на каждый учётный данные, от выпуска до смерти, сгруппированные по происхождению, из которого они происходят, со сменой пароля, нарисованной как линия, на которой всё до неё должно было закончиться.
Одна намеренная инверсия: тепло означает жив, а после линии тепло — это неправильно. В большинстве интерфейсов безопасности красный означает, что произошла ошибка. Здесь ничего не ошибается — каждый запрос в уязвимом прогоне возвращает 200. Поэтому цвет следует за смертностью: холодный — это учётные данные, которые умерли, когда им было велено, тёплый — те, что ещё дышат, а за гильотиной то, что всё ещё тёплое, и есть вся находка.
(BLACKOUT установил ту же конвенцию, где 200 был красным.)
Тонкие вертикальные штрихи — это генеалогия: дочерние учётные данные, созданные из родительских в этот момент. rt-001 → rt-002 → rt-004 спускается вниз и вправо через всё окно, и в уязвимом режиме продолжает шагать после отсечения — а это и есть картина происхождения, создающего новые учётные данные по другую сторону от собственного события вымирания.```bash
python scripts/lab.py console
записывает **[`docs/console-preview.html`](https://github.com/het-p301204/afterlife/blob/main/docs/console-preview.html)** — автономный файл размером 74 КиБ с обоими запусками, встроенными внутрь. Ни сервера, ни сети, ни шрифтов для загрузки; откройте его прямо из файловой системы. Или запустите живую версию:```bash
python -m console
Перетащите ползунок назад за 10:00:00 и вперёд через него: находки появляются,
когда журнал их заслуживает, AFTERLIFE-003 при изменении и AFTERLIFE-001 три
секунды спустя. Обоснование дизайна, включая то, что было вырезано, находится в
docs/console-design.md.
Консоль по построению доступна только для чтения. Она повторно запускает завершённые сценарии и отрисовывает их; она не может переключить реализацию, перевести часы или отозвать что-либо.
test_the_console_has_no_control_surfaceутверждает, что единственный не-GET маршрут — это/api/rebuild. Поверхность для чтения, которая может изменить то, что она читает, — это то, чему нельзя доверять.
Шесть пакетов, один класс безопасности, никакой инфраструктуры.```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
Приложение работает **внутри процесса** под управлением демо, консоли и тестов. Никаких
портов, никакого dev-сервера, никакого Docker. Часы зафиксированы, поэтому каждый запуск
детерминирован, а закоммиченная телеметрия, консоль, графики и отчёты побайтово
идентичны между запусками — что CI проверяет с помощью `git diff --exit-code`.
### Принудительное применение на стороне сервера
Учётные данные — это **строка**, а не строка символов. JWT, который держит клиент, несёт `cid` —
ключ для поиска — и именно строка решает, жив ли учётный доступ.
Срок действия проверяется по `expires_at` строки, а не по утверждению `exp`, которое
предъявил держатель. Оба временных утверждения передаются в PyJWT с `verify=False`,
что здесь означает *«сервер проверяет это сам»*, и [`app/auth.py`](https://github.com/het-p301204/afterlife/blob/main/app/auth.py)
делает это при каждом запросе. Клиент никогда не получает права голоса в вопросе о том,
действителен ли его собственный учётный доступ.
---
## Хронология атаки```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
Ветвь цепочки обновления в середине — это самое главное. Атакующий не использует старые учётные данные. Он использует старую линию происхождения, и эта линия создаёт что-то новое по требованию.
Сессия — это корень линии происхождения. Всё, что создано под ней, наследует этот корень навсегда.```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` существует три секунды. Его происхождение — час. Оба факта верны,
и только один из них виден в журнале запросов.
Два инварианта скрепляют это, и оба протестированы:
1. **Только аутентификация создаёт происхождение.** `Store.open_session` — единственное
место, где генерируется `lineage_id`, и он делает сессию своим собственным
`root_credential_id`.
2. **Выпуск копирует корень вниз.** `TokenService.mint` читает поля происхождения
из родителя, а не пересчитывает их, поэтому учётные данные не могут получить
более свежее происхождение, чем логин, от которого они происходят.
Токен, предъявленный держателем, намеренно **не** несёт свой корень происхождения. Если бы
нёс, держатель мог бы солгать о нём.
---
## Аудит состояния
AFTERLIFE-001 срабатывает, когда устаревшее происхождение *используется*. Это правильный триггер для
правила обнаружения, и он оставляет дыру: **устаревшее происхождение, к которому никто не прикасался, невидимо для него.** Атакующий, который крадёт учётные данные, наблюдает, как сброс не удаётся,
а затем ждёт, не производит никакой активности для корреляции.
Поэтому лаборатория также задаёт вопрос, который правило не может задать: не *что произошло*, а *что
всё ещё живо*.```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-verify — пропустить проверку TLS-сертификата (не рекомендуется)--timeout — таймаут запроса в секундах (по умолчанию: 10)--retries — количество повторных попыток при неудачных запросах (по умолчанию: 3)--proxy — прокси-сервер для использования при выполнении запросов--headers — дополнительные заголовки для включения в запросы--user-agent — строка User-Agent для использования в запросах--follow-redirects — следовать перенаправлениям HTTP--max-redirects — максимальное количество перенаправлений для отслеживания (по умолчанию: 5)--verbose — включить подробный вывод--quiet — подавить весь вывод, кроме ошибок--output — выходной файл для записи результатов--format — формат вывода (json, yaml, csv, table)--config — файл конфигурации для загрузки настроек--log-level — уровень логирования (debug, info, warn, error)--log-file — файл для записи логов--version — показать информацию о версии и выйти--help — показать справочное сообщение и выйти# Базовое сканирование
python3 main.py --target example.com
# Сканирование с пользовательскими параметрами
python3 main.py --target example.com --threads 20 --timeout 30
# Сканирование с выводом в формате JSON
python3 main.py --target example.com --format json --output results.json
# Сканирование с использованием файла конфигурации
python3 main.py --config config.yaml
# Сканирование с подробным выводом
python3 main.py --target example.com --verbose
# Сканирование с использованием прокси
python3 main.py --target example.com --proxy http://127.0.0.1:8080
# Сканирование с пользовательскими заголовками
python3 main.py --target example.com --headers "Authorization: Bearer token"
# Сканирование с пользовательским User-Agent
python3 main.py --target example.com --user-agent "Mozilla/5.0"
# Сканирование с отслеживанием перенаправлений
python3 main.py --target example.com --follow-redirects --max-redirects 10
# Сканирование с логированием в файл
python3 main.py --target example.com --log-level debug --log-file scan.log
Инструмент поддерживает файл конфигурации в формате YAML. Пример:
target: example.com
threads: 10
timeout: 10
retries: 3
proxy: http://127.0.0.1:8080
headers:
Authorization: Bearer token
user_agent: Mozilla/5.0
follow_redirects: true
max_redirects: 5
verbose: false
quiet: false
output: results.json
format: json
log_level: info
log_file: scan.log
Инструмент поддерживает несколько форматов вывода:
Пример вывода в формате JSON:
{
"target": "example.com",
"scan_time": "2024-01-01T00:00:00Z",
"results": [
{
"url": "https://example.com",
"status_code": 200,
"content_length": 1234,
"content_type": "text/html",
"server": "nginx",
"technologies": ["nginx", "php"],
"vulnerabilities": []
}
]
}
Этот проект лицензирован в соответствии с лицензией MIT — подробности см. в файле LICENSE.
Этот инструмент предназначен только для образовательных целей. Используйте его ответственно и только на системах, на которые у вас есть разрешение. Авторы не несут ответственности за любой ущерб, причинённый использованием этого инструмента.```bash python scripts/lab.py audit
| `--no-color` | Отключить цветной вывод |
| `--debug` | Включить режим отладки |
| `--verbose` | Включить подробный вывод |
| `--silent` | Подавить весь вывод |
| `--json` | Выводить результаты в формате JSON |
| `--csv` | Выводить результаты в формате CSV |
| `--html` | Выводить результаты в формате HTML |
| `--markdown` | Выводить результаты в формате Markdown |
| `--output <file>` | Записать вывод в файл |
| `--config <file>` | Использовать указанный файл конфигурации |
| `--threads <n>` | Количество потоков (по умолчанию: 10) |
| `--timeout <n>` | Тайм-аут в секундах (по умолчанию: 30) |
| `--retries <n>` | Количество повторных попыток (по умолчанию: 3) |
| `--proxy <url>` | Использовать прокси |
| `--user-agent <string>` | Установить User-Agent |
| `--cookie <string>` | Установить Cookie |
| `--header <string>` | Установить пользовательский заголовок |
| `--rate-limit <n>` | Ограничить запросы в секунду |
| `--random-agent` | Использовать случайный User-Agent |
| `--follow-redirects` | Следовать перенаправлениям |
| `--no-verify-ssl` | Отключить проверку SSL |
| `--insecure` | Разрешить небезопасные соединения |
| `--force` | Принудительное выполнение |
| `--quiet` | Тихий режим |
| `--version` | Показать версию |
| `--help` | Показать справку |```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
Два источника, намеренно: аудит телеметрии видит то, что может доказать журнал, аудит состояния сервера видит то, во что верит база данных. Там, где они расходятся, журнал не является достоверной записью состояния учётных данных, и любое обнаружение, построенное на нём, слабее, чем кажется — поэтому команда выводит оба и сообщает, если они различаются.
Аудит состояния сервера нуждается в таблице security_changes, которую пишут оба режима,
потому что запись о том, что изменение произошло, — это отдельная обязанность от
реагирования на него — и уязвимая реализация выполняет ровно одну из двух.
Девять сценариев против обеих реализаций. python scripts/scenarios.py проверяет
свои два инварианта и завершается с ненулевым кодом, если любой из них нарушен.
| сценарий | режим | события | худший | сработавшие правила | устаревшие | спящие |
|---|---|---|---|---|---|---|
legitimate-only | уязвимый | 19 | LOW | AFTERLIFE-003 | 0 | 0 |
legitimate-only | исправленный | 19 | – | нет | 0 | 0 |
stolen-refresh | уязвимый | 22 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
stolen-refresh | исправленный | 23 | – | нет | 0 | 0 |
stolen-refresh-with-device-id | уязвимый | 19 | LOW | AFTERLIFE-003 | 0 | 0 |
stolen-refresh-with-device-id | исправленный | 19 | – | нет | 0 | 0 |
multi-device | уязвимый | 29 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 2 | 0 |
multi-device | исправленный | 29 | – | нет | 0 | 0 |
refresh-reuse | уязвимый | 11 | MEDIUM | AFTERLIFE-002 | 0 | 0 |
refresh-reuse | исправленный | 15 | MEDIUM | AFTERLIFE-002 | 0 | 0 |
Четыре строки несут аргумент:
legitimate-only вообще не содержит злоумышленника. Исправление не производит ничего;
уязвимая реализация производит LOW. Это не ложное срабатывание — это
enumeration_only: отзыв сработал на этот раз, путём перечисления, без
водяного знака, чтобы покрыть учётные данные, которые сервер забыл. Пакет разделяет
две реализации при отсутствии злоумышленника.dormant-survivor — это слепое пятно и его ответ в одной строке:
AFTERLIFE-001 молчит, AFTERLIFE-003 HIGH, аудит dormant: 1.multi-device — это место, где рабочая ветвь уязвимой реализации
всё равно терпит неудачу — отзыв ограничен ноутбуком, два других устройства не тронуты.expired-lineage — это контроль ложных срабатываний: 40-дневная сессия, чьи
учётные данные истекли сами по себе, не является выжившим путём персистентности и не
сообщается как таковой.И центральное утверждение матрицы, проверяемое скриптом и
test_the_fix_never_produces_a_control_failure_finding: исправление не вызывает
находку об отказе контроля ни в одном сценарии. AFTERLIFE-002 допускается — он
сообщает о краже, а не об отказе контроля, и у корректной реализации всё ещё есть
кражи, о которых нужно сообщать.
Правило, которое срабатывает при каждом сбросе пароля, будет заглушено в течение недели, а заглушённое правило хуже отсутствия правила — это правило, о котором все думают, что оно работает.
| случай | результат | почему |
|---|---|---|
| Смена пароля, затем использование сессии-замены | нет оповещения | корень новой линии находится на водяном знаке или после него |
Сброс, затем немедленный просмотр (/me, /profile, /settings) | нет оповещения | одна свежая линия, один свежий корень |
| Телефон, ноутбук и планшет, все корректно ротированы | нет оповещения | каждый вход — это своя линия |
| Устройство, вошедшее после изменения, работающее рядом со злоумышленником | нет оповещения | свежий корень — и устаревшая линия всё равно оповещает, в одиночку |
| Устаревшие учётные данные, которые были отклонены | нет оповещения | result: failure исключается; это доказательство для защиты |
| Расхождение часов до 2с между компонентами | нет оповещения | задокументированный допуск |
| Неудачная смена пароля | нет оповещения | не якорь |
| Активность до изменения | нет оповещения | проверка временного порядка |
Ротированные учётные данные (reason: rotated) | не считается смертью | учётные данные израсходованы, а не линия убита |
| 40-дневная сессия, чьи учётные данные истекли | не выживший | истечение отслеживается по линии |
| Первая смена пароля на совершенно новом аккаунте | нет оповещения | ничего не было активно на входе |
| Ротация refresh-токена из устаревшей линии | ОПОВЕЩЕНИЕ | свежая метка времени, устаревшее происхождение — это и есть находка |
Каждая строка имеет тест в
tests/test_detector_afterlife001.py и
tests/test_detector_rulepack.py.
О 2-секундном допуске. Каждая метка времени в этой лаборатории исходит из одного процесса
и одних часов, поэтому честный допуск равен нулю. 2с — это то, что нужно реалистичному
двуххостовому развёртыванию под NTP, и это на три порядка меньше, чем
разрывы, которые производит эта атака — всё назначение refresh-учётных данных в том, чтобы быть
долгоживущими. Допуск — это окно, в котором действительно устаревшая линия
игнорируется, поэтому он намеренно оставлен маленьким:
test_12b_a_gap_beyond_the_tolerance_does_alert фиксирует, что 2.1с всё ещё срабатывает.
self.store.set_watermark(user.user_id, moment) # the guarantee revoked = self.store.revoke_credentials(user.user_id, ...) # defence in depth
Ни один из них не зависит от `device_id`. Он фиксируется как контекст и не влияет на
радиус поражения.
**Водяной знак — это архитектурная гарантия.** Одна временная метка на пользователя,
`credentials_valid_after`, сравнивается при каждом запросе как с моментом выпуска самого
credential'а, *так и* с корнем его цепочки наследования:```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
Второе сравнение — это то, что требует усилий, чтобы сделать правильно, и то, что наивная реализация упускает — как при принудительном применении, так и при обнаружении.
Явный отзыв — это эшелонированная защита, и доказательство. revoked_at и
revocation_reason — это то, что читает специалист по реагированию на инциденты, а
события auth.token.revoked — это то, что доказывает, что локализация произошла.
test_the_watermark_alone_rejects_a_stale_credential устанавливает watermark без
отзыва чего-либо и требует, чтобы устаревшие учётные данные были отклонены — что
устанавливает, какой из двух является несущим, и какое свойство
сохраняется для учётных данных, о выдаче которых сервер забыл.
Ни один запрос не изменился. Одна функция — да.
Триггер — это не «пароль изменился». Это «что-то изменилось, что делает
ранее выданные учётные данные не заслуживающими доверия». Все четыре маршрута используют
_revoke_for_security_change, поэтому исправление и баг применяются одинаково к:
| изменение | почему учётные данные становятся не заслуживающими доверия |
|---|---|
| смена / сброс пароля | секрет, с которым была установлена сессия, исчез |
| регистрация или изменение MFA | факторы, с которыми была установлена сессия, не являются факторами аккаунта |
| смена роли / повышение привилегий | учётные данные были выпущены при другой авторизации |
| восстановление аккаунта | по построению, аккаунт мог мгновение назад быть в чужих руках |
| вход | не отзывает другие — новая линия происхождения, а не утверждение, что старые не заслуживают доверия |
Протестировано для каждого вида, в обоих режимах, в
tests/test_privilege_changes.py — включая
не-требование для входа, задокументированное, чтобы следующий читатель не «исправил» его.
Четыре способа заставить учётные данные перестать работать. AFTERLIFE реализует A + C, и порядок имеет значение.
| механизм | что даёт | чего стоит | |
|---|---|---|---|
| A | watermark отзыва на пользователя | одна запись отзывает всё, включая учётные данные, о выдаче которых сервер забыл; O(1) хранение и проверка | серверное состояние на пути чтения; семантика временных меток должна быть точно правильной; ничего не говорит об учётных данных, выданных после watermark |
| B | короткоживущий access + отзываемый refresh | ограничивает ущерб от access-токена без состояния на пути чтения | украденный access-токен действителен до истечения срока; сторона refresh всё ещё нуждается в состоянии — это тот паттерн, внутри которого живёт CVE |
| C | явный denylist | точен; понятен специалисту по реагированию на инциденты; производит телеметрию, доказывающую локализацию | состояние растёт и требует очистки; отзывает только то, что вы не забыли перечислить — а это тот запрос, который баг выполнил неправильно |
| D | полностью stateless JWT | вообще нет состояния на пути чтения | отзыва не существует. TTL и ротация ключей — единственные рычаги |
О D: JWT не являются по своей природе небезопасными. Напряжение более узкое и заслуживает точной
формулировки — stateless-проверка и немедленный серверный отзыв
взаимоисключающи. Вы не можете решить «эти учётные данные больше не действительны»
без обращения к чему-то, что это знает, а обращение к этому и делает
систему stateful. Ошибка — принять JWT ради их stateless-ности, а затем
всё равно нуждаться в отзыве, что делает каждый продукт при первой краже ноутбука. То, что вы
получаете в итоге, — это подписанный указатель на серверное состояние — что и
реализует app/tokens.py намеренно, потому что прийти
к этому нарочно дешевле, чем прийти к этому посреди инцидента.
CVE-2026-22706 оценивается в 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), и оценка
защитима: PR:H, потому что атакующий уже должен обладать действительными refresh-
учётными данными, AC:H, потому что получение их требует предварительной компрометации, и
VC:N/VI:L, потому что уязвимость не даёт доступа, которого у атакующего уже не было.
CVSS измеряет маргинальное воздействие уязвимости, а маргинальное
воздействие — это длительность доступа, а не его охват. При таких входных данных 2.1
корректно вытекает из формулы.
Что CVSS не моделирует, так это то, что отказывающий контроль — это действие по локализации — поэтому полезный вывод касается маршрутизации триажа, а не числа. Low на пути локализации заслуживает внимания, которого Low на пути функции не заслуживает, потому что цена платится во время инцидента. Полное обсуждение: docs/tradeoffs.md.
290 passed
| файл | что он фиксирует |
|---|---|
| [`test_fixed_mode.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_fixed_mode.py) | **набор регрессионных тестов, который никогда не должен становиться красным** — ни один учётный данные, выданные до события отзыва, не принимаются после него, включая учётные данные, происходящие от устаревшей цепочки |
| [`test_vulnerable_mode.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_vulnerable_mode.py) | уязвимость **существует**, детерминирована, длится 30 дней и вызвана условным оператором — при этом контроль `device_id` доказывает, что путь отзыва является опциональным, а не мёртвым |
| [`test_lineage.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_lineage.py) | цепочка обновления сохраняет одну линию происхождения и один корень; только вход создаёт линию происхождения |
| [`test_detector_afterlife001.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_detector_afterlife001.py) | 7 случаев обнаружения, 9 случаев ложного срабатывания, 2 слепые зоны, все 24 порядка событий, ограниченное состояние, некорректный ввод |
| [`test_detector_rulepack.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_detector_rulepack.py) | AFTERLIFE-002 и AFTERLIFE-003 — каждый вердикт, окно отсрочки, ротация-не-смерть, исключение истечения срока, и порядок работы движка |
| [`test_naive_detector.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_naive_detector.py) | NAIVE-001 продолжает падать **именно так, как утверждает этот README** — включая то, что у него были нужные данные |
| [`test_audit_and_console.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_audit_and_console.py) | дремлющий выживший, которого не видит ни одно правило; согласие обоих источников аудита; консоль не вычисляет собственный вердикт; офлайн-предпросмотр ничего не загружает; каждая цифра в этом README в допустимых пределах, без стилевых таблиц и побайтово стабильна |
| [`test_scenarios.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_scenarios.py) | строки матрицы, которые несут аргумент |
| [`test_privilege_changes.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_privilege_changes.py) | MFA, смена роли и восстановление учётной записи, оба режима |
| [`test_telemetry.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_telemetry.py) | контракт редактирования и поиск в логе буквальных строк bearer, которые были выданы |
| [`test_app.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_app.py) | подделанные подписи, неверные типы учётных данных, истечение срока из состояния сервера |
| [`test_infrastructure.py`](https://github.com/het-p301204/afterlife/blob/main/tests/test_infrastructure.py) | конфигурация, часы, частичные строки tailer, оба CLI, каждая демонстрационная команда |
Самый сильный тест в наборе —
`test_no_bearer_string_or_password_ever_reaches_the_log`: он запускает всю атаку
и затем ищет в файле лога фактические учётные данные, которые выдало приложение,
плюс оба пароля, плюс ключ подписи. Не имена полей — а значения.
---
## Слепые зоны
Изложены прямо, потому что их сокрытие сделало бы лабораторию нечестной. У каждой есть тест.
> **Если приложение не генерирует события изменения учётных данных, пакет не может
> надёжно сопоставить активность после сброса.** Нет якоря, поэтому нет ничего,
> «после» чего могла бы быть активность — и это первым заглушает AFTERLIFE-003,
> то есть правило, которое сообщило бы вам, что контроль сломан.
>
> **Если приложение не сохраняет метаданные выдачи/линии происхождения, детектор
> не может определить, произошёл ли свежий токен доступа от более старого
> учётного данных.** Учётные данные, созданные три секунды назад, неотличимы от
> учётных данных, созданных три секунды назад месячной цепочкой.
Это **не опциональные предпочтения логирования. Это требования безопасности
обнаружения.** Отказ от `auth.token.issued` для уменьшения объёма логов не делает
логирование дешевле; он отключает обнаружение.
Неразрешимая активность **подсчитывается**, а не молча отбрасывается —
`stats()["activity_with_unresolved_lineage"]` не равно нулю всякий раз, когда
детектору задавали вопросы об учётных данных, которые телеметрия никогда не
описывала. Правило, которое молчит, потому что ничего не случилось, и правило,
которое молчит, потому что оно слепо, выглядят одинаково снаружи, и это число —
разница между ними.
Также верно, и также задокументировано:
* **Детектор не может отклонить запрос.** Он читает лог. Он сообщает вам, что
локализация не удалась; он не локализует.
* **Аудит — это сканирование, а не обнаружение.** Он закрывает пробел с
дремлющими выжившими, но он запускается, когда кто-то его запускает. Он не
может оповещать.
* **Исправление не помогает с учётными данными, украденными *после* сброса.**
Локализация — это момент времени, а не свойство.
* **AFTERLIFE-003 проверяет наблюдаемую локализацию, а не архитектурную
полноту.** Отзыв с областью действия устройства, который случайно покрывает
всё, получает находку LOW, а не HIGH. Баг всё ещё там; он не укусил на том
аккаунте. Тест может спросить «а как насчёт двух устройств?»; лог может
сообщить только о том, что произошло.
* **Другая персистентность полностью переживает смену пароля** — OAuth-гранты,
API-ключи, правила пересылки почты, контакты для восстановления.
Полное обсуждение: **[docs/limitations.md](https://github.com/het-p301204/afterlife/blob/main/docs/limitations.md)**.
---
## Запуск лаборатории```bash
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt -r requirements-dev.txt
pytest -q
Затем, по порядку:```bash python scripts/lab.py vulnerable
| | |
|---|---|
| `-h`, `--help` | Показать справочное сообщение и выйти |
| `-v`, `--version` | Показать версию программы и выйти |
| `-c`, `--config` | Путь к файлу конфигурации |
| `-o`, `--output` | Путь к выходному файлу |
| `-f`, `--format` | Формат вывода (json, yaml, xml) |
| `-q`, `--quiet` | Тихий режим, подавлять вывод |
| `-d`, `--debug` | Включить отладочный вывод |```bash
python scripts/lab.py detect
| -s | Silent mode. Only print the final result. |
| -v | Verbose mode. Print detailed progress information. |
| -o <file> | Write output to the specified file. |
| -t <type> | Specify the output type. |
| -h | Show help message. |```bash
python scripts/lab.py fixed
## Сценарии использования
- **Аудит безопасности**: сканирование внутренних приложений на наличие уязвимостей
- **Bug bounty**: быстрое тестирование целей в рамках программы
- **CI/CD интеграция**: автоматизированное сканирование в пайплайнах развёртывания
- **Обучение**: понимание распространённых векторов веб-атак
## Установка
### Из исходного кода
```bash
git clone https://github.com/example/vulnscan.git
cd vulnscan
pip install -r requirements.txt
pip install vulnscan
# Базовое сканирование
vulnscan --url https://example.com
# Сканирование с аутентификацией
vulnscan --url https://example.com --cookie "session=abc123"
# Сканирование нескольких целей
vulnscan --file targets.txt --threads 10
Создайте файл config.yaml в рабочей директории:
scan:
timeout: 30
threads: 5
user_agent: "VulnScan/1.0"
modules:
sqli: true
xss: true
ssrf: true
lfi: false
output:
format: json
verbose: false
| Модуль | Описание | Статус |
|---|---|---|
| sqli | Обнаружение SQL-инъекций | Стабильный |
| xss | Обнаружение межсайтового скриптинга | Стабильный |
| ssrf | Обнаружение подделки серверных запросов | Бета |
| lfi | Обнаружение локального включения файлов | Экспериментальный |
Данный проект распространяется под лицензией MIT. Подробности см. в файле LICENSE.```bash python scripts/lab.py audit
Или всю историю сразу, с уроком в конце:```bash
python scripts/lab.py all
И картинка:```bash python scripts/lab.py console
<details>
<summary><b>Всё остальное</b> — CLI детектора, матрица, отчёт, графики, серверы</summary>
<br>
Воспроизведите зафиксированные доказательства через пакет. Запуск приложения не требуется —
детектор строго ниже по потоку, и это доказательство:```bash
python -m detector --once --timeline --events evidence/vulnerable-persistence.jsonl
По одному правилу за раз, либо наивное, либо сканирование состояний:```bash python -m detector --once --rule AFTERLIFE-003 --events evidence/vulnerable-persistence.jsonl
| `--no-color` | Отключить цветной вывод |
| `--debug` | Включить отладочный вывод |
| `--verbose` | Включить подробный вывод |
| `--silent` | Включить тихий режим |
| `--version` | Показать версию программы и выход |
| `--update` | Обновить программу до последней версии |
| `--help` | Показать справочное сообщение и выход |```bash
python -m detector --once --naive --events evidence/vulnerable-persistence.jsonl
| --no-ssl | Отключить проверку SSL-сертификата. |
| --no-redirect | Отключить автоматическое следование перенаправлениям. |
| --no-cookies | Отключить использование файлов cookie. |
| --no-cache | Отключить кэширование. |
| --no-proxy | Отключить использование прокси. |
| --no-auth | Отключить аутентификацию. |
| --no-verify | Отключить проверку. |
| --no-update | Отключить обновление. |
| --no-banner | Отключить отображение баннера. |
| --no-color | Отключить цветной вывод. |
| --no-log | Отключить ведение журнала. |
| --no-debug | Отключить отладочный вывод. |
| --no-verbose | Отключить подробный вывод. |
| --no-quiet | Отключить тихий режим. |
| --no-silent | Отключить беззвучный режим. |
| --no-interactive | Отключить интерактивный режим. |
| --no-batch | Отключить пакетный режим. |
| --no-parallel | Отключить параллельный режим. |
| --no-threads | Отключить использование потоков. |
| --no-processes | Отключить использование процессов. |
| --no-timeout | Отключить тайм-аут. |
| --no-retry | Отключить повторные попытки. |
| --no-delay | Отключить задержку. |
| --no-sleep | Отключить паузу. |
| --no-wait | Отключить ожидание. |
| --no-block | Отключить блокировку. |
| --no-lock | Отключить блокировку. |
| --no-mutex | Отключить мьютекс. |
| --no-semaphore | Отключить семафор. |
| --no-queue | Отключить очередь. |
| --no-buffer | Отключить буферизацию. |
| --no-stream | Отключить потоковую передачу. |
| --no-chunk | Отключить разбиение на фрагменты. |
| --no-split | Отключить разделение. |
| --no-merge | Отключить объединение. |
| --no-concat | Отключить конкатенацию. |
| --no-append | Отключить добавление. |
| --no-prepend | Отключить добавление в начало. |
| --no-insert | Отключить вставку. |
| --no-delete | Отключить удаление. |
| --no-remove | Отключить удаление. |
| --no-clear | Отключить очистку. |
| --no-reset | Отключить сброс. |
| --no-restore | Отключить восстановление. |
| --no-backup | Отключить резервное копирование. |
| --no-restore | Отключить восстановление. |
| --no-save | Отключить сохранение. |
| --no-load | Отключить загрузку. |
| --no-import | Отключить импорт. |
| --no-export | Отключить экспорт. |
| --no-download | Отключить загрузку. |
| --no-upload | Отключить выгрузку. |
| --no-sync | Отключить синхронизацию. |
| --no-async | Отключить асинхронность. |
| --no-await | Отключить ожидание. |
| --no-yield | Отключить уступку. |
| --no-return | Отключить возврат. |
| --no-break | Отключить прерывание. |
| --no-continue | Отключить продолжение. |
| --no-pass | Отключить пропуск. |
| --no-raise | Отключить возбуждение исключения. |
| --no-assert | Отключить утверждение. |
| --no-check | Отключить проверку. |
| --no-validate | Отключить валидацию. |
| --no-sanitize | Отключить санитизацию. |
| --no-escape | Отключить экранирование. |
| --no-quote | Отключить заключение в кавычки. |
| --no-encode | Отключить кодирование. |
| --no-decode | Отключить декодирование. |
| --no-encrypt | Отключить шифрование. |
| --no-decrypt | Отключить дешифрование. |
| --no-hash | Отключить хеширование. |
| --no-sign | Отключить подпись. |
| --no-verify | Отключить проверку. |
| --no-trust | Отключить доверие. |
| --no-cert | Отключить сертификат. |
| --no-key | Отключить ключ. |
| --no-password | Отключить пароль. |
| --no-token | Отключить токен. |
| --no-secret | Отключить секрет. |
| --no-credential | Отключить учётные данные. |
| --no-auth | Отключить аутентификацию. |
| --no-authorize | Отключить авторизацию. |
| --no-permission | Отключить разрешение. |
| --no-role | Отключить роль. |
| --no-policy | Отключить политику. |
| --no-rule | Отключить правило. |
| --no-filter | Отключить фильтр. |
| --no-match | Отключить сопоставление. |
| --no-regex | Отключить регулярное выражение. |
| --no-glob | Отключить шаблон. |
| --no-wildcard | Отключить подстановочный знак. |
| --no-pattern | Отключить шаблон. |
| --no-search | Отключить поиск. |
| --no-replace | Отключить замену. |
| --no-substitute | Отключить подстановку. |
| --no-transform | Отключить преобразование. |
| --no-convert | Отключить конвертацию. |
| --no-format | Отключить форматирование. |
| --no-parse | Отключить разбор. |
| --no-serialize | Отключить сериализацию. |
| --no-deserialize | Отключить десериализацию. |
| --no-marshal | Отключить маршализацию. |
| --no-unmarshal | Отключить демаршализацию. |
| --no-stringify | Отключить преобразование в строку. |
| --no-parse | Отключить разбор. |
| --no-print | Отключить вывод. |
| --no-display | Отключить отображение. |
| --no-render | Отключить рендеринг. |
| --no-draw | Отключить отрисовку. |
| --no-paint | Отключить рисование. |
| --no-style | Отключить стиль. |
| --no-theme | Отключить тему. |
| --no-font | Отключить шрифт. |
| --no-size | Отключить размер. |
| --no-scale | Отключить масштабирование. |
| --no-zoom | Отключить масштабирование. |
| --no-rotate | Отключить поворот. |
| --no-flip | Отключить отражение. |
| --no-crop | Отключить обрезку. |
| --no-resize | Отключить изменение размера. |
| --no-thumbnail | Отключить создание миниатюр. |
| --no-preview | Отключить предпросмотр. |
| --no-view | Отключить просмотр. |
| --no-show | Отключить показ. |
| --no-hide | Отключить скрытие. |
| --no-toggle | Отключить переключение. |
| --no-switch | Отключить переключение. |
| --no-select | Отключить выбор. |
| --no-deselect | Отключить снятие выбора. |
| --no-check | Отключить проверку. |
| --no-uncheck | Отключить снятие отметки. |
| --no-enable | Отключить включение. |
| --no-disable | Отключить отключение. |
| --no-activate | Отключить активацию. |
| --no-deactivate | Отключить деактивацию. |
| --no-start | Отключить запуск. |
| --no-stop | Отключить остановку. |
| --no-pause | Отключить паузу. |
| --no-resume | Отключить возобновление. |
| --no-restart | Отключить перезапуск. |
| --no-reload | Отключить перезагрузку. |
| --no-refresh | Отключить обновление. |
| --no-update | Отключить обновление. |
| --no-upgrade | Отключить обновление. |
| --no-downgrade | Отключить понижение версии. |
| --no-install | Отключить установку. |
| --no-uninstall | Отключить удаление. |
| --no-reinstall | Отключить переустановку. |
| --no-build | Отключить сборку. |
| --no-compile | Отключить компиляцию. |
| --no-link | Отключить компоновку. |
| --no-pack | Отключить упаковку. |
| --no-unpack | Отключить распаковку. |
| --no-zip | Отключить архивацию. |
| --no-unzip | Отключить распаковку. |
| --no-tar | Отключить архивацию. |
| --no-untar | Отключить распаковку. |
| --no-gzip | Отключить сжатие. |
| --no-gunzip | Отключить распаковку. |
| --no-compress | Отключить сжатие. |
| --no-decompress | Отключить распаковку. |
| --no-archive | Отключить архивацию. |
| --no-extract | Отключить извлечение. |
| --no-package | Отключить упаковку. |
| --no-publish | Отключить публикацию. |
| --no-deploy | Отключить развёртывание. |
| --no-release | Отключить выпуск. |
| --no-distribute | Отключить распространение. |
| --no-share | Отключить общий доступ. |
| --no-send | Отключить отправку. |
| --no-receive | Отключить получение. |
| --no-recv | Отключить получение. |
| --no-transmit | Отключить передачу. |
| --no-broadcast | Отключить широковещательную передачу. |
| --no-multicast | Отключить многоадресную передачу. |
| --no-unicast | Отключить одноадресную передачу. |
| --no-anycast | Отключить anycast. |
| --no-connect | Отключить подключение. |
| --no-disconnect | Отключить отключение. |
| --no-reconnect | Отключить переподключение. |
| --no-bind | Отключить привязку. |
| --no-listen | Отключить прослушивание. |
| --no-accept | Отключить принятие. |
| --no-reject | Отключить отклонение. |
| --no-close | Отключить закрытие. |
| --no-open | Отключить открытие. |
| --no-read | Отключить чтение. |
| --no-write | Отключить запись. |
| --no-seek | Отключить позиционирование. |
| --no-tell | Отключить определение позиции. |
| --no-flush | Отключить сброс буфера. |
| --no-truncate | Отключить усечение. |
| --no-append | Отключить добавление. |
| --no-rename | Отключить переименование. |
| --no-move | Отключить перемещение. |
| --no-copy | Отключить копирование. |
| --no-paste | Отключить вставку. |
| --no-cut | Отключить вырезание. |
| --no-undo | Отключить отмену. |
| --no-redo | Отключить повтор. |
| --no-revert | Отключить возврат. |
| --no-commit | Отключить фиксацию. |
| --no-rollback | Отключить откат. |
| --no-push | Отключить отправку. |
| --no-pull | Отключить получение. |
| --no-fetch | Отключить извлечение. |
| --no-clone | Отключить клонирование. |
| --no-fork | Отключить форк. |
| --no-branch | Отключить ветвление. |
| --no-tag | Отключить тегирование. |
| --no-merge | Отключить слияние. |
| --no-rebase | Отключить перебазирование. |
| --no-cherry-pick | Отключить cherry-pick. |
| --no-stash | Отключить откладывание. |
| --no-pop | Отключить извлечение. |
| --no-apply | Отключить применение. |
| --no-patch | Отключить патч. |
| --no-diff | Отключить сравнение. |
| --no-blame | Отключить blame. |
| --no-log | Отключить журнал. |
| --no-status | Отключить статус. |
| --no-show | Отключить показ. |
| --no-config | Отключить конфигурацию. |
| --no-init | Отключить инициализацию. |
| --no-clean | Отключить очистку. |
| --no-reset | Отключить сброс. |
| --no-checkout | Отключить переключение. |
| --no-switch | Отключить переключение. |
| --no-restore | Отключить восстановление. |
| --no-rm | Отключить удаление. |
| --no-mv | Отключить перемещение. |
| --no-cp | Отключить копирование. |
| --no-mkdir | Отключить создание каталога. |
| --no-rmdir | Отключить удаление каталога. |
| --no-touch | Отключить создание файла. |
| --no-ln | Отключить создание ссылки. |
| --no-chmod | Отключить изменение прав. |
| --no-chown | Отключить изменение владельца. |
| --no-chgrp | Отключить изменение группы. |
| --no-umask | Отключить маску прав. |
| --no-stat | Отключить статистику. |
| --no-file | Отключить файл. |
| --no-dir | Отключить каталог. |
| --no-path | Отключить путь. |
| --no-name | Отключить имя. |
| --no-ext | Отключить расширение. |
| --no-base | Отключить базовое имя. |
| --no-dirname | Отключить имя каталога. |
| --no-basename | Отключить базовое имя. |
| --no-realpath | Отключить реальный путь. |
| --no-abspath | Отключить абсолютный путь. |
| --no-relpath | Отключить относительный путь. |
| --no-normalize | Отключить нормализацию. |
| --no-canonical | Отключить канонизацию. |
| --no-resolve | Отключить разрешение. |
| --no-lookup | Отключить поиск. |
| --no-query | Отключить запрос. |
| --no-search | Отключить поиск. |
| --no-find | Отключить поиск. |
| --no-locate | Отключить поиск. |
| --no-which | Отключить поиск. |
| --no-where | Отключить поиск. |
| --no-type | Отключить тип. |
| --no-kind | Отключить вид. |
| --no-class | Отключить класс. |
| --no-category | Отключить категорию. |
| --no-group | Отключить группу. |
| --no-tag | Отключить тег. |
| --no-label | Отключить метку. |
| --no-annotate | Отключить аннотацию. |
| --no-comment | Отключить комментарий. |
| --no-note | Отключить заметку. |
| --no-desc | Отключить описание. |
| --no-title | Отключить заголовок. |
| --no-subject | Отключить тему. |
| --no-body | Отключить тело. |
| --no-header | Отключить заголовок. |
| --no-footer | Отключить нижний колонтитул. |
| --no-meta | Отключить метаданные. |
| --no-data | Отключить данные. |
| --no-info | Отключить информацию. |
| --no-detail | Отключить детали. |
| --no-summary | Отключить сводку. |
| --no-report | Отключить отчёт. |
| --no-output | Отключить вывод. |
| --no-input | Отключить ввод. |
| --no-stdin | Отключить стандартный ввод. |
| --no-stdout | Отключить стандартный вывод. |
| --no-stderr | Отключить стандартный вывод ошибок. |
| --no-pipe | Отключить конвейер. |
| --no-redirect | Отключить перенаправление. |
| --no-tee | Отключить разветвление. |
| --no-xargs | Отключить xargs. |
| --no-parallel | Отключить параллельный режим. |
| --no-sequential | Отключить последовательный режим. |
| --no-serial | Отключить последовательный режим. |
| --no-concurrent | Отключить параллельный режим. |
| --no-simultaneous | Отключить одновременный режим. |
| --no-sync | Отключить синхронизацию. |
| --no-async | Отключить асинхронность. |
| --no-blocking | Отключить блокировку. |
| --no-nonblocking | Отключить неблокирующий режим. |
| --no-wait | Отключить ожидание. |
| --no-nowait | Отключить режим без ожидания. |
| --no-timeout | Отключить тайм-аут. |
| --no-deadline | Отключить крайний срок. |
| --no-expire | Отключить истечение срока. |
| --no-ttl | Отключить TTL. |
| --no-lease | Отключить аренду. |
| --no-lock | Отключить блокировку. |
| --no-unlock | Отключить разблокировку. |
| --no-mutex | Отключить мьютекс. |
| --no-semaphore | Отключить семафор. |
| --no-barrier | Отключить барьер. |
| --no-latch | Отключить защёлку. |
| --no-cond | Отключить условие. |
| --no-signal | Отключить сигнал. |
| --no-event | Отключить событие. |
| --no-callback | Отключить обратный вызов. |
| --no-hook | Отключить хук. |
| --no-trigger | Отключить триггер. |
| --no-handler | Отключить обработчик. |
| --no-listener | Отключить слушатель. |
| --no-observer | Отключить наблюдатель. |
| --no-subscriber | Отключить подписчика. |
| --no-publisher | Отключить издателя. |
| --no-producer | Отключить производителя. |
| --no-consumer | Отключить потребителя. |
| --no-broker | Отключить брокер. |
| --no-queue | Отключить очередь. |
| --no-topic | Отключить тему. |
| --no-channel | Отключить канал. |
| --no-stream | Отключить поток. |
| --no-buffer | Отключить буфер. |
| --no-cache | Отключить кэш. |
| --no-pool | Отключить пул. |
| --no-connection | Отключить соединение. |
| --no-session | Отключить сессию. |
| --no-transaction | Отключить транзакцию. |
| --no-commit | Отключить фиксацию. |
| --no-rollback | Отключить откат. |
| --no-savepoint | Отключить точку сохранения. |
| --no-checkpoint | Отключить контрольную точку. |
| --no-snapshot | Отключить снимок. |
| --no-backup | Отключить резервное копирование. |
| --no-restore | Отключить восстановление. |
| --no-recover | Отключить восстановление. |
| --no-repair | Отключить исправление. |
| --no-fix | Отключить исправление. |
| --no-heal | Отключить самовосстановление. |
| --no-migrate | Отключить миграцию. |
| --no-upgrade | Отключить обновление. |
| --no-downgrade | Отключить понижение версии. |
| --no-convert | Отключить конвертацию. |
| --no-transform | Отключить преобразование. |
| --no-map | Отключить отображение. |
| --no-reduce | Отключить свёртку. |
| --no-filter | Отключить фильтрацию. |
| --no-fold | Отключить свёртку. |
| --no-scan | Отключить сканирование. |
| --no-iterate | Отключить итерацию. |
| --no-loop | Отключить цикл. |
| --no-recurse | Отключить рекурсию. |
| --no-traverse | Отключить обход. |
| --no-walk | Отключить обход. |
| --no-visit | Отключить посещение. |
| --no-explore | Отключить исследование. |
| --no-discover | Отключить обнаружение. |
| --no-enumerate | Отключить перечисление. |
| --no-list | Отключить список. |
| --no-enum | Отключить перечисление. |
| --no-count | Отключить подсчёт. |
| --no-sum | Отключить суммирование. |
| --no-avg | Отключить вычисление среднего. |
| --no-min | Отключить минимум. |
| --no-max | Отключить максимум. |
| --no-sort | Отключить сортировку. |
| --no-order | Отключить упорядочивание. |
| --no-rank | Отключить ранжирование. |
| --no-group | Отключить группировку. |
| --no-uniq | Отключить уникальность. |
| --no-dedup | Отключить дедупликацию. |
| --no-distinct | Отключить различение. |
| --no-merge | Отключить слияние. |
| --no-join | Отключить соединение. |
| --no-split | Отключить разделение. |
| --no-partition | Отключить секционирование. |
| --no-chunk | Отключить разбиение на фрагменты. |
| --no-batch | Отключить пакетную обработку. |
| --no-bulk | Отключить массовую обработку. |
| --no-mass | Отключить массовую обработку. |
| --no-burst | Отключить пакетную обработку. |
| --no-throttle | Отключить ограничение скорости. |
| --no-rate | Отключить ограничение скорости. |
| --no-limit | Отключить ограничение. |
| --no-quota | Отключить квоту. |
| --no-cap | Отключить ограничение. |
| --no-ceiling | Отключить верхний предел. |
| --no-floor | Отключить нижний предел. |
| --no-bound | Отключить границу. |
| --no-range | Отключить диапазон. |
| --no-scope | Отключить область видимости. |
| --no-context | Отключить контекст. |
| --no-env | Отключить окружение. |
| --no-var | Отключить переменную. |
| --no-const | Отключить константу. |
| --no-let | Отключить let. |
| --no-def | Отключить определение. |
| --no-declare | Отключить объявление. |
| --no-assign | Отключить присваивание. |
| --no-bind | Отключить привязку. |
| --no-alias | Отключить псевдоним. |
| --no-ref | Отключить ссылку. |
| --no-ptr | Отключить указатель. |
| --no-deref | Отключить разыменование. |
| --no-address | Отключить адрес. |
| --no-offset | Отключить смещение. |
| --no-index | Отключить индекс. |
| --no-key | Отключить ключ. |
| --no-value | Отключить значение. |
| --no-pair | Отключить пару. |
| --no-entry | Отключить запись. |
| --no-item | Отключить элемент. |
| --no-element | Отключить элемент. |
| --no-node | Отключить узел. |
| --no-edge | Отключить ребро. |
| --no-vertex | Отключить вершину. |
| --no-graph | Отключить граф. |
| --no-tree | Отключить дерево. |
| --no-list | Отключить список. |
| --no-array | Отключить массив. |
| --no-vector | Отключить вектор. |
| --no-matrix | Отключить матрицу. |
| --no-set | Отключить множество. |
| --no-map | Отключить карту. |
| --no-dict | Отключить словарь. |
| --no-hash | Отключить хеш. |
| --no-table | Отключить таблицу. |
| --no-schema | Отключить схему. |
| --no-model | Отключить модель. |
| --no-entity | Отключить сущность. |
| --no-object | Отключить объект. |
| --no-instance | Отключить экземпляр. |
| --no-class | Отключить класс. |
| --no-struct | Отключить структуру. |
| --no-union | Отключить объединение. |
| --no-enum | Отключить перечисление. |
| --no-interface | Отключить интерфейс. |
| --no-trait | Отключить трейт. |
| --no-mixin | Отключить примесь. |
| --no-protocol | Отключить протокол. |
| --no-abstract | Отключить абстракцию. |
| --no-virtual | Отключить виртуальность. |
| --no-override | Отключить переопределение. |
| --no-overload | Отключить перегрузку. |
| --no-extend | Отключить расширение. |
| --no-implement | Отключить реализацию. |
| --no-inherit | Отключить наследование. |
| --no-derive | Отключить наследование. |
| --no-compose | Отключить композицию. |
| --no-delegate | Отключить делегирование. |
| --no-proxy | Отключить прокси. |
| --no-facade | Отключить фасад. |
| --no-adapter | Отключить адаптер. |
| --no-bridge | Отключить мост. |
| --no-factory | Отключить фабрику. |
| --no-builder | Отключить строитель. |
| --no-singleton | Отключить синглтон. |
| --no-prototype | Отключить прототип. |
| --no-observer | Отключить наблюдатель. |
| --no-strategy | Отключить стратегию. |
| --no-command | Отключить команду. |
| --no-state | Отключить состояние. |
| --no-visitor | Отключить посетителя. |
| --no-mediator | Отключить посредника. |
| --no-memento | Отключить снимок. |
| --no-iterator | Отключить итератор. |
| --no-generator | Отключить генератор. |
| --no-coroutine | Отключить корутину. |
| --no-fiber | Отключить файбер. |
| --no-thread | Отключить поток. |
| --no-process | Отключить процесс. |
| --no-task | Отключить задачу. |
| --no-job | Отключить задание. |
| --no-worker | Отключить воркер. |
| --no-pool | Отключить пул. |
| --no-executor | Отключить исполнитель. |
| --no-scheduler | Отключить планировщик. |
| --no-dispatcher | Отключить диспетчер. |
| --no-runner | Отключить исполнитель. |
| --no-agent | Отключить агент. |
| --no-daemon | Отключить демон. |
| --no-service | Отключить службу. |
| --no-server | Отключить сервер. |
| --no-client | Отключить клиент. |
| --no-peer | Отключить пир. |
| --no-host | Отключить хост. |
| --no-node | Отключить узел. |
| --no-cluster | Отключить кластер. |
| --no-swarm | Отключить рой. |
| --no-mesh | Отключить сетку. |
| --no-grid | Отключить сетку. |
| --no-cloud | Отключить облако. |
| --no-edge | Отключить периферию. |
| --no-fog | Отключить туман. |
| --no-mist | Отключить туман. |
| --no-dew | Отключить росу. |
| --no-frost | Отключить мороз. |
| --no-ice | Отключить лёд. |
| --no-snow | Отключить снег. |
| --no-rain | Отключить дождь. |
| --no-storm | Отключить шторм. |
| --no-thunder | Отключить гром. |
| --no-lightning | Отключить молнию. |
| --no-wind | Отключить ветер. |
| ````bash
python -m detector --audit --events evidence/vulnerable-persistence.jsonl
CLI завершается с кодом `1`, когда что-то находит, поэтому работает как проверка в CI без
разбора своего вывода.
Девять сценариев для обеих реализаций, с самопроверяющимися инвариантами:```bash
python scripts/scenarios.py
Отчёт об инциденте, который был бы передан специалисту по реагированию, и цифры в этом README:```bash python scripts/report.py
Проверяет, содержит ли строка подозрительные символы, которые могут указывать на попытку инъекции команд.```bash
python scripts/figures.py
Живая консоль и лабораторный API вручную — оба только через loopback:```bash python -m console
| `--no-color` | Отключить цветной вывод |
| `--debug` | Включить режим отладки |
| `--verbose` | Включить подробный вывод |
| `--silent` | Включить тихий режим |
| `--version` | Показать версию программы и выйти |
| `--help` | Показать это справочное сообщение и выйти |```bash
python -m app --mode vulnerable --port 9101
Восстановите зафиксированную телеметрию (фиксированные часы, побайтово идентичная между запусками):```bash python scripts/lab.py evidence
Task runners, одни и те же цели в любом случае:```bash
make demo
| --config | Path to a YAML config file (default: config.yaml) |
| --output | Output directory for reports (default: ./reports) |
| --format | Report format: json, html, or sarif (default: json) |
| --verbose | Enable verbose logging |
| --quiet | Suppress all non-error output |
The scanner reads its configuration from a YAML file. A minimal example:
targets:
- https://example.com
- https://api.example.com
checks:
- headers
- tls
- cors
timeout: 30
concurrency: 10
| Code | Meaning |
|---|---|
0 | No issues found |
1 | Issues detected |
2 | Configuration error |
3 | Network or connection failure |
Scan a single target and output SARIF:
scanner --target https://example.com --format sarif --output ./results
Run with a custom config file and verbose logging:
scanner --config ./custom-config.yaml --verbose
Build and run the container:
docker build -t scanner:latest .
docker run --rm -v $(pwd)/reports:/app/reports scanner:latest --target https://example.com
Contributions are welcome. Please open an issue before submitting a pull request for major changes.
git checkout -b feature/my-feature).git commit -am 'Add new feature').git push origin feature/my-feature).This project is licensed under the MIT License. See the LICENSE file for details.```powershell ./make.ps1 demo
</details>
### Учётная запись лаборатории```text
alice / Password123! → changed during the demo to Correct-Horse-Battery-9!
Ложные, локальные и единственные учётные данные в этом репозитории.
Этот репозиторий содержит намеренно уязвимый код.
app/auth.py:_revoke_for_security_change намеренно не выполняет отзыв, когда
AFTERLIFE_MODE=vulnerable.
Уязвимый путь — не режим по умолчанию. AFTERLIFE_MODE по умолчанию равен fixed,
и test_the_default_mode_is_the_safe_one это фиксирует — лаборатория, сломанное поведение
которой получается при забывчивости настроить хоть что-нибудь, в конечном итоге будет скопирована
во что-то реальное.
Небезопасно выставлять в сеть в любом из режимов: bearer-учётные данные возвращаются в
телах ответов, GET /lab/credentials выдаёт всё представление сервера о состоянии учётных данных пользователя,
GET /lab/audit неаутентифицирован по замыслу,
POST /security-change выдаст себе admin, и нигде нет TLS, ограничения частоты запросов, защиты от CSRF или блокировки учётных записей. Всё привязывается к
127.0.0.1, и как python -m app, так и python -m console отказываются от любого другого
адреса.
Ни один реальный учётный данные, ключ или секрет не встречается в этом репозитории. Ключ подписи —
это буквальная строка afterlife-lab-signing-key-not-a-secret-do-not-reuse;
база данных учётных данных по умолчанию использует :memory:; телеметрия заменяет значения, имена ключей которых
выглядят как учётные данные, и ссылается на учётные данные по id и отпечатку.
Подробности и процесс сообщения об уязвимостях: SECURITY.md.
| docs/detection.md | набор правил: все три карточки правил, аудит состояния, требуемая телеметрия, ложные срабатывания, обоснование серьёзности, сценарии реагирования |
| docs/tradeoffs.md | четыре архитектуры отзыва, напряжение вокруг JWT и обсуждение CVSS |
| docs/limitations.md | все слепые зоны, что исправление не исправляет, что закрывает аудит и собственные компромиссы лаборатории |
| docs/console-design.md | почему консоль — это реестр смертности, решения по палитре и шрифтам и что было вырезано |
| docs/console-preview.html | консоль, собранная в один самодостаточный файл |
| docs/figures/ | иллюстрации в этом README, сгенерированные из полезной нагрузки |
| report/AFTERLIFE-report.md | сгенерированный отчёт об инциденте для уязвимого запуска |
| SECURITY.md | граница только-локального использования, учётные данные лаборатории, контракт редактирования |
| evidence/ | очищенная выборочная телеметрия, порождаемые ею оповещения и аудит состояния |
Проверено по первоисточникам 2026-09-11. VERIFIED означает, что было прочитано собственное уведомление вендора; REPORTED означает, что деталь взята из базы данных уязвимостей, а не от вендора.
Стандарты и классификации
Паттерн, который воспроизводит эта лаборатория
@strapi/admin и @strapi/plugin-users-permissions ≤ 5.33.2; исправлено в 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. Опубликовано 2026-05-13.
В уведомлении вендора указано, что шаг аннулирования refresh-токена был обусловлен
переданным вызывающей стороной deviceId, и что патч аннулирует все refresh-токены
при каждом изменении и сбросе пароля независимо от того, передан ли deviceId.
Именно это условие моделирует _revoke_for_security_change, и именно этот
патч реализует исправленный режим.
Контекст вендора: Strapi security disclosure, May 2026.Тот же класс, другие механизмы
Три продукта, три механизма — условный параметр, отсутствующий вызов, неротированный ключ — один исход: сброс прошёл успешно, а злоумышленник остался.
Злоумышленника не обнаруживают, потому что его запрос выглядит вредоносным.
Каждый сделанный им запрос был корректно сформирован, правильно подписан и нёс учётные данные, которые сервер только что для него выпустил.
Его обнаруживают, потому что цепочка учётных данных, которая должна была умереть при событии безопасности, была принята после него.
MIT · локальная лаборатория · ничто здесь не безопасно развёртывать
dormant-survivor | уязвимый | 15 | HIGH | AFTERLIFE-003 | 1 | 1 |
dormant-survivor | исправленный | 19 | – | нет | 0 | 0 |
mfa-change | уязвимый | 18 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
mfa-change | исправленный | 18 | – | нет | 0 | 0 |
account-recovery | уязвимый | 18 | HIGH | AFTERLIFE-001, AFTERLIFE-003 | 1 | 0 |
account-recovery | исправленный | 18 | – | нет | 0 | 0 |
expired-lineage | уязвимый | 17 | LOW | AFTERLIFE-003 | 0 | 0 |
expired-lineage | исправленный | 20 | – | нет | 0 | 0 |