
В оригинальном исследовании PortSwigger Research 20 HTTP/2-запросов, отправленных из Мельбурна в Дублин — через полмира — укладываются в окно менее 1 мс. Single-packet attack превращает race condition из непредсказуемого бага в воспроизводимую атаку с первой попытки. Звучит красиво. А потом на CTF ты копируешь скрипт Turbo Intruder из чужого writeup, запускаешь — и ноль попаданий в race window. Неправильный engine, session locking на стороне PHP, холодное соединение — и 20 запросов приходят последовательно вместо параллельно. Разберём race condition эксплуатацию от механики TOCTOU до рабочих скриптов и чек-листа дебага, который экономит часы на CTF.
Race condition (CWE-362 по каталогу MITRE) — конкурентная последовательность кода, которая требует временного эксклюзивного доступа к общему ресурсу, но существует окно, в котором другой поток этот ресурс модифицирует. По CWE-362 последствия: DoS через потребление CPU, аварийное завершение процесса, утечка данных. Платформы — C, C++, Java, Python, Mobile, но веб-приложения на любом стеке подвержены точно так же.
Подтип TOCTOU (CWE-367, Time-of-Check to Time-of-Use) — дочерний элемент CWE-362 — описывает конкретный паттерн: сервер проверяет состояние ресурса перед использованием, но между проверкой и использованием состояние успевает измениться. По CWE-367 последствия — нарушение целостности: изменение логики исполнения, модификация данных приложения.
На практике это выглядит просто. Сервер получает POST /apply-coupon, читает из базы SELECT used FROM coupons WHERE code = ?, видит used = false, применяет скидку и только потом делает UPDATE coupons SET used = true. Между SELECT и UPDATE — race window. Если в это окно влетает второй запрос, он тоже видит used = false и проходит проверку. Одноразовый купон применяется столько раз, сколько запросов проскочили до обновления.
Размер окна зависит от архитектуры. Монолит с одним потоком — почти нулевой зазор. Приложение на gunicorn с несколькими воркерами — конкуренция между процессами уже реальна. Микросервисная схема, где проверка баланса идёт через один сервис, а списание через другой с промежуточным HTTP-вызовом, расширяет окно до десятков миллисекунд. На CTF обычно стоит Flask, Django или Express с несколькими потоками — окно в единицы миллисекунд, и single-packet attack попадает в него стабильно.
Бизнес-логика эксплуатации race condition сводится к трём сценариям. Double spending — один запрос на оплату порождает два заказа. Limit overrun — промокод применяется многократно. Account takeover — гонка в потоке смены email или OAuth-авторизации. В терминах MITRE ATT&CK эксплуатация race condition в веб-приложении ложится на технику Exploit Public-Facing Application (T1190, Initial Access). Последствия — Stored Data Manipulation (T1565.001, Impact) при модификации данных в БД и Financial Theft (T1657, Impact) при double-spend.
Главная проблема эксплуатации гонки состояний — не «отправить много запросов», а доставить их на сервер одновременно. Сетевой jitter — случайные задержки на каждом хопе — размазывает запросы во времени. Race window в 2 мс легко пропустить, если запросы приходят с разницей в 50 мс. До 2023 года эксплуатация race condition выглядела как лотерея: открываем двадцать потоков, из каждого шлём HTTP-запрос, надеемся на лучшее. Иногда срабатывало. Чаще — нет.
HTTP/2 single-packet attack. Техника от James Kettle из PortSwigger Research, представленная на Black Hat USA 2023 (whitepaper «Smashing the state machine»). HTTP/2 позволяет мультиплексировать несколько запросов через одно TCP-соединение. Ключевой трюк — упаковать все HTTP/2-фреймы (HEADERS + DATA) в один TCP-пакет размером до ~1500 байт (ограничение MTU). Сетевой стек сервера получает их атомарно — не «примерно одновременно», а в одном чтении из сокета. По данным PortSwigger, в один пакет помещается 20-30 коротких запросов. Сетевой jitter устраняется полностью: разница во времени доставки — ноль.
HTTP/1.1 last-byte synchronization. Для серверов без HTTP/2. Все запросы отправляются по отдельным TCP-соединениям без последнего байта тела. Соединения удерживаются открытыми. Затем последние байты досылаются одновременно. Сервер начинает обработку только после получения полного тела — jitter влияет только на доставку одного байта через уже установленное соединение. В оригинальном исследовании last-byte sync давал разброс 4-10 мс против менее 1 мс у single-packet attack. Для CTF чаще всего достаточно, но требует больше попыток и больше TCP-соединений (одно на каждый запрос).
Burp Suite начиная с одной из версий 2023 года автоматически выбирает технику при параллельной отправке: HTTP/2 — single-packet attack, HTTP/1 — last-byte sync. Turbo Intruder даёт явный контроль через параметр engine.
Почему 20-30 запросов, а не 2? Серверный jitter (GC-паузы, блокировки пула потоков, кэш-промахи) никуда не девается. Чем больше запросов в пачке, тем выше шанс, что несколько из них попадут в race window одновременно. Для разведки — 20-30. Когда поведение подтверждено и нужен чистый эксплойт — сокращаем до 2-5.
На CTF race condition обычно прячется в тасках с темой «магазин», «баланс», «голосование», «загрузка файлов». Видите в описании слова «balance», «coupon», «redeem», «transfer», «upload» — проверяйте эндпоинты на гонку первым делом. По классификации OWASP, race condition затрагивает A04 (Insecure Design) — когда архитектура не предусматривает синхронизацию, A01 (Broken Access Control) — обход проверок прав через гонку, A08 (Software and Data Integrity Failures) — TOCTOU и повреждение состояния при конкурентном доступе.
Самый распространённый и самый приземлённый тип. Есть лимит: одноразовый купон, максимум три голоса, одно бесплатное начисление. Приложение читает значение из базы, проверяет условие, выполняет действие, обновляет счётчик. При параллельных запросах несколько из них проскакивают до обновления. По данным PortSwigger, limit overrun — подтип TOCTOU-уязвимостей.
Вариации в CTF и bug bounty: многократное погашение подарочной карты, обход rate limit на ввод OTP (гонка превращается в перебор), повторное использование ответа CAPTCHA, многократное голосование в рейтинге, вывод средств сверх баланса.
Эксплуатация прямолинейна: перехватите запрос на применение купона, отправьте 20 копий параллельно через single-packet attack, проверьте баланс. Скидка применилась больше одного раза — race window существует. Всё, можно копать дальше.
Более тонкий тип, который по данным PortSwigger Research относится к hidden multi-step sequences. Один запрос запускает многошаговый процесс на сервере, внутри которого существуют промежуточные состояния — sub-states. Второй запрос к тому же эндпоинту попадает в промежуточное состояние и модифицирует данные до завершения первого.
По данным NVD: CVSS 6.4 (MEDIUM), вектор CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N, CWE-362. Затрагивало все версии GitLab до 15.5.7, от 15.6 до 15.6.4 и от 15.7 до 15.7.2. Механика: два одновременных запроса на смену email. Первый устанавливает pending_email = "attacker@evil.com", второй перезаписывает на pending_email = "victim@target.com". Когда сервер берёт pending_email для генерации токена — там уже адрес жертвы. Атакующий получает на свою почту токен подтверждения для чужого email. Это ведёт к подделке верифицированных email и захвату аккаунтов при использовании GitLab как OAuth-провайдера. По данным CISA (SSVC Decision: Track), эксплуатация не автоматизируема, технический импакт partial.
Отдельный подтип — partial construction race. При регистрации сервер сначала создаёт запись пользователя, потом генерирует и сохраняет токен подтверждения. Между этими шагами токен ещё пустой. Если отправить запрос на подтверждение с пустым значением токена в этот момент — часть фреймворков примет его как корректный. По данным PortSwigger, partial construction — один из наименее изученных подтипов. Составители CTF его обожают — именно потому, что мало кто про него знает.
Гонка состояний между разными обработчиками, работающими с общими данными. CTF-сценарий: один эндпоинт добавляет товар в корзину, другой инициирует оплату. Если запрос на оплату отправить одновременно с изменением корзины, сервер может обработать оплату по старой цене с новым содержимым. Многопоточная атака на веб-приложение с multi-endpoint race усложняется тем, что разные эндпоинты обрабатываются с разной скоростью — нужны дополнительные приёмы синхронизации (connection warming, padding запросов).
Отдельный и крайне практичный CTF-сценарий — загрузка файлов через race window. Сервер принимает файл, сохраняет его во временную директорию, проверяет содержимое по whitelist расширений и удаляет, если проверка не пройдена. Между сохранением и удалением — race window. Атакующий загружает web shell через POST /upload и одновременно шлёт несколько GET-запросов к пути загрузки — если хоть один GET попадает в окно до удаления, shell исполняется. На PortSwigger Academy этому посвящена отдельная лаба «Web shell upload via race condition».
CVSS 8.1 (HIGH), вектор CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H, CWE-362. По описанию NVD, неаутентифицированный атакующий мог выполнить произвольный код через race condition во временной директории, когда разработчик позволял пользователю задавать имя файла в методе fromData. Публичные PoC-эксплойты в открытых базах (Exploit-DB) не зафиксированы; сценарий ниже — иллюстрация общей техники file-upload race, а не точное воспроизведение эксплуатации этого CVE. По данным CISA (SSVC Decision: Track), эксплуатация не автоматизируема, но технический импакт total — при попадании в race window атакующий получает полный контроль. Маркер AC:H (Attack Complexity: High) в CVSS-векторе — характерный признак race condition: эксплуатация зависит от точного тайминга.
Turbo Intruder — расширение Burp Suite с Jython-движком от James Kettle. Ставится через BApp Store. Ключевая концепция для race condition — gate: запросы ставятся в очередь с пометкой и не отправляются до вызова openGate(). Когда все запросы в очереди — gate сбрасывает их одновременно, одним пакетом.
Сценарий 1: limit overrun. Перехватите запрос POST /apply-coupon через Burp Proxy, правый клик → Extensions → Turbo Intruder → Send to Turbo Intruder. В нижней панели замените скрипт:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2)
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
engine.complete(timeout=60)
def handleResponse(req, interesting):
table.add(req)
Разбор параметров. engine=Engine.BURP2 включает HTTP/2 single-packet mode — все 20 запросов уходят в одном TCP-пакете. Но тут есть подвох: это работает только если соединение с target действительно идёт по HTTP/2 (ALPN h2). Проверьте протокол в колонке «Protocol» в Burp HTTP History — если там HTTP/1.1, Turbo Intruder либо выдаст ошибку, либо тихо откатится на HTTP/1.1-семантику без single-packet attack. concurrentConnections=1 — одно TCP-соединение, все запросы через мультиплексирование. gate='race1' — запросы копятся и не уходят на сервер до вызова openGate('race1'). Шаблон race-single-packet-attack.py лежит в стандартной директории примеров Turbo Intruder.
А теперь самое частое место, где всё ломается. Если сервер поддерживает только HTTP/1.1, замените Engine.BURP2 на Engine.THREADED и увеличьте concurrentConnections до 10-30. При BURP2 на HTTP/1.1-сервере запросы уйдут последовательно — я это наблюдаю на CTF постоянно. Игрок копирует скрипт из writeup, запускает, получает 20 последовательных «coupon already used» и думает, что race condition нет. А проблема — в engine.
Сценарий 2: загрузка файла через race window. Сервер удаляет загруженный файл через миллисекунды после проверки. Нужно одновременно отправить POST с web shell и несколько GET к пути загрузки:
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=10)
# request1 и request2 — скопируйте raw-запросы из Burp HTTP History целиком,
# сохранив точный Content-Length и multipart boundary, иначе сервер вернёт 400.
request1 = '''POST /upload HTTP/1.1\r\nHost: target...'''
request2 = '''GET /files/shell.php HTTP/1.1\r\nHost: target...'''
engine.queue(request1, gate='race1')
for x in range(5):
engine.queue(request2, gate='race1')
engine.openGate('race1')
engine.complete(timeout=60)
def handleResponse(req, interesting):
table.add(req)
В request1 подставляете полный POST-запрос с multipart-телом (скопируйте из Burp HTTP History), в request2 — GET к пути, куда файл загружается. Из шести запросов (один POST + пять GET) достаточно, чтобы хотя бы один GET попал в окно между сохранением файла и его удалением. По данным лабы PortSwigger, при 5 GET-запросах попадание происходит в 2-3 из них.
Сценарий 3: multi-endpoint race. Для чередования запросов к разным эндпоинтам модифицируйте тело запроса перед engine.queue(), подставляя нужный path или параметры. Для тонкой настройки тайминга между группами используйте time.sleep() в Jython — полезно, когда нужно подстроить порядок обработки на сервере.
Для проверки гипотезы о race condition не обязательно писать Jython-скрипт. В современных версиях Burp Suite (начиная с 2023 года) в Repeater есть нативная параллельная отправка. Перехватите целевой запрос, отправьте в Repeater, продублируйте вкладку 10-20 раз через Ctrl+R, создайте tab group и выберите «Send group in parallel». Burp автоматически применяет single-packet attack для HTTP/2 или last-byte sync для HTTP/1.
Метод проще Turbo Intruder, но менее гибок: нельзя варьировать payload между запросами, сложнее масштабировать свыше 30 запросов, нельзя чередовать запросы к разным эндпоинтам. Для первичной разведки limit overrun — за глаза хватает.
Практический нюанс: первый запрос в группе иногда идёт медленнее из-за холодного соединения к бэкенду. По данным PortSwigger, пустой GET к корневому пути в начале tab group (connection warming) убирает этот шум и выравнивает тайминг внутри группы.
Race condition не сработала с первого раза — не значит, что её нет. Прогоняю этот чек-лист на каждом CTF-таске перед тем, как отбросить гипотезу.
Проверьте engine. Если сервер на HTTP/1.1, а в скрипте стоит Engine.BURP2 — запросы не упакуются в один пакет. Определите протокол через curl -vvv или по заголовкам ответа. HTTP/2 — Engine.BURP2 + concurrentConnections=1. HTTP/1.1 — Engine.THREADED + concurrentConnections=10-30. Для HTTP/1.1 также доступен Engine.BURP, который использует last-byte sync через нативный движок Burp.
Обойдите session locking. PHP по умолчанию сериализует все запросы с одним session_id через блокировку файла сессии. Двадцать параллельных запросов с одной cookie обрабатываются строго последовательно — никакого race window. По данным HackTricks, решение — для каждого запроса использовать разные session token, если логика позволяет. Другие фреймворки с session-based locking (некоторые реализации Java Servlet, Django с database-backed sessions) ведут себя аналогично. На CTF обычно можно залогиниться в нескольких сессиях одновременно — просто откройте инкогнито и получите второй session_id.
Прогрейте соединение. Первый запрос в серии часто идёт медленнее: сервер поднимает соединение к базе, инициализирует пул потоков, прогревает кэш. Добавьте «холостой» GET-запрос к корневому пути перед основной группой. В Turbo Intruder — один engine.queue() без gate перед циклом с gate.
Повторите 5-10 раз. Серверный jitter (GC-паузы, блокировки пула потоков, кэш-промахи) сдвигает обработку непредсказуемо. Три неудачные попытки — не приговор. На CTF обычно хватает 3-5 попыток для подтверждения, но для сложных race condition с узким окном — до 10.
Анализируйте response timing. Признак попадания в race window в Turbo Intruder — отрицательный timestamp в колонке времени ответа. Это значит, что сервер начал отвечать до полной отправки запроса — запросы обработались параллельно и наложились друг на друга. Другой маркер — одинаковые success-ответы на запросы, где один должен был получить ошибку «купон уже использован» или «insufficient balance».
Увеличьте количество запросов. Если 20 запросов не попадают в race window, попробуйте 30. Для HTTP/2 single-packet attack ограничение — размер TCP-пакета (~1500 байт). Если запросы длинные (тяжёлые cookie, объёмные заголовки), в один пакет влезает меньше. Сокращайте заголовки до минимума или разбивайте на несколько wave.
По данным PortSwigger Research, методология эксплуатации race condition строится на трёх этапах: predict, probe, prove.
Predict — предскажите коллизию. Ищите эндпоинты, где два или более запроса работают с одними и теми же данными. Маркеры в CTF-тасках: операции с балансом, купонами, лимитами попыток, статусом заказа, email пользователя, загрузкой файлов. Отдельно обращайте внимание на hidden multi-step sequences: один HTTP-запрос запускает на сервере цепочку действий с промежуточными состояниями. Смена email, генерация OTP, регистрация пользователя — всё это многошаговые процессы, где каждый шаг создаёт sub-state, в который можно вклиниться.
Практический приём: изучите все эндпоинты, которые работают с одним ресурсом (одна таблица в БД, один файл, одна сессия). Если два эндпоинта читают и пишут один ресурс — это потенциальная multi-endpoint race.
Probe — прощупайте. Отправьте группу из 20-30 параллельных запросов и анализируйте ответы. Ищите аномалии: несколько success-ответов там, где ожидался один success и 19 ошибок; изменённые числовые значения (баланс, количество); ответы с разным содержимым при идентичных запросах. На этом этапе не нужен готовый эксплойт — нужно подтверждение, что race window существует.
Prove — докажите. Сократите количество запросов до минимума, воспроизведите аномалию стабильно. Для CTF — забирайте флаг. Для bug bounty — документируйте с воспроизводимым скриптом Turbo Intruder.
Предусловия и ограничения для каждого типа race condition:
| Тип | Протокол | Минимум запросов | Характерная сложность |
|---|---|---|---|
| Limit overrun | HTTP/2 или HTTP/1.1 | 10-20 | Низкая: один эндпоинт, один payload |
| Single-endpoint collision | HTTP/2 предпочтительнее | 2-5 (разные payload) | Средняя: нужно понять sub-states |
| Multi-endpoint race | HTTP/1.1 или HTTP/2 | 2-10 к разным путям | Высокая: синхронизация между эндпоинтами |
| File upload race | HTTP/1.1 (чаще) | 1 POST + 5 GET | Средняя: нужен точный путь загрузки |
Формула на бумаге понятна, но race window по-настоящему ощущается только когда сам прогоняешь запросы и видишь, как один лишний GET попадает в зазор между upload и delete. Готовый стенд для отработки есть на HackerLab — на платформе 12 категорий задач (web, pwn, forensics, crypto и другие), где можно найти таски на гонку состояний в разделе web; для доступа нужна регистрация.
Race condition — не учебная уязвимость. Два подтверждённых CVE из NVD показывают спектр последствий.
Гонка при смене email ведёт к подделке верифицированного адреса и захвату сторонних аккаунтов через OAuth. Затрагивало все версии до 15.5.7, от 15.6 до 15.6.4 и от 15.7 до 15.7.2. Обратите внимание на AC:L (Attack Complexity: Low) в CVSS-векторе — техническая сложность единичной эксплуатации оценена как низкая. При этом CISA (SSVC Decision: Track) оценивает уязвимость как не автоматизируемую (Automatable: no) — это не противоречие: AC:L описывает сложность одной атаки, а Automatable оценивает возможность массовой/вормообразной эксплуатации без участия оператора. EPSS 0.0066 (0.66%) — абсолютная вероятность эксплуатации низкая, хотя percentile 0.5157 чуть выше медианы среди всех CVE в базе. Публичные PoC-эксплойты для этого CVE в открытых базах (Exploit-DB) не зафиксированы.
RCE через race condition во временной директории. Тут AC:H (Attack Complexity: High) — race window узкий, нужен точный тайминг. Исправлено в версии 1.0.476. По данным CISA, технический импакт total — при попадании атакующий получает полный контроль. EPSS 0.0146 (1.46%), percentile 0.7370 — выше медианы среди CVE, но абсолютная вероятность эксплуатации остаётся низкой.
Контраст между AC:L у GitLab и AC:H у October CMS — хорошая иллюстрация. Не все race condition одинаковы: single-endpoint collision с широким race window эксплуатируется стабильно, file-based race с узким окном требует десятков попыток. На CTF составители обычно делают race window достаточно широким, чтобы 20 параллельных запросов попадали стабильно, но в bug bounty и на проде — готовьтесь к AC:H.
Есть устойчивое заблуждение, что race condition — это «отправить много запросов и надеяться». После появления single-packet attack в 2023 году это уже не стохастический процесс: при правильной настройке engine и протокола попадание в race window детерминировано с первой попытки. Проблема сместилась от «как доставить запросы одновременно» к «как найти скрытые sub-states в бизнес-логике». На каждом CTF вижу одну и ту же картину: игроки тратят часы на подбор количества запросов, тогда как стоило потратить 10 минут на анализ — какие эндпоинты работают с общими данными и где между check и use находится разрыв. Predict-probe-prove — не академическая методология, а реальный workflow, который экономит часы.
Мой самый частый промах — пропустить session locking. Второй по частоте — не проверить протокол сервера и оставить Engine.BURP2 при HTTP/1.1. Оба фиксятся за минуту, когда знаешь, куда смотреть. Если хочешь не просто отдельный writeup, а системно проходить цепочки атак от limit overrun до hidden substates — на WAPT эту прогрессию выстраивают в течение модулей с лабами.
🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».
0 комментариев
Пожалуйста, войдите, чтобы оставить комментарий.
Загрузка комментариев...