Главная / Блог / Race condition эксплуатация: гайд для CTF

12 мин.00

Race condition эксплуатация: гайд для CTF

Race condition эксплуатация: гайд для CTF

На прошлогоднем CTF веб-таск с магазином провисел без решений полтора часа. Все команды ковыряли SQLi в поиске товаров и XSS в корзине. А решилось за три минуты: 20 параллельных запросов на применение промокода через Burp Repeater, баланс улетел в минус, куртка за $1337 обошлась в $37, флаг в кармане. Race condition эксплуатация не требует хитрых payload'ов, не ловится автоматическими сканерами — и при этом встречается в каждом втором веб-таске, где есть бизнес-логика с ограничениями.

Race window и TOCTOU: что ломается на бэкенде

Состояние гонки в веб-приложении возникает, когда сервер обрабатывает несколько HTTP-запросов параллельно и они одновременно лезут к одним данным без синхронизации. Результат зависит от порядка выполнения потоков — порядка, который разработчик не контролирует.

Центральное понятие — race window. Промежуток времени между двумя операциями с общим ресурсом, в течение которого возможна коллизия. В типичном веб-приложении race window возникает между проверкой условия (Time of Check) и выполнением действия (Time of Use) — паттерн TOCTOU, классика CWE-362.

Вот как выглядит уязвимый код — встречается и в CTF-тасках, и в продакшене:

# Time of Check — проверяем
coupon = db.query("SELECT used FROM coupons WHERE code=?", code)
if coupon.used:
    return "Купон уже использован"
# ← Race window: параллельный поток тоже видит used=false
# Time of Use — действуем
db.execute("UPDATE coupons SET used=1 WHERE code=?", code)
apply_discount(order)

Отправляешь 20 запросов одновременно — все 20 потоков выполняют SELECT и видят used = false, потому что ни один ещё не добрался до UPDATE. Все 20 проходят проверку и применяют скидку. Race window здесь — микросекунды между SELECT и UPDATE. Single-packet attack позволяет попасть в это окно с высокой вероятностью.

Зачем атакующему race condition. Зависит от контекста. Двойное списание бонусов и подарочных карт — прямая монетизация. Обход rate-limiting при брутфорсе паролей или OTP — эксплуатация публично доступного приложения (T1190, Initial Access). Подмена email для захвата чужого аккаунта — получение доступа к легитимным учётным данным (Valid Accounts, T1078). Манипуляция балансами — Stored Data Manipulation (T1565.001, Impact). В CTF race condition — путь к флагу через уязвимости бизнес-логики, где прямые инъекции бесполезны.

Три типа race condition в CTF-тасках

По исследованию PortSwigger «Smashing the state machine» (Black Hat USA 2023), race condition в веб-приложениях делится на три типа. В CTF все три попадаются регулярно.

Limit Overrun — обход одноразовых ограничений

Самый частый тип — и в CTF, и в Bug Bounty. Приложение ставит лимит (одноразовый купон, максимум попыток входа, один голос на пользователя), но проверка и обновление счётчика не атомарны. Между ними — TOCTOU-окно.

Где искать limit overrun в CTF-тасках:

  • Применение промокодов и купонов (обход ограничения «использовать один раз»)
  • Вывод средств и переводы между счетами (double-spending)
  • Активация пробных подписок, trial-периодов, invite-кодов
  • Голосование с ограничением «один раз на пользователя»
  • Обход rate-limit при сбросе пароля или вводе OTP
  • Повторное использование CAPTCHA-решения

Limit overrun — подтип TOCTOU-уязвимостей. Приложение проходит через временное промежуточное состояние (sub-state): входит в него, когда начинает обработку первого запроса, и выходит, когда обновляет базу. Все параллельные запросы, попавшие в это окно, обработаются так, будто лимит ещё не исчерпан.

На практике для эксплуатации обычно хватает 20-30 параллельных запросов. В реальных Bug Bounty-кейсах (есть разборы на Хабре) аналогичный подход срабатывает на кешбэк-программах крупных сервисов: сервер проверяет общий лимит активированных кешбэков как счётчик, и параллельные запросы с разными ID кешбэков успевают проскочить до обновления.

Single-endpoint Collision — подмена данных между потоками

Тут гонка состояний не про обход лимита, а про рассинхронизацию данных. Два запроса к одному эндпоинту одновременно меняют одно поле, и один поток генерирует результат на основе данных, записанных другим.

Классический сценарий — смена email с подтверждением через токен. Сервер хранит одно поле pending_email, новый запрос перезаписывает предыдущее значение. Два запроса обрабатываются параллельно: один поток записывает email атакующего, другой перезаписывает его email жертвы. Токен подтверждения привязывается к email жертвы, но отправляется на почту атакующего (из параметра исходного запроса). Результат — захват чужого аккаунта. Именно так работала CVE-2022-4037 в GitLab, которую разберём ниже.

В CTF single-endpoint collision чаще всего попадается в механизмах смены email, сброса пароля (когда reset-токен хранится в сессии) и обновления профиля, где одно поле влияет на другое.

Multi-endpoint Race — гонка между разными эндпоинтами

Самый хитрый тип. Параллельные запросы идут к разным URL, но затрагивают одни и те же данные в базе. Один эндпоинт добавляет товар в корзину, другой проводит оплату — а между ними нет синхронизации.

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

Multi-endpoint race сложнее в эксплуатации: race window нужно совместить между двумя разными операциями. Ключевой приём — выравнивание окон (aligning race windows): оба запроса должны попасть в серверный pipeline одновременно, хотя направлены к разным эндпоинтам. Single-packet attack решает эту задачу, упаковывая запросы к разным URL в один TCP-пакет.

Single-packet attack и last-byte sync: техника синхронизации

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

HTTP/2 — single-packet attack. HTTP/2 мультиплексирует запросы поверх одного TCP-соединения. Single-packet attack позволяет запихнуть 20-30 полных HTTP-запросов внутрь одного TCP-пакета. Сервер получает их все разом и начинает обработку параллельно — сетевой jitter устраняется полностью. По бенчмаркам PortSwigger, разброс при single-packet attack — порядка четырёх микросекунд. На порядки точнее любого другого метода. Это превращает race condition из теоретической уязвимости в стабильно воспроизводимую.

HTTP/1.1 — last-byte synchronization. Если сервер не поддерживает HTTP/2, применяется last-byte sync. Открываешь N TCP-соединений, отправляешь все запросы кроме последнего байта тела, а затем досылаешь финальный байт во всех соединениях одновременно. Jitter минимизируется, хотя полностью не исчезает — задержки между соединениями остаются.

В Burp Suite 2023.9+ обе техники встроены нативно. При отправке группы запросов через Repeater («Send group in parallel») Burp автоматически выбирает single-packet attack для HTTP/2 и last-byte sync для HTTP/1.1. В Turbo Intruder нужно явно указать engine=Engine.BURP2 и concurrentConnections=1.

Для CTF это критически важно. Большинство платформ поддерживают HTTP/2. Без single-packet attack эксплуатация race condition в CTF-тасках требовала десятков повторных попыток и считалась нестабильной. С ним — одного прогона обычно хватает.

Пошаговая эксплуатация race condition

Методология PortSwigger: три фазы — Predict (предсказать коллизию), Probe (проверить гипотезу), Prove (подтвердить эксплуатацию). Адаптирую этот workflow под CTF — по сути, «делай раз, делай два, делай три».

Шаг 1 — найти потенциальную коллизию

Ищи эндпоинты с ограничением или одноразовым действием. В CTF это любой POST-запрос с купоном, промокодом или invite-кодом. Операции с балансом: покупка, перевод, вывод. Эндпоинты, которые меняют состояние: email, пароль, роль пользователя. Любой rate-limit: login, OTP, password reset.

Ключевой вопрос для каждого найденного эндпоинта: «Что произойдёт, если этот запрос выполнится дважды одновременно?» Если повторный запрос (после первого успешного) возвращает ошибку типа «купон уже использован» или «лимит исчерпан» — проверка и обновление разделены на два шага. Между ними есть race window для тестирования API.

Ещё подсказка: если эндпоинт отвечает за миллисекунды — race window узкий, нужен single-packet attack. Если обработка занимает сотни миллисекунд (например, из-за внешнего API) — окно шире, может хватить и обычного asyncio-скрипта.

Шаг 2 — параллельная отправка через Turbo Intruder

Для limit overrun и single-endpoint collision в CTF самый надёжный инструмент — Turbo Intruder. Перехватываешь целевой запрос в Burp, отправляешь в Turbo Intruder (правый клик → Send to Turbo Intruder) и используешь шаблон single-packet attack:

# Шаблон Turbo Intruder — single-packet attack (HTTP/2)
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')  # все 20 улетают одним пакетом

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

concurrentConnections=1 и engine=Engine.BURP2 — обязательны для single-packet attack. Все 20 запросов ставятся в очередь с gate='race1', а openGate('race1') отправляет их одним TCP-пакетом. Шаблон лежит в стандартной директории примеров Turbo Intruder (race-single-packet-attack.py).

Без Burp Pro (на CTF лицензии часто нет) работает и Burp Community Edition: создаёшь группу вкладок в Repeater с одинаковым запросом и жмёшь «Send group (parallel)». Для HTTP/2-серверов Burp активирует single-packet attack автоматически.

Для multi-endpoint race модифицируешь шаблон: вместо одного target.req ставишь в очередь разные запросы (к разным эндпоинтам) с одинаковым gate — улетят в одном пакете.

Шаг 3 — подтверждение и масштабирование

После отправки смотришь ответы в таблице Turbo Intruder:

  • Разные HTTP-коды (часть 200, часть 400/409) — race window существует, но попали не все запросы. Увеличь количество до 30-50 или повтори. Стабильная воспроизводимость — вопрос числа запросов.
  • Все 200 — проверяй побочные эффекты: запроси баланс, посмотри количество применённых скидок, проверь число созданных записей. Скидка применилась многократно — гонка подтверждена.
  • Все 400/409 — race window слишком узкий для текущего метода, или race condition отсутствует, или есть серверная блокировка. Пробуй другой эндпоинт или проверь, не используется ли session-based locking.

Альтернатива без Burp: Python + asyncio. Для CTF, где Burp недоступен, подойдёт скрипт с aiohttp. Точности single-packet attack он не даёт (сетевой jitter остаётся), но для race window в миллисекунды бывает достаточно:

# Концепт — не заменяет single-packet attack, но работает на широких окнах
import asyncio, aiohttp

async def send(session, url, data):
    async with session.post(url, json=data) as r:
        return r.status, await r.text()

async def race(url, data, n=20):
    async with aiohttp.ClientSession() as s:
        return await asyncio.gather(
            *[send(s, url, data) for _ in range(n)])

Запуск: asyncio.run(race("http://target/apply_coupon", {"code": "PROMO20"})). Корутины стартуют через asyncio.gather и отправляются практически одновременно — но «практически» тут ключевое слово. Для тесных race window в микросекунды не хватит, для окон в десятки миллисекунд — работает.

CVE-2022-4037: race condition в GitLab OAuth

Реальный пример single-endpoint collision — CVE-2022-4037 в GitLab CE/EE. По данным NVD, уязвимость затрагивает все версии до 15.5.7, версии с 15.6 до 15.6.4 и с 15.7 до 15.7.2. CVSS — 6.4 (MEDIUM), вектор: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:L/I:L/A:N. CWE — CWE-362.

Как работала атака. При смене email GitLab записывал новый адрес в поле pending_email, генерировал токен подтверждения и отправлял письмо. Критичная деталь: pending_email — одно поле на пользователя, каждый новый запрос перезаписывает предыдущее значение.

Атакующий отправлял два параллельных запроса на смену email:

  1. Поток A записывал pending_email = attacker@evil.com
  2. Поток B перезаписывал pending_email = victim@target.com
  3. Поток A считывал pending_email для генерации токена — а там уже адрес жертвы
  4. Поток A отправлял письмо на attacker@evil.com (адрес из параметра запроса), но токен был привязан к victim@target.com

Результат: атакующий получал на свою почту токен подтверждения для чужого email. После подтверждения — полный доступ к аккаунту жертвы через OAuth. Классическая уязвимость бизнес-логики: каждый шаг по отдельности корректен, но параллельное выполнение ломает инвариант «токен привязан к тому же email, на который отправлен».

Компоненты CVSS-вектора: PR:L — нужен аккаунт в GitLab, UI:N — действий жертвы не требуется, S:C — scope изменён (затронуты аккаунты третьих лиц). По данным CISA, эксплуатация в дикой природе не зафиксирована (Exploitation: none), автоматизация невозможна (Automatable: no), техническое воздействие частичное (Technical Impact: partial). EPSS-скор — 0.0064 (percentile 0.49, ниже медианы). Низкая вероятность массовой эксплуатации не отменяет ценности уязвимости для целевой атаки на конкретного пользователя.

PortSwigger реализовала аналогичный сценарий в лабораторной работе «Race conditions: single-endpoint» — хороший тренировочный стенд для отработки single-endpoint collision до CTF.

Защита от race condition и обход в CTF-тасках

Понимание защитных механизмов помогает CTF-игроку быстрее определить, где гонка сработает, а где нет.

Атомарные операции в БД. Вместо двух запросов (SELECT + UPDATE) — один: UPDATE coupons SET used=1 WHERE code=? AND used=0. Если affected_rows = 0 — купон уже использован. Проверка и обновление в одной атомарной операции, race window исчезает. В CTF-тасках этот паттерн встречается редко — задания специально оставляют TOCTOU для эксплуатации.

Блокировка строки. SELECT ... FOR UPDATE блокирует строку до конца транзакции (рекомендация PostgreSQL STIG по контролю параллельного доступа). Все параллельные потоки встают в очередь. Обход в CTF: если разработчик забыл добавить FOR UPDATE хотя бы к одному запросу в цепочке — это слабое звено.

Идемпотентные ключи (idempotency keys). API принимает уникальный ключ, повторные запросы с тем же ключом игнорируются. Обход: отправляй параллельные запросы с разными ключами (или без ключа, если параметр опционален).

Session-based locking. Некоторые фреймворки блокируют обработку запросов одной сессии последовательно. Обход: отправляй параллельные запросы с разных сессий (создай несколько аккаунтов) или найди эндпоинт без сессионной блокировки. PHP и ряд других стеков используют session locking по умолчанию — это сужает поверхность атаки, но не устраняет multi-session race.

Rate-limiting на уровне WAF. Ограничивает количество запросов в секунду с одного IP. Обход: single-packet attack отправляет все запросы в одном TCP-пакете до того, как rate-limiter успевает обработать счётчик. Это относится к проблематике Unrestricted Resource Consumption (API4:2023 по OWASP API Security Top 10) — WAF должен проверять запросы до передачи бэкенду.

Главное правило при оценке защиты в CTF: если таск содержит бизнес-логику с лимитом — race condition скорее всего заложен специально. Ищи race window, а не инъекции.

Большинство CTF-игроков подходят к гонке состояний как к экзотике — пробуют после того, как перебрали SQLi, XSS и SSTI. На мой взгляд, это ошибка в приоритизации. Race condition — первое, что стоит проверять на любом таске с купонами, балансами, одноразовыми действиями или rate-limiting. Техника эксплуатации одна и та же: 20 запросов через single-packet attack. Что купон, что OTP, что перевод средств — за ними стоит один паттерн TOCTOU с race window между проверкой и обновлением. Воспроизводимый, стабильный, скучно предсказуемый. Самое сложное в race condition — не сама эксплуатация, а дисциплина: не бросаться сразу искать сложные баги, а сначала проверить параллельные запросы на очевидных эндпоинтах. В CTF-таске с магазином я начинаю с купона и баланса — и в половине случаев флаг уже у меня, пока другие команды ковыряют фильтры WAF. Тем, кому writeup'ов недостаточно и хочется системно пройти все типы логических уязвимостей самостоятельно — на WAPT эту цепочку проходят в нескольких модулях с лабами и ментором.

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

Поделиться

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

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

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

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

Реверс-инжиниринг в Ghidra: разбираем crackme

10 мин.

5

Реверс-инжиниринг в Ghidra: разбираем crackme

Пошаговый разбор crackme в Ghidra: strcmp, XOR-шифрование, хеш-функции. Как находить проверку пароля через XRefs, когда декомпилятор врёт и как патчить переходы.

8 ОКТЯБРЬ, 2026

Write-up CTF методология: гайд с шаблоном

12 мин.

10

Write-up CTF методология: гайд с шаблоном

Готовый Markdown-шаблон для CTF write-up, разбор хороших и плохих примеров, чеклист перед публикацией. Как тратить 10 минут на документацию вместо двух часов.

8 ОКТЯБРЬ, 2026

Pwntools buffer overflow эксплойт с нуля для CTF

13 мин.

8

Pwntools buffer overflow эксплойт с нуля для CTF

Пошаговый туториал: установка pwntools, checksec, cyclic, ret2win — пишем рабочий эксплойт для CTF buffer overflow с разбором каждой строки кода

7 ОКТЯБРЬ, 2026