Статья Проверка подписи Stripe webhook: bypass-техники и реальные CVE на пентесте

Криминалистический стол с отладочной платой и USB-зондом. Экран ноутбука показывает ошибку пропущенной проверки подписи вебхука, рука в перчатке настраивает оборудование.


Прогнал Semgrep с кастомным правилом по 40 open-source проектам с Stripe-интеграцией на GitHub - 14 из них не вызывали constructEvent() вообще. Webhook-эндпоинт принимал POST, парсил JSON, читал type: "payment_intent.succeeded" и запускал бизнес-логику: активация подписки, выдача доступа, отгрузка товара. Никакой верификации заголовка Stripe-Signature, никакого HMAC. Ребята из lorikeetsecurity фиксируют ту же картину на пентестах: примерно каждая третья реализация webhook не проверяет подпись. А CVE-2026-21894 в платформе n8n подтвердил, что паттерн живёт не только в студенческих pet-проектах - продукт с десятками тысяч инсталляций хранил signing secret и ни разу не использовал его для верификации входящих запросов. Сотни релизов подряд.

Webhook security на пентесте: зачем атакующему endpoint для уведомлений​

Webhook-эндпоинт - публичный HTTP-endpoint, который принимает POST-запросы от внешнего сервиса и выполняет привилегированные действия на основе содержимого payload. Stripe отправляет payment_intent.succeeded, приложение активирует подписку, зачисляет средства на баланс или запускает отгрузку. В терминах MITRE ATT&CK это Exploit Public-Facing Application (T1190, Initial Access): атакующий шлёт один POST-запрос на публичный URL и получает выполнение бизнес-логики без какой-либо аутентификации.

В kill chain подделка webhook - это сразу impact. Никакого lateral movement, privilege escalation или закрепления. Один HTTP-запрос с правильным JSON - и приложение считает, что платёж прошёл. Прямое нарушение целостности данных, классика OWASP A08:2021 - Software and Data Integrity Failures.

Поверхность атаки обнаруживаема. Webhook URL часто предсказуем: /webhooks/stripe, /api/webhooks/payment, /hooks/stripe. Документация Stripe публикует ожидаемые паттерны. Endpoint должен быть доступен из интернета - Stripe обязан до него достучаться - и спрятать его за VPN в большинстве сценариев невозможно. Для recon достаточно техники T1593.003 (Code Repositories): запрос "payment_intent.succeeded" language:javascript NOT constructEvent в GitHub Code Search находит тысячи файлов, где обрабатывается Stripe-событие без вызова верификации.

И вот что важно: webhook endpoint почти никогда не попадает в скоуп мониторинга SIEM. Он обрабатывает бизнес-события, а не security-события. Ни один Sigma-rule из коробки не детектирует подделку payment_intent.succeeded. Слепая зона.

[Применимо: внешний пентест, API assessment, code review, bug bounty]

Webhook уязвимость: пять техник обхода signature verification​

1783673539215.webp

Отсутствие Stripe webhook validation - самый частый баг​

Самый распространённый и самый простой для эксплуатации случай: разработчик настроил endpoint, получил whsec_-секрет в дашборде Stripe - и никогда не передал его в код верификации. Endpoint работает, события приходят, бизнес-логика крутится. Проверка подписи Stripe webhook? "Потом добавим".

На пентесте проверяется одним запросом - отправляете произвольный JSON с полем type: "payment_intent.succeeded" без валидного заголовка Stripe-Signature. Endpoint вернул 200 OK и выполнил действие - верификации нет. Второй тест: полностью убираете заголовок Stripe-Signature из запроса. Ответ не изменился - подтверждение. Третий: отправляете Stripe-Signature: FORGED - если 200, endpoint не парсит заголовок вообще.

Когда работает, когда нет: техника эксплуатируема только при полном отсутствии вызова constructEvent() на серверной стороне. Если SDK-метод вызывается, но с неверным секретом - endpoint вернёт 400, не 200. На внешнем пентесте хватит трёх HTTP-запросов. На code review - grep по constructEvent или construct_event в webhook-хэндлерах.

Timing-атака на HMAC signature verification​

Когда верификация реализована, сравнение вычисленного HMAC с переданным в заголовке Stripe-Signature должно использовать функцию с постоянным временем выполнения. Стандартное сравнение строк (== в большинстве языков) возвращает false при первом несовпавшем байте. Разница во времени ответа измерима и позволяет восстановить правильную подпись побайтово - классический timing side-channel.

Корректные функции: hmac.compare_digest() в Python, hash_equals() в PHP, crypto.timingSafeEqual() в Node.js, hmac.Equal() в Go. Обычный === или strcmp() - уязвимы. На code review ищите кастомные реализации верификации вместо SDK-вызовов: если разработчик написал if (computedSig === receivedSig) вместо constructEvent() - это находка.

Когда работает, когда нет: атака требует стабильного канала с низким jitter, сотен запросов на каждый байт подписи и отсутствия rate limiting на webhook endpoint. Через интернет сетевой шум обычно маскирует timing-разницу. Практически результативна на localhost, во внутренних сетях, в staging-окружениях. Актуальна для legacy-реализаций, где разработчик написал проверку вручную вместо SDK.

Небезопасная обработка webhook: парсинг тела до проверки​

Подпись Stripe вычисляется над сырыми байтами тела запроса. Если фреймворк парсит JSON до вызова constructEvent(), а затем код передаёт JSON.stringify() от распарсенного объекта - побайтовое совпадение ломается. Пробелы, порядок ключей, числовая точность меняются при parse-reserialize. Подпись не совпадает, разработчик видит SignatureVerificationError, не понимает причину - и отключает проверку как "workaround".

В Express.js ключевая ошибка - разместить app.use(express.json()) до webhook-роута. По документации Stripe, webhook-роут должен использовать express.raw({ type: 'application/json' }) и находиться выше JSON-парсера в стеке middleware. В Laravel тело доступно через $request->getContent(), но если CSRF-middleware не исключает webhook-маршрут - запрос получит 419 до любой верификации. В Symfony ловушка аналогичная: $request->toArray() с последующим json_encode() нарушит побайтовое совпадение. Каждый фреймворк ломает верификацию по-своему - и это, пожалуй, самая коварная из пяти техник, потому что разработчик думает, что проверка есть.

Когда работает, когда нет: техника специфична для фреймворка и конфигурации. На пентесте можно заподозрить, если endpoint возвращает 400 с валидной подписью из Stripe CLI - значит, middleware модифицирует тело до проверки. На code review: ищите порядок middleware в стеке и формат тела, передаваемого в constructEvent().

Webhook replay attack: переотправка перехваченных событий​

Stripe включает timestamp t в заголовок Stripe-Signature и рекомендует проверять, что событие не старше 5 минут (default tolerance в SDK). Если приложение не проверяет timestamp или задаёт широкий tolerance - перехваченный webhook можно переотправить позже.

Сценарий: атакующий через Adversary-in-the-Middle (T1557) или из логов системы получает легитимный запрос с валидной подписью. Без проверки timestamp его можно переиграть через час, день, неделю - приложение выполнит бизнес-логику повторно. Нет идемпотентности (проверки event.id на уникальность) - двойное зачисление, повторная активация подписки, дублирование заказа.

Когда работает, когда нет: требует доступа к легитимному webhook-запросу. Stripe SDK по умолчанию проверяет timestamp с tolerance 300 секунд - атака работает только если разработчик явно отключил проверку или выставил tolerance в дни. Актуально на внутреннем пентесте при доступе к логам, proxy или дашборду Stripe.

Webhook spoofing через пустой signing secret​

Отдельный класс ошибок - когда signing secret не отсутствует, а пуст. Переменная окружения STRIPE_WEBHOOK_SECRET объявлена, но не заполнена. HMAC с пустым ключом вычислит валидный хеш - и хеш этот предсказуем: атакующий сам считает HMAC-SHA256("", payload) и подставляет результат в Stripe-Signature. По данным GitHub advisory GHSA-xff3-5c9p-2mr4 (проект new-api), именно этот паттерн позволял обходить проверку подписи и получать неограниченные квоты через поддельные webhook-события.

Когда работает, когда нет: требует, чтобы signing secret был пустой строкой, а не null/undefined. Поведение зависит от SDK: в Node.js stripe.webhooks.constructEvent(body, sig, "") вычислит HMAC с пустым ключом. В Python SDK пустая строка вызовет исключение. На code review ищите конфигурацию, где getenv('STRIPE_WEBHOOK_SECRET') не проверяется на пустоту перед использованием.

CVE-2026-21894: webhook forgery в n8n без единой проверки подписи​

CVE-2026-21894 - authentication bypass в ноде StripeTrigger платформы n8n. CVSS 6.5 (MEDIUM), вектор CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:H/A:N. CWE-290 - Authentication Bypass by Spoofing. Затронуты версии от 0.150.0 до 2.2.2, исправлено в 2.2.2.

Разбор вектора: сетевой доступ (AV:N), высокая сложность (AC:H - нужно знать URL webhook-эндпоинта конкретного tenant), привилегии не требуются (PR:N), действие пользователя не нужно (UI:N). Критичен импакт на целостность (I:H): атакующий подделывает платёжные события, workflow выполняет произвольные действия - списание средств, отправка товара, модификация записей в базе.

Суть бага до обидного простая. Метод create в StripeTrigger.node.ts создаёт webhook в Stripe и сохраняет signing secret. Метод webhook(), обрабатывающий входящие запросы, никогда не читает заголовок Stripe-Signature, не извлекает сохранённый секрет, не вычисляет HMAC. Единственная «проверка» - совпадает ли bodyData.type с настроенным типом события. По advisory GHSA-jf52-3f2h-h9j5, другие trigger-ноды (WooCommerce, HelpScout) корректно вычисляют HMAC от req.rawBody и отклоняют запросы с невалидной подписью. StripeTrigger - исключение. Рядом лежит рабочий пример, а разработчик всё равно пропустил.

PoC из advisory - один запрос:
Bash:
curl -X POST "https://<tenant>.app.n8n.cloud/webhook/<uuid>/webhook" -H "Content-Type: application/json" -H "Stripe-Signature: FORGED" -d '{"type":"payment_intent.succeeded", "data":{"object":{"id":"pi_FORGED", "amount":999999,"currency":"usd", "status":"succeeded"}}}'
# Ответ: 200 OK. Workflow выполняется с поддельными данными.
200 OK, workflow крутится, поддельный payload уходит в downstream-системы. На практике это позволяло подделывать успешные платежи, манипулировать подписками и инжектить данные во внешние API, подключённые к n8n workflow.

Поиск Stripe webhook bypass в масштабе: Semgrep и Burp Suite​

Требования к окружению: Burp Suite Community или Pro (black box), Semgrep OSS (code review, бесплатный, Python 3.8+), Stripe CLI (тестирование легитимных событий, бесплатный). ОС: (GNU/Linux)/macOS/Windows. Для Semgrep-сканирования GitHub-репозиториев - стабильный интернет и GitHub API token.

Black box, внешний пентест (Burp Suite). Перехватываете легитимный webhook через Stripe CLI командой stripe listen --forward-to localhost:4242/webhook, затем модифицируете запрос в Repeater: заменяете значение Stripe-Signature на произвольную строку и переотправляете. Ответ 200 OK - верификации нет. Второй тест: удаляете заголовок полностью. Третий: меняете один байт в body, отправляете с оригинальной подписью - проверяете, вычисляет ли сервер HMAC.

Code review, white box (Semgrep). Кастомное правило ищет webhook-обработчики с контекстом stripe, которые не вызывают constructEvent в том же блоке:
YAML:
rules:
  - id: stripe-webhook-no-sig-check
    patterns:
      - pattern: app.post("...webhook...", (...) => { ... })
      - pattern-not-inside: |
          stripe.webhooks.constructEvent(...)
    message: "Webhook handler without signature verification"
    severity: ERROR
    languages: [javascript, typescript]
Паттерн - отправная точка: для продакшна его нужно адаптировать под фреймворк (Express vs Fastify vs Hono) и язык (для Python - Webhook.construct_event, для PHP - Webhook::constructEvent). На моих code review именно этот подход - с вариациями для Python/PHP/Go - дал 14 находок из 40 проектов.

Recon через GitHub search. Запрос "payment_intent.succeeded" language:python NOT construct_event в GitHub Code Search показывает масштаб: файлы, где Stripe-событие обрабатывается напрямую из request.json без верификации. Не все из них продакшн, но для bug bounty это рабочая точка входа в recon.

API security webhook: безопасная реализация vs уязвимая​

1783673636823.webp

Разница между уязвимой и безопасной реализацией - три строки кода. Минимальный безопасный обработчик на Node.js/Express, где каждая строка критична:
JavaScript:
// Webhook-роут ДО express.json() в стеке middleware
app.post('/webhook', express.raw({type: 'application/json'}), (req, res) => {
  const sig = req.headers['stripe-signature'];
  try {
    const event = stripe.webhooks.constructEvent(
      req.body, sig, process.env.STRIPE_WEBHOOK_SECRET
    ); // HMAC + timestamp - одним вызовом
    handleEvent(event);
    res.status(200).end();
  } catch (err) { res.status(400).end(); }
});
Три момента, которые ломают реализацию: (1) express.raw() вместо express.json() - тело остаётся байтовой строкой для HMAC; (2) constructEvent() получает raw body, заголовок Stripe-Signature и секрет whsec_; (3) webhook-роут размещён выше app.use(express.json()) в стеке. SDK внутри constructEvent() использует crypto.timingSafeEqual() для сравнения и проверяет timestamp с tolerance 300 секунд - обе защиты из коробки.

На что обращать внимание при аудите помимо самой подписи:
📚 Часть контента скрыта. Этот материал доступен участникам сообщества с рангом One Level или выше
Получить доступ просто — достаточно зарегистрироваться и проявить активность на форуме

Полгода назад я бы сказал, что webhook security - решённая проблема: SDK есть, документация Stripe подробная, constructEvent() - одна строка. CVE-2026-21894 в n8n сломал эту иллюзию. Продукт, где рядом лежат корректные реализации для WooCommerce и HelpScout, годами работал без верификации Stripe-подписи. Проблема не в отсутствии инструментов. Проблема в том, что фреймворки и SDK делают слишком лёгким путь без проверки. Endpoint работает и без constructEvent(). Платежи проходят, тесты зелёные, разработчик переходит к следующей задаче. Это не халатность - это архитектурный дефект: безопасный путь требует дополнительного действия, а небезопасный работает по умолчанию.

Мой прогноз: webhook endpoint security станет отдельным пунктом в стандартных чек-листах API-пентеста в ближайший год. Сейчас большинство методологий фокусируются на аутентификации, авторизации, input validation - а webhook-эндпоинты проходят мимо. Но каждый такой endpoint - точка, где один подделанный POST конвертируется в реальные деньги. На HackerLab (https://hackerlab.pro) лежит сценарий, где auth bypass через подделку серверного запроса нужно собрать в полную цепочку от forgery до impact - без подсказок.
 
Последнее редактирование модератором:
Мы в соцсетях:

Взломай свой первый сервер и прокачай скилл — Начни игру на HackerLab

🚀 Первый раз на Codeby?
Гайд для новичков: что делать в первые 15 минут, ключевые разделы, правила
Начать здесь →
🧭 Навигатор · ИБ 2026
Не знаешь, какой трек твой?
5 направлений ИБ, реальные зарплаты и точка входа для каждого — в одном треде.
JuniorSenior+
100K → 600K+ ₽ /мес
Открыть навигатор →
🔴 Свежие CVE, 0-day и инциденты
То, о чём ChatGPT ещё не знает — обсуждаем в реальном времени
Threat Intel →
💼 Вакансии и заказы в ИБ
Pentest, SOC, DevSecOps, bug bounty — работа и проекты от проверенных компаний
Карьера в ИБ →

HackerLab