Купон на скидку применяется один раз. Так написано в ТЗ, так реализовано в коде, так проходит QA. А потом кто-то отправляет двадцать одинаковых POST-запросов в одном TCP-пакете - и купон применяется двадцать раз. Баланс уходит в минус, товар отгружается бесплатно, а в журналах всё выглядит как двадцать легитимных запросов с разницей в доли миллисекунды.
Race conditions - класс уязвимостей, который десятилетиями считался экзотикой: слишком ненадёжная эксплуатация, слишком зависима от сетевого jitter, слишком сложно воспроизвести в отчёте. Всё изменилось в августе 2023 года, когда James Kettle из PortSwigger показал на Black Hat USA исследование Smashing the state machine и технику single-packet attack. Она убрала из уравнения сетевую задержку и сделала race conditions воспроизводимыми с первой попытки. После этого bug bounty-платформы увидели резкий рост отчётов по этому классу, а в Burp Suite появился штатный инструментарий для тестирования.
TOCTOU: окно между проверкой и действием
В основе большинства race conditions в веб-приложениях лежит паттерн Time-of-Check to Time-of-Use. Сервер проверяет условие - баланс достаточен, купон ещё не использован, лимит не превышен - а потом выполняет действие: списывает деньги, помечает купон, увеличивает счётчик. Между проверкой и действием существует временное окно. Иногда это микросекунды, иногда миллисекунды. Если в этот зазор попадает второй запрос, он проходит ту же проверку, потому что состояние ещё не успело обновиться.Псевдокод типичного уязвимого обработчика:
Python:
# Обработчик применения купона
coupon = get_coupon(code)
if not coupon.used: # Time-of-Check
apply_discount(order)
coupon.used = True # Time-of-Use
save(coupon)
Размер окна зависит от архитектуры. Монолит с синхронным обработчиком в одном потоке даёт почти нулевой зазор. Монолит на gunicorn с несколькими процессами уже создаёт конкуренцию. Микросервисная схема, где проверка баланса идёт через один сервис, а списание через другой с промежуточным HTTP-вызовом, расширяет окно до десятков миллисекунд. Чем больше расстояние между check и use, тем проще эксплуатация.
Почему race conditions было сложно тестировать
До single-packet attack эксплуатация race conditions выглядела примитивно: открываем двадцать потоков, из каждого отправляем HTTP-запрос, надеемся, что они придут на сервер достаточно близко по времени. Проблема - сетевой разброс. Даже запросы, отправленные “одновременно”, проходят через TCP handshake, TLS negotiation, маршрутизацию и приходят на сервер с расхождением в 10-50 мс. Для race window в 1-2 мс этого уже достаточно, чтобы всё развалилось.Last-byte synchronization частично решала проблему. Суть такая: все запросы отправляются без последнего байта тела, соединения удерживаются открытыми, а потом последние байты досылаются одновременно. Сервер начинает обработку только после получения полного тела, поэтому разброс уменьшается. Но на практике TCP-буферизация, алгоритм Нейгла и промежуточные прокси всё равно добавляли несколько миллисекунд погрешности.
Single-packet attack: race без сетевого разброса
Техника, предложенная Kettle, опирается на HTTP/2-мультиплексирование. HTTP/2 позволяет отправлять несколько запросов через одно TCP-соединение. Ключевой трюк в том, чтобы упаковать все HTTP/2-фреймы в один TCP-пакет. Тогда сетевой стек сервера получает их атомарно - не “примерно одновременно”, а в одном чтении из сокета.Механика простая:
- Открываем одно HTTP/2-соединение к серверу.
- Формируем 20-30 HTTP/2-запросов как отдельные HEADERS + DATA фреймы.
- Упаковываем все фреймы в один TCP-сегмент размером до примерно 1500 байт.
- Отправляем одним вызовом send().
Для серверов без HTTP/2 Kettle адаптировал last-byte sync: тела запросов отправляются по разным TCP-соединениям, последний байт удерживается, а потом все последние байты досылаются одним пакетом. Это менее точно, чем вариант с HTTP/2, но всё равно на порядок лучше наивного многопоточного подхода.
В оригинальном исследовании 20 запросов из Мельбурна в Дублин, то есть почти через полмира, укладывались в окно менее 1 мс. При last-byte sync разброс составлял уже 4-10 мс.
Ограничение MTU в 1500 байт означает, что в один пакет помещается примерно 20-30 коротких запросов. Если запросы длиннее - большое тело, тяжёлые куки, объёмные заголовки - число запросов на пакет уменьшается. В 2024 году исследователь RyotaK из Flatt Security показал способ обойти этот предел через IP-фрагментацию и пересортировку TCP sequence numbers. Это позволило укладывать в атаку уже тысячи запросов, но там начинается совсем другой уровень сложности и нужен доступ к raw sockets.
Типы race conditions в веб-приложениях
Limit overrun
Самый распространённый и самый приземлённый вариант. Есть лимит: одноразовый купон, максимум три голоса, одно бесплатное начисление. Приложение читает текущее значение из базы, проверяет условие, выполняет действие, а потом обновляет счётчик. При параллельных запросах несколько из них успевают проскочить до обновления.Где это встречается чаще всего:
- применение промокодов и купонов в интернет-магазинах;
- бонусы при регистрации и в реферальных программах;
- голосования и рейтинги;
- лимиты на количество попыток ввода OTP, где race condition превращается в перебор.
Multi-endpoint race
Более сложный вариант: race condition возникает между разными обработчиками. Классический пример - процесс оформления заказа:- POST /cart - добавить товар в корзину.
- POST /cart/checkout - оплатить корзину.
В лабораториях PortSwigger это обычно воспроизводится так: в Burp Repeater создаётся группа вкладок с запросом на добавление товара и запросом на checkout, после чего они отправляются параллельно. Здесь есть ещё один практический нюанс - соединение лучше “прогреть”. Первый запрос в группе может идти чуть дольше из-за холодного открытия внутреннего соединения до базы или промежуточного сервиса. Пустой GET в начале группы часто убирает этот шум.
Single-endpoint race и промежуточные состояния
Самый интересный класс, который Kettle отдельно выделил в своих исследованиях. Один запрос запускает многошаговый процесс на сервере, и внутри него существуют промежуточные состояния, в которые можно попасть вторым запросом к тому же самому обработчику.Классический пример - смена email. Сервер принимает новый адрес, сохраняет его как pending, генерирует токен подтверждения, потом привязывает всё это друг к другу. Если отправить два запроса на смену адреса почти одновременно, можно попасть в ситуацию, где токен от первого запроса окажется связан с адресом из второго. Именно так выглядела найденная Kettle уязвимость в GitLab, зарегистрированная как CVE-2022-4037.
Есть и другой подтип - partial construction race. При регистрации сервер сначала создаёт запись пользователя, а уже потом генерирует и сохраняет токен подтверждения. Между этими двумя шагами существует промежуточное состояние, где токен ещё пустой. Если в этот момент отправить запрос на подтверждение с пустым токеном, часть фреймворков принимает его как корректный.
Когда гонка уже перестаёт быть историей только про HTTP-запросы и начинает упираться в то, как приложение хранит и переключает состояние между сообщениями, полезно посмотреть на соседний класс уязвимостей: "Уязвимости в конфигурациях WebSocket и их эксплуатация". В статье мы рассказали, как в stateful-механике ломаются изоляция, логика сессии и контроль сообщений - и почему race conditions, логические баги и ошибки в хранении состояния часто живут рядом.
Session-based race
Здесь объект гонки - уже не купон и не баланс, а состояние сессии. Два параллельных запроса на вход с разными учётными данными попадают в общий обработчик сессии, и один из запросов может “перетереть” состояние другого. Такое чаще всплывает там, где хранилище сессий построено на общем in-memory-кэше без жёсткой изоляции запросов.Turbo Intruder: race-атаки через скрипты
Turbo Intruder - расширение для Burp Suite от того же James Kettle. Это Jython-движок для отправки HTTP-запросов с тонким контролем над таймингом, соединениями и параллелизмом. Для race conditions здесь важны две вещи: механизм gate и поддержка single-packet attack через Engine.BURP2.Минимальный скрипт для single-packet race выглядит так:
Python:
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2 # HTTP/2 single-packet mode
)
for i in range(20):
engine.queue(target.req, gate='race')
engine.openGate('race')
def handleResponse(req, interesting):
table.add(req)
- engine=Engine.BURP2 включает HTTP/2 и single-packet attack;
- concurrentConnections=1 оставляет одно соединение, внутри которого все запросы мультиплексируются;
- gate='race' удерживает запросы до вызова openGate('race');
- openGate('race') отпускает их сразу, одним пакетом.
Python:
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=20,
engine=Engine.THREADED
)
for i in range(20):
engine.queue(target.req, gate='race')
engine.openGate('race')
Для более сложных сценариев, где запросы идут к разным обработчикам или содержат разные тела, шаблон не меняется по сути: каждый engine.queue() получает свой вариант запроса, но все они привязываются к одному gate.
Burp Repeater: Send group in parallel
Начиная с Burp Suite 2023.9 race conditions можно тестировать без Turbo Intruder - прямо через tab groups в Repeater.Рабочий процесс такой:
- Отправляем интересующий запрос в Repeater.
- Дублируем вкладку 19-20 раз.
- Объединяем вкладки в группу.
- В меню Send выбираем Send group in parallel.
Для multi-endpoint race в группу добавляются разные запросы. При последовательной отправке порядок вкладок имеет значение. При параллельной - все уходят одновременно.
Опция Send group in sequence (single connection) полезна для другой задачи: она показывает обычное время выполнения каждого обработчика по одному соединению. Если один из них стабильно тяжелее, это хороший кандидат на расширенное race window - возможно, там есть доступ к базе, внешний API-вызов или отправка письма.
В Burp Professional начиная с 2024 года есть и более быстрый путь - custom action Trigger race conditions, который запускает параллельную отправку в один клик.
Когда базовая механика single-packet attack уже понятна, следующий шаг - не просто знать одну технику, а держать под рукой нормальный рабочий набор инструментов для веб-тестов. Как раз об этом мы рассказали в материале: "Инструменты для пентеста веб-приложений 2026: что реально работает на engagement’ах". Там Burp Suite, WebSocket, race conditions и практический инструментарий показаны, как рабочая связка для живых проверок.
Кейсы из bug bounty
Race conditions в платёжных сценариях почти всегда уходят в High или Critical. Несколько характерных публичных случаев:
Ссылка скрыта от гостей
. Исследователь нашёл race condition на подтверждении retest и смог получать оплату несколько раз за одну и ту же работу. Причина - отсутствие идемпотентности на обработчике подтверждения. Race window был достаточно широким, чтобы всё работало даже без single-packet attack.
Ссылка скрыта от гостей
. В 2023 году исследователь из Греции нашёл race condition в популярном интернет-магазине. Подарочная карта на €150 применялась дважды и давала уже €300 скидки. Воспроизведение работало через single-packet attack в Repeater: два параллельных запроса на применение одного и того же кода. BugCrowd дал P3 и выплатил $650. Сумма не впечатляет, но при автоматизации ущерб мог бы расти очень быстро.
Ссылка скрыта от гостей
, перехват подтверждения email. Уязвимость в Devise, найденная Kettle. Race condition между двумя запросами на смену адреса позволяла подтвердить чужой email. GitLab оценил её как Medium, сам Kettle считал, что по реальному последствию это ближе к High.Типовые диапазоны выплат тоже довольно показательные: $500-$2 000 за limit overrun с ограниченным влиянием вроде голосов и лайков, $5 000-$10 000 за финансовые race conditions - купоны, баланс, списания, и до $20 000+ за случаи, ведущие к повышению привилегий или обходу аутентификации.
Митигация: что действительно работает
Ограничения на уровне базы данных
Самый надёжный уровень защиты - не давать базе данных принять невалидное состояние.
SQL:
-- Уникальный constraint не даст применить купон дважды
ALTER TABLE coupon_usages
ADD CONSTRAINT uq_coupon_user UNIQUE (coupon_id, user_id);
SQL:
-- Проверка баланса на уровне UPDATE, а не в коде приложения
UPDATE accounts
SET balance = balance - 100
WHERE user_id = 42 AND balance >= 100;
-- Проверяем affected rows: если 0, баланс недостаточен
Блокировки строк
Если бизнес-логика сложнее одного UPDATE, нужна явная блокировка:
SQL:
BEGIN;
SELECT * FROM coupons WHERE code = 'SAVE20' FOR UPDATE;
-- Другие транзакции ждут, пока мы не завершим
-- ... проверяем, применяем, обновляем ...
COMMIT;
Подводный камень здесь один и тот же: длинные транзакции. Если внутри критической секции происходит внешний вызов - платёжный шлюз, отправка письма, HTTP-запрос в другой сервис, - блокировка висит всё это время, а остальные запросы стоят в очереди. Поэтому критическая секция должна быть короткой: проверить, обновить, зафиксировать, и только потом делать всё внешнее.
Распределённые блокировки
В микросервисной архитектуре, где состояние размазано по нескольким сервисам и хранилищам, блокировка на уровне одной БД уже не закрывает задачу. Тогда нужен распределённый лок.Самый частый вариант - Redis с SET key value NX EX ttl:
Python:
lock_key = f"coupon:{coupon_id}:user:{user_id}"
acquired = redis.set(lock_key, "1", nx=True, ex=30)
if not acquired:
return error("Operation in progress")
try:
# ... бизнес-логика ...
finally:
redis.delete(lock_key)
Но здесь сразу начинаются свои проблемы. Если процесс упал между захватом и освобождением, TTL должен быть достаточно длинным, чтобы операция успела завершиться в норме, и достаточно коротким, чтобы не блокировать пользователя слишком долго. В кластерном Redis добавляются вопросы к split-brain и к строгости Redlock-подхода. Для финансовых операций это уже не такая простая история, как кажется в примерах.
ZooKeeper и etcd дают более строгие гарантии, но стоят дороже по сложности и по задержке.
Idempotency keys
Вместо того чтобы запрещать параллельные запросы, можно сделать их безопасными. Клиент генерирует уникальный ключ операции и передаёт его в заголовке:
HTTP:
POST /api/payment HTTP/2
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
- Проверяет, есть ли уже результат для этого ключа.
- Если есть - возвращает сохранённый результат.
- Если нет - обрабатывает запрос и сохраняет результат вместе с ключом.
Stripe, PayPal и другие серьёзные платёжные API поддерживают этот паттерн из коробки. Для внутренних сервисов это тоже хорошая практика на любом обработчике, который меняет финансовое состояние или другой чувствительный ресурс.
Что остаётся за пределами автоматики
Race conditions плохо ловятся SAST, DAST и обычным сканированием. Burp Scanner может отметить подозрительный обработчик, но автоматически доказать race condition не может. Для этого нужно понимать бизнес-логику: что именно считается лимитом, какое поведение считается нормальным и где изменение состояния должно происходить строго один раз.Ручное тестирование сводится к очень приземлённой методике: найти обработчики, меняющие состояние на сервере, понять, какие из них связаны с лимитами и одноразовыми действиями, отправить параллельные запросы и сравнить результат с обычным поведением. Single-packet attack упростил только третий шаг. Первые два по-прежнему требуют головы, а не инструмента.
На стороне защиты универсального рецепта нет. Ограничения на уровне базы данных закрывают простые случаи. Блокировки строк работают внутри одной БД. Распределённые локи добавляют сложность и новые режимы отказа. Idempotency keys защищают от повторов, но не от всех видов гонки. Практическая защита - это комбинация: атомарные операции на уровне хранилища, идемпотентность на уровне API и мониторинг аномалий. Два списания за 50 мс от одного пользователя - это повод для тревоги, а не для второго списания.