Главная / Блог / Race Condition в CTF: эксплуатация гонки на практике

13 мин.00

Race Condition в CTF: эксплуатация гонки на практике

Race Condition в CTF: эксплуатация гонки на практике

Куртка за $1337, купленная за $37.62 двадцатью параллельными POST-запросами к одному эндпоинту — не выдумка, а результат эксплуатации limit overrun race condition в лабе PortSwigger. На CTF гонка состояний встречается в каждом втором веб-таске с бизнес-логикой: двойное применение купона, обход лимитов промокодов, раздувание баланса через параллельные рефанды. Между «я знаю что такое race condition» и «я забрал флаг через race window в 2 мс» — пропасть из конкретных техник, инструментов и понимания того, как сервер обрабатывает конкурентные запросы. Разберём эту пропасть: от механики TOCTOU до рабочих скриптов Turbo Intruder и разбора реальных CTF-тасков.

CWE-362 и TOCTOU: механика гонки состояний в веб-приложениях

Race condition — CWE-362 по классификации MITRE, «Concurrent Execution using Shared Resource with Improper Synchronization». Формулировка из каталога CWE прямая: продукт содержит конкурентную последовательность кода, которая требует временного эксклюзивного доступа к общему ресурсу, но существует временное окно, в которое этот ресурс может быть изменён другой последовательностью, работающей параллельно. Последствия по CWE-362: DoS через потребление CPU, аварийное завершение процесса, утечка данных. Платформы, где CWE-362 чаще всего встречается по данным MITRE: C, C++, Java, Mobile, ICS/OT — но веб-приложения на любом стеке точно так же подвержены этой слабости.

Подтип, который на CTF эксплуатируют чаще всего — TOCTOU (CWE-367, Time-of-Check to Time-of-Use). CWE-367 — дочерний элемент CWE-362, описывает конкретный паттерн: продукт проверяет состояние ресурса перед использованием, но между проверкой и использованием состояние может измениться. Последствия по CWE-367 — нарушение целостности: изменение логики исполнения, модификация данных приложения.

Представьте интернет-магазин в CTF-таске. Два потока на сервере одновременно обрабатывают запросы на покупку последнего товара (stock = 1). Оба читают stock = 1, оба проходят if stock > 0, оба декрементят — финальное значение -1. Ни один поток не увидел запись другого. Промежуток между чтением и записью — race window. Именно в это окно атакующий вклинивает свой второй (третий, двадцатый) запрос.

По классификации OWASP (данные OWASP Tier-1 research), race condition затрагивает сразу несколько категорий OWASP Top 10: A08 (Software and Data Integrity Failures) — TOCTOU и повреждение состояния при конкурентном доступе; A01 (Broken Access Control) — обход проверок прав через гонку; A04 (Insecure Design) — когда архитектура вообще не предусматривает синхронизацию.

В терминах MITRE ATT&CK эксплуатация race condition в веб-приложении ложится на технику Exploit Public-Facing Application (T1190, Initial Access). Последствия — Stored Data Manipulation (T1565.001, Impact) при модификации данных в БД и Financial Theft (T1657, Impact) при double-spend сценариях.

Бизнес-логика атаки: зачем race condition атакующему

Race condition — не техническое упражнение ради красоты. В CTF это путь к флагу, в bug bounty — вектор с финансовым импактом. Мотивация атакующего конкретна.

Double spending — один запрос на оплату порождает два заказа, потому что проверка баланса и списание — два отдельных шага. Атакующий получает товар (или флаг) дважды за одну оплату.

Limit overrun — многократное применение промокода, который должен быть одноразовым. Скидка 20% превращается в скидку 400%, потому что счётчик использований не инкрементирован атомарно.

Account takeover — подмена email или верификация стороннего адреса через гонку в OAuth-потоке. Два одновременных запроса: один на привязку нового email, второй на верификацию — и атакующий получает контроль над чужим аккаунтом.

Privilege escalation — одновременное назначение роли и проверка прав: поток A получает привилегии до того, как поток B завершит валидацию текущего уровня доступа.

На CTF race condition обычно маркируется тасками с темой «магазин», «баланс», «голосование», «рейтинг» — любая операция, где пользователь может повторно влиять на числовое значение. Видите в описании слова «balance», «coupon», «redeem», «transfer» — проверяйте эндпоинты на гонку первым делом.

Многопоточные запросы: single-packet attack и last-byte sync

Главная техническая проблема при эксплуатации race condition — не «отправить много запросов», а доставить их на сервер одновременно. Сетевой jitter — случайные задержки на каждом хопе — размазывает запросы во времени. Race window в 2-5 мс легко пропустить, если запросы приходят с разницей в 50 мс. Для минимизации jitter разработаны две техники.

HTTP/1.1 last-byte synchronization

Принцип: отправляем несколько запросов, удерживая последний байт тела каждого. Веб-сервер не начинает обработку, пока не получит запрос целиком. Когда все запросы «взведены» — одновременно досылаем финальные байты. Влияние сетевых задержек минимизируется: по сети в критический момент уходит всего несколько байт, а не полные тела запросов.

Ограничение: HTTP/1.1 поддерживает один запрос на соединение. Для 20 параллельных запросов нужно 20 TCP-соединений, каждое вносит задержку при установке. По данным Vaadata, метод позволяет доставить до 20 запросов на сервер в пределах ~1 мс — для большинства CTF-тасков хватает за глаза.

HTTP/2 single-packet attack

Техника, описанная Джеймсом Кеттлом (James Kettle, PortSwigger Research) в статье «The single-packet attack: making remote race-conditions local». HTTP/2 поддерживает мультиплексирование — несколько запросов через одно TCP-соединение. Атакующий отправляет фрагменты каждого запроса, удерживая финальный фрагмент. Затем все финальные фрагменты уходят в одном TCP-пакете.

Результат: все запросы приходят на сервер одновременно, полностью исключая сетевой jitter. Удалённая race condition фактически становится локальной. В один пакет реально упаковать 20-30 запросов.

Для CTF single-packet attack — основной метод. Если таск поддерживает HTTP/2 (а большинство современных CTF-серверов поддерживают), шансы на эксплуатацию гонки значительно выше. При HTTP/1.1 last-byte sync работает, но требует больше попыток из-за невозможности полностью устранить jitter.

Эксплуатация race condition через Burp Suite Turbo Intruder

Turbo Intruder: gate-механизм

Turbo Intruder — расширение Burp Suite с Jython-движком, поддерживающее и last-byte sync, и single-packet attack. Ключевая концепция — gate: запросы ставятся в очередь с пометкой и не отправляются до вызова openGate(). Когда все запросы в очереди — gate сбрасывает их одновременно, одним пакетом.

Скрипт для single-packet attack (по данным OWASP и руководства YesWeHack):

def queueRequests(target, wordlists):
    engine = RequestEngine(endpoint=target.endpoint,
                           concurrentConnections=10,
                           engine=Engine.BURP2)
    for i in range(20):
        engine.queue(target.req, gate='race1')
    engine.openGate('race1')

def handleResponse(req, interesting):
    table.add(req)

Разберём параметры — от них зависит, сработает эксплоит или нет:

Engine.BURP2 — включает HTTP/2 single-packet mode. Если цель поддерживает только HTTP/1.1, используйте Engine.THREADED (last-byte sync) или Engine.BURP. Выбор неправильного engine — частая ошибка, из-за которой эксплоит тупо не срабатывает. На CTF видел это раз десять: человек копирует скрипт, запускает — ничего. Потому что сервер на HTTP/1.1, а в скрипте стоит BURP2.

gate='race1' — маркер группы. Все запросы с одинаковым gate отправляются вместе при openGate('race1'). Можно использовать несколько gate для многофазных атак.

range(20) — количество параллельных запросов. Для CTF обычно достаточно 10-30. Для bug bounty может потребоваться 50-100 с повторными попытками, потому что серверная нагрузка и балансировщики добавляют непредсказуемость.

Признак успешного попадания в race window: отрицательный timestamp в ответах Turbo Intruder. Это значит, что сервер начал отвечать до полной отправки запроса — запросы обработались параллельно и наложились друг на друга.

Burp Repeater: Send group in parallel

С 2023 года Burp Suite поддерживает параллельную отправку нативно. Перехватите целевой запрос через Proxy, отправьте в Repeater, создайте tab group с 10-20 дубликатами и выберите «Send group in parallel». Burp отправит все запросы одновременно через last-byte synchronization.

Этот метод проще Turbo Intruder — не нужно писать Jython-скрипты. Но менее гибок: нет HTTP/2 single-packet, сложнее масштабировать до 50+ запросов, нельзя варьировать payload между запросами. Для быстрой проверки гипотезы на CTF — Repeater. Для серьёзной эксплуатации с тонкой настройкой — Turbo Intruder.

Race condition CTF writeup: limit overrun и double spending

Limit overrun: куртка за $37.62

Разбор основан на лабораторной задаче PortSwigger «Limit overrun race conditions», описанной в руководстве YesWeHack.

Условие: баланс $50, в магазине «Lightweight L33t Leather Jacket» за $1337. Промокод PROMO20 на 20% скидку. Одно применение даёт цену $1069.60 — далеко за пределами бюджета.

Эксплуатация — делай раз, делай два, делай три:

  1. Добавьте куртку в корзину. Примените PROMO20 штатным способом — убедитесь, что эндпоинт работает и вы понимаете формат запроса.
  2. Перехватите POST-запрос применения купона через Burp Proxy (обычно POST /cart/coupon с телом csrf=...&coupon=PROMO20). Отправьте в Turbo Intruder.
  3. Настройте скрипт: engine=Engine.BURP2, range(20), gate race1. Запустите атаку.
  4. Проверьте корзину — скидка должна быть применена многократно.

Результат: промокод применяется ~20 раз. Итоговая скидка — $1299.38, финальная цена — $37.62. Флаг получен.

Почему работает: race window находится между SQL-операциями SELECT used FROM coupons WHERE code='PROMO20' (возвращает false для всех 20 запросов, потому что ни один ещё не записал true) и UPDATE coupons SET used=true WHERE code='PROMO20' (выполняется только после SELECT). Все 20 запросов проходят проверку, все 20 применяют скидку. Проверка и действие — не атомарная операция. Вот и вся фундаментальная причина.

Double spending через WebSocket: m0leCon CTF 2023

На m0leCon CTF 2023 задача «goldinospizza2» использовала WebSocket (по данным writeup'а). Стартовый баланс — 30 евро, цель — купить «GOLDEN Pizza» стоимостью значительно дороже.

В исходном коде были функции order (уменьшает баланс на price * quantity) и cancel (увеличивает баланс обратно — рефанд). Обе работали с общим балансом в БД без блокировок. Красный флаг размером с футбольное поле.

Эксплуатация: одновременная отправка множества order-запросов через WebSocket. Каждый запрос читал текущий баланс (30 евро), проходил balance >= price, создавал заказ. Все запросы видели одно значение баланса — деньги списывались некорректно, создавалось больше заказов, чем баланс позволял. Дальше — массовый cancel, рефанд за каждый заказ. Баланс растёт экспоненциально: 30 → 60 → 120 → ... до покупки GOLDEN Pizza.

Момент для CTF-игроков: WebSocket — stateful-протокол. Turbo Intruder для WebSocket не подходит напрямую. В оригинальном writeup автор запускал JavaScript-скрипт из DevTools браузера, который открывал WS-соединение и отправлял 100 order-сообщений в цикле без ожидания ответа — все сообщения попадали в серверный event loop практически одновременно. Грязно, но работает.

Совмещение несовместимых купонов: реальный пентест

По данным Vaadata, на реальном аудите e-commerce платформы два разных купона нельзя было совместить. При последовательной отправке сервер отвечал: первый купон — 200 OK с текстом «Coupon applied», второй — 500 Internal Server Error с «One coupon already applied».

При одновременной отправке двух запросов POST /redeem_coupon (один с Coupon=Holidays20, другой с Coupon=Christmas10) через single-packet attack — оба возвращали 200 OK. Проверка «сколько купонов в корзине» возвращала 0 для обоих запросов, потому что ни один ещё не был записан в БД на момент проверки. Оба купона успешно применялись.

Тот же паттерн переносится на CTF-таски: если есть ограничение «одно применение» — пробуйте параллельную отправку. Ограничение реализовано как check-then-act, а не как атомарная операция с уникальным constraint.

Race condition в реальных CVE: GitLab OAuth

Гонки состояний — не академическая тема и не исключительно CTF-вектор. CVE-2022-4037 в GitLab CE/EE — наглядный пример.

Затронутые версии: все до 15.5.7, серия 15.6 до 15.6.4, серия 15.7 до 15.7.2.

Суть: race condition позволяла подделать верифицированный email и захватить аккаунт на сторонних сервисах при использовании GitLab как OAuth-провайдера.

Технические данные (NVD): CVSS 6.4 (MEDIUM), вектор CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N — атака по сети, низкая сложность, нужны минимальные привилегии (авторизованный пользователь), без участия жертвы. Scope: Changed — уязвимость бьёт за пределами самого GitLab (на сторонние сервисы, использующие GitLab OAuth). Слабость: CWE-362.

Механизм: два одновременных запроса к GitLab OAuth — один на привязку email, второй на верификацию. Из-за отсутствия блокировки между проверкой «этот email свободен» и действием «привязать email» атакующий мог привязать чужой верифицированный email к своему аккаунту. Классический TOCTOU.

По данным CISA (SSVC decision: Track), активная эксплуатация в дикой природе не зафиксирована, автоматизация атаки невозможна (Automatable: no). EPSS — 0.0064 (percentile 0.4956, ниже медианы). Но для OAuth-цепочки импакт серьёзен: захват аккаунтов на всех сервисах, делегирующих аутентификацию GitLab.

Для CTF-контекста CVE-2022-4037 интересен как паттерн: race condition не обязательно про деньги. Одновременная смена email на двух аккаунтах, одновременная верификация двух адресов через один токен — те же задачи встречаются на соревнованиях.

Как искать race condition: чек-лист для CTF-таска

Признаки уязвимого эндпоинта

Перед запуском Turbo Intruder проверьте четыре условия:

  1. Shared state — баланс, инвентарь, счётчик применений купона, голоса. Если данные в БД и несколько запросов могут их изменить — потенциальная гонка.
  2. Неатомарный check-then-act — в исходниках (если даны) ищите SELECT → проверка в коде → UPDATE. Два SQL-запроса вместо одного атомарного — красный флаг.
  3. Отсутствие блокировок — нет SELECT ... FOR UPDATE, нет UNIQUE constraints, нет мьютексов (threading.Lock() в Python, sync.Mutex в Go, synchronized в Java). Если ничего из этого не найдено рядом с критической операцией — синхронизации нет.
  4. Операция с побочным эффектом — не чтение, а запись: списание, применение купона, создание заказа. Read-only эндпоинты к race condition не уязвимы (за исключением timing-атак на перечисление).

Паттерны уязвимого кода

По чек-листу OWASP:

Check-then-act в Python: if user.balance >= amount: user.balance -= amount — две операции, race window между ними. В Node.js: const balance = await getBalance(userId); if (balance >= amount) { await deductBalance(userId, amount); } — два await, между которыми другой запрос может вклиниться.

SQL без атомарности — самый распространённый паттерн в CTF:

-- Уязвимо: два запроса, race window между ними
SELECT qty FROM inventory WHERE id = 1;
UPDATE inventory SET qty = qty - 1 WHERE id = 1;

-- Безопасно: атомарный условный декремент
UPDATE inventory SET qty = qty - 1 WHERE id = 1 AND qty > 0;

Второй вариант — единый запрос: проверка qty > 0 и декремент выполняются атомарно внутри СУБД. Race window исчезает. Если в исходниках CTF-таска видите первый вариант — вы нашли вектор.

Filesystem TOCTOU (CWE-367): if os.path.exists(path): os.remove(path) — между exists() и remove() атакующий может подменить файл на symlink. В CTF встречается реже, но в задачах на pwn и misc — вполне.

Инструменты автоматизации

Для обнаружения TOCTOU в исходниках — Semgrep с правилом toctou-filesystem-check-then-act (severity: WARNING, confidence: HIGH, CWE-367). Правило ловит конструкции os.path.exists() / os.path.isfile() / os.access() с последующим os.remove() или open().

Для тестирования веб-эндпоинтов — Nuclei с community-шаблонами: nuclei -t vulnerabilities/generic/ -tags race-condition -u https://target. Шаблоны отправляют параллельные запросы и анализируют ответы на признаки двойного срабатывания.

Для Go-сервисов — встроенный race detector: go test -race ./... или go run -race main.go. Ловит data races на этапе тестирования.

Митигация: как разработчики закрывают race window

Понимание защиты — часть навыка CTF-игрока. Если видите в исходниках одну из этих конструкций, race condition скорее всего закрыта, и стоит искать другой вектор.

Уровень БД (самый надёжный): атомарные условные операции (UPDATE ... SET qty = qty - 1 WHERE qty > 0 вместо SELECT + UPDATE); блокировка строк SELECT ... FOR UPDATE; уникальные constraints UNIQUE(user_id, coupon_id) — БД сама отклонит дубликат.

Уровень приложения: мьютексы и блокировки (threading.Lock() в Python, sync.Mutex в Go); distributed locks через Redis (SET key value NX EX ttl) для горизонтально масштабируемых сервисов; идемпотентные ключи — каждый запрос содержит уникальный idempotency_key, сервер обрабатывает запрос с одним ключом ровно один раз.

Архитектурный уровень: queue-based processing — все операции с критическим ресурсом проходят через очередь, исключая параллелизм; optimistic locking с version check: UPDATE ... SET qty = qty - 1, version = version + 1 WHERE id = 1 AND version = 42 — если кто-то успел изменить version, UPDATE не применится.

Если рядом с операциями записи баланса, применения купонов или голосования ни одного из этих механизмов нет — пробуйте race condition.

Гонка состояний — одна из тех уязвимостей, где 90% работы не в написании эксплоита, а в понимании модели состояний приложения. Часто наблюдаю одну и ту же ошибку у новичков на CTF: сразу хватаются за Turbo Intruder и начинают засыпать эндпоинт сотней запросов, не разобравшись, где именно race window и какие два запроса должны столкнуться. Без модели состояний это brute-force по всей поверхности атаки, который срабатывает только случайно. Правильный подход — нарисовать диаграмму переходов состояний (хотя бы в голове): промокод не применён → проверка валидности → проверка лимита → пересчёт корзины → промокод применён. Гонка возникает на конкретном переходе между «проверка лимита» и «промокод применён», и если этот переход не найден — количество запросов не поможет. Чрезмерная нагрузка убивает таск-сервер и создаёт DoS вместо эксплуатации. На WAPT эту цепочку «модель состояний → нахождение race window → точечная эксплуатация» разбирают в двух модулях с лабами, где каждый шаг отрабатывается отдельно — от анализа исходников до финального флага.

🚀 Хочешь закрепить на практике? Реши задачи по теме на HackerLab — категория «pentest-machines».

Поделиться

0 комментариев

Пожалуйста, войдите, чтобы оставить комментарий.

Загрузка комментариев...

Читайте также

Ghidra для начинающих: решаем crackme без кода

12 мин.

5

Ghidra для начинающих: решаем crackme без кода

Пошаговый разбор crackme в Ghidra: от команды file до извлечения XOR-ключа из декомпилятора. Контрольные точки на каждом шаге анализа бинарника.

18 ОКТЯБРЬ, 2026

Pwntools для начинающих: первый эксплойт CTF

12 мин.

4

Pwntools для начинающих: первый эксплойт CTF

Пошаговый разбор ret2win эксплойта на pwntools: checksec, cyclic, p64, GDB-отладка. Код, команды и типичные ошибки — всё для первого pwn-таска на CTF.

17 ОКТЯБРЬ, 2026

Деобфускация вредоносных скриптов: PS и JS в CTF

11 мин.

3

Деобфускация вредоносных скриптов: PS и JS в CTF

Пошаговый разбор обфусцированных PowerShell и JavaScript пейлоадов: base64-цепочки, IEX, eval, CyberChef-рецепты и чек-лист инструментов. От артефакта до флага.

17 ОКТЯБРЬ, 2026