Главная / Блог / Race Condition уязвимости веб-приложений: находим баги в бизнес-логике на CTF-тасках

14 мин.00

Race Condition уязвимости веб-приложений: находим баги в бизнес-логике на CTF-тасках

Race Condition уязвимости веб-приложений: находим баги в бизнес-логике на CTF-тасках

Race Condition уязвимости веб-приложений: находим баги в бизнес-логике на CTF-тасках

В 2012 году Джеймс Чжун (James Zhong) создал несколько аккаунтов на даркнет-маркетплейсе Silk Road и, эксплуатируя race condition в системе вывода средств, по данным DOJ, похитил около 50 000 BTC — на момент конфискации ФБР в ноябре 2021 года сумма на изъятом оборудовании составляла $3,3 млрд. Никаких SQLi и XSS — только параллельные запросы, отправленные в нужный момент. Одна уязвимость в бизнес-логике, и человек стал криптомиллиардером (ненадолго). На CTF-площадках этот класс задач встречается всё чаще, и тот, кто умеет находить race window в checkout-флоу или купонной системе, стабильно набирает флаги там, где остальные застревают на классических инъекциях.

Зачем эксплуатировать race condition: от double-spending до захвата аккаунтов

Race condition — не баг в коде в привычном смысле. Это баг в голове разработчика, который уверен, что запросы обрабатываются строго последовательно. Когда два или более запроса попадают на сервер одновременно и модифицируют общие данные без синхронизации — между ними возникает коллизия. Результат зависит от того, в каком порядке потоки прочитают и запишут состояние. И этот порядок никто не контролирует. Подробнее — в нашем статье о пентест веб-приложений.

В терминах MITRE ATT&CK эксплуатация race condition ложится на несколько тактик. На этапе Initial Access — Exploit Public-Facing Application (T1190), когда атакующий через публичный API запускает параллельные запросы. Конечный Impact варьируется: Financial Theft (T1657) при double-spending купонов и вывода средств, Stored Data Manipulation (T1565.001) при перезаписи чужих данных через коллизию, Transmitted Data Manipulation (T1565.002) при подмене параметров в процессе обработки.

По классификации OWASP Top 10 (2021), race condition попадает под A01: Broken Access Control — механизм контроля доступа формально на месте, но обходится за счёт временного окна между проверкой и действием. На CTF это конкретные сценарии: многократное применение одноразового купона, вывод средств сверх баланса, обход rate-limit на попытки аутентификации, захват чужого email через коллизию при смене адреса.

Место техники в kill chain: атакующий уже имеет легитимный доступ к приложению — зарегистрированный аккаунт, валидную сессию. Race condition — это эскалация возможностей внутри приложения, а не initial access с нуля. В bug bounty-отчётах и на CTF это типичная стартовая позиция: аккаунт с балансом $50, а куртка стоит $1 337. Знакомо?

Три типа логических уязвимостей веб-приложений через race condition

Согласно исследованию PortSwigger (whitepaper «Smashing the state machine», Black Hat USA 2023), race condition в веб-приложениях делится на три основных типа. Каждый эксплуатирует свой паттерн обработки данных на сервере.

Limit Overrun и TOCTOU уязвимость

Самый распространённый и самый понятный тип. TOCTOU (Time of Check, Time of Use) — уязвимость, при которой между моментом проверки условия и моментом выполнения действия существует временное окно. Если в это окно влезают параллельные запросы, все они проходят проверку и все выполняют действие.

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

Работает если: сервер обрабатывает запросы в отдельных потоках, операции с БД не обёрнуты в транзакцию с блокировкой строки, между SELECT и UPDATE проходит хотя бы несколько микросекунд. Не работает если: разработчик использует SELECT ... FOR UPDATE, атомарные операции или advisory locks на уровне БД.

[Применимо: внешний пентест, любая web-платформа с бизнес-логикой лимитов]

Типичные сценарии в CTF и bug bounty: многократное применение промокода, повторный вывод средств, обход лимита «одна заявка на пользователя», повторное использование решения CAPTCHA, многократное погашение подарочной карты. По данным PortSwigger, достаточно отправить 20–30 параллельных запросов, чтобы с высокой вероятностью попасть в race window.

Отдельный подтип — обход account lockout. Приложение блокирует аккаунт после N неудачных попыток, но счётчик обновляется не мгновенно. При single-packet attack можно протолкнуть кучу попыток логина до того, как функция инкремента счётчика отработает хотя бы для первого запроса. Ограничение «3 попытки» фактически превращается в 10–20 параллельных попыток за один раунд — этого хватает для целевого перебора паролей из короткого словаря.

Single-endpoint Collision

Тут два запроса к одному эндпоинту одновременно модифицируют одно и то же поле, но с разными значениями. Это не обход лимита — это подмена данных через перезапись.

Классический сценарий: смена email с подтверждением. Сервер записывает новый адрес в поле pending_email, генерирует токен и отправляет письмо. Если два запроса обрабатываются параллельно, второй поток может перезаписать pending_email после того, как первый уже прочитал его для генерации токена. Результат: токен привязан к email жертвы, но письмо уходит на адрес атакующего.

Именно этот механизм стоит за CVE-2022-4037 в GitLab — разбор ниже.

Связь с IDOR уязвимостью: single-endpoint collision может создать условия, при которых данные одного пользователя становятся доступны другому не через прямую ссылку на объект, а через перезапись общего поля. Нетипичная IDOR-уязвимость, которую не обнаружить стандартным перебором идентификаторов.

Multi-endpoint Race

Самый сложный для эксплуатации тип. Несколько разных эндпоинтов обращаются к общему состоянию, и race window возникает между вызовами разных API.

Пример из e-commerce: пользователь добавляет товар в корзину (/cart/add), а параллельно применяет купон (/cart/coupon). Если проверка стоимости для купона происходит до обновления суммы корзины — скидка применяется к старой сумме. На практике два разных купона, которые нельзя комбинировать по бизнес-логике, успешно применялись при одновременной отправке запросов через single-packet attack — сервер не успевал проинкрементировать счётчик применённых купонов.

Ключевая сложность: race window в multi-endpoint сценарии зависит от двух разных операций на сервере. Их нужно выровнять по времени. Тут помогает connection warming — предварительная отправка «пустых» запросов прогревает TCP-соединения и выравнивает задержку на стороне сервера. Без прогрева один эндпоинт может обрабатываться на 50–100 мс дольше, и race window схлопывается. Я на одном CTF потерял час, пока не догадался прогреть соединения — после этого коллизия воспроизвелась с первого раза.

Методология поиска race condition в CTF-тасках

В русскоязычных writeup'ах race condition описывают как «отправь запросы параллельно и смотри что будет». Исследование PortSwigger предлагает системный подход из трёх шагов: Predict, Probe, Prove. Этот подход позволяет проводить gap analysis race condition-поверхности приложения вместо слепого перебора.

Predict — ищем потенциальные коллизии

На этом этапе нужно найти эндпоинты, которые модифицируют общее состояние. Что искать:

  • Любые лимиты: количество применений купона, баланс при выводе средств, лимит попыток логина, ограничение на количество отзывов.
  • Операции «должно произойти один раз»: подтверждение email, использование invite-токена, активация подписки, сброс пароля.
  • Многошаговые процессы: checkout с проверкой баланса и списанием, регистрация с верификацией.
  • Скрытые sub-states: если один запрос запускает цепочку (создание записи, генерация токена, отправка уведомления), между шагами есть промежуточные состояния.

На CTF ищите формы с промокодами, балансы с возможностью перевода, механизмы голосования и всё, что в описании содержит слова «once», «limit», «one-time». Каждый такой эндпоинт — кандидат на TOCTOU.

Probe и Prove — от улик к эксплойту

Probe: отправьте группу из 20 одинаковых запросов параллельно через Burp Repeater (Send group in parallel) и сравните ответы. Если несколько вернули одинаковый результат «купон применён» — это улика. Нормальное поведение: первый запрос — «применён», остальные 19 — «уже использован».

Обращайте внимание на тайминг. Если время ответа варьируется на десятки миллисекунд при идентичных запросах — сервер обрабатывает их в разных потоках. Это подтверждает наличие concurrency vulnerability.

Prove: подтвердите impact. Мало показать, что запросы обработались параллельно — нужно доказать изменение состояния: баланс уменьшился один раз, а товар получен дважды. Или токен привязан к чужому email. Для CTF это обычно означает: проверить баланс, получить флаг из ответа, увидеть изменение в профиле. Один-два «лишних» ответа со статусом 200 — не доказательство; доказательство — итоговая цена товара ниже порога.

Практика: Burp Suite Repeater и эксплуатация race condition в CTF-лабе

Разберём пошагово эксплуатацию limit overrun — самый частый тип задач на CTF и в лабах PortSwigger Web Security Academy.

Требования к окружению

  • Burp Suite Professional 2023.9+ (параллельная отправка через Repeater; альтернатива — расширение Turbo Intruder для Community Edition)
  • HTTP/2 на целевом сервере для single-packet attack; если только HTTP/1.1 — используется last-byte sync (менее надёжно)
  • RAM: 4 ГБ минимум для Burp, 8 ГБ рекомендуется при Turbo Intruder с большим числом запросов
  • ОС: Windows / macOS / Linux без ограничений
  • Сеть: стабильное соединение; VPN при работе с CTF-платформой

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

Шаг 1. Перехватите запрос на целевое действие. В лабе PortSwigger «Limit overrun race conditions» это POST /cart/coupon с телом csrf=...&coupon=PROMO20. Отправьте в Repeater.

Шаг 2. Создайте группу вкладок. Продублируйте запрос 20 раз (Ctrl+R). Все вкладки — идентичный запрос с одинаковым CSRF-токеном и купоном.

Шаг 3. Выберите режим «Send group in parallel» (иконка с параллельными стрелками в панели группы). Burp автоматически применит single-packet attack для HTTP/2 или last-byte sync для HTTP/1.1.

Шаг 4. Отправьте группу. Изучите ответы: если несколько вернули 200 OK с сообщением о применении скидки — race condition подтверждён. Ожидаемый результат: куртка за 1 337 EUR с промокодом 20% при многократном применении стоит ниже баланса (50 EUR). Скидка накапливается, итоговая цена падает значительно ниже доступного баланса.

Шаг 5. Оформите заказ. Цена ниже баланса — флаг ваш.

Если не сработало: после первой попытки скидка применилась только один раз — повторите с увеличенным количеством запросов (30–40) или отправьте несколько раундов. Server-side jitter может потребовать нескольких итераций. На практике я обычно делаю 3–4 раунда по 20 запросов, прежде чем списывать таск как «защищённый».

Single-packet attack через Turbo Intruder

Для более гибких сценариев — кастомные задержки, сотни запросов, разные payload'ы в одной группе — берите расширение Turbo Intruder. Скрипт ниже основан на шаблоне race-single-packet-attack.py из официальных примеров PortSwigger:

# 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)

Работает если: цель поддерживает HTTP/2, concurrentConnections=1 обеспечивает отправку через одно соединение в одном TCP-пакете. Не работает если: сервер поддерживает только HTTP/1.1 — переключайтесь на engine=Engine.THREADED с last-byte sync, надёжность снижается из-за сетевого jitter.

Параметр gate задерживает отправку финального фрагмента каждого запроса до вызова openGate(). Все 20 запросов завершаются одновременно. По бенчмаркам PortSwigger (данные из whitepaper), разброс времени доставки при single-packet attack сведён к минимуму — сетевой jitter практически нейтрализован.

Когда Turbo Intruder нужнее Repeater: при multi-endpoint race с разными URL в одной группе, при необходимости стартовать сотни запросов одновременно, при автоматизации с переменными payload'ами. В Repeater максимум — вручную нащёлкать 30–40 вкладок. Turbo Intruder прожуёт и 500.

CVE-2022-4037: single-endpoint collision в GitLab на практике

CVE-2022-4037 затрагивает GitLab CE/EE во всех версиях до 15.5.7, в версиях 15.6 до 15.6.4 и 15.7 до 15.7.2. По данным 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 (Concurrent Execution using Shared Resource with Improper Synchronization). Уязвимость приводит к verified email forgery и захвату аккаунтов третьих сторон при использовании GitLab как OAuth-провайдера.

Разберём вектор: AV:N — атака по сети, AC:L — низкая сложность эксплуатации (вот это настораживает), PR:L — нужен аккаунт с базовыми привилегиями, UI:N — действие жертвы не требуется, S:C — scope changed, атака влияет за пределами уязвимого компонента. Scope changed тут критичен: через OAuth атакующий получает доступ к сторонним сервисам.

Механика (single-endpoint collision): GitLab хранит одно поле pending_email на пользователя. При смене email сервер выполняет три операции: (1) записывает новый адрес в pending_email, (2) читает pending_email для генерации подтверждающего токена, (3) отправляет письмо на адрес из параметра запроса.

Атакующий отправляет два параллельных POST /change-email: поток A с email=attacker@evil.com, поток B с email=victim@target.com. При удачном тайминге: поток A записывает pending_email, поток B перезаписывает его значением жертвы, поток A читает из БД уже victim@target.com и привязывает к нему токен — но письмо уходит на attacker@evil.com (адрес из запроса потока A).

Результат: атакующий получает ссылку подтверждения, которая привязывает к его аккаунту верифицированный email жертвы. Дальше — OAuth-авторизация на сторонних сервисах от имени жертвы (Valid Accounts, T1078). Красиво и страшно одновременно.

По данным CISA Vulnrichment, SSVC Decision — Track (мониторить): Exploitation: none, Automatable: no, Technical Impact: partial. EPSS-оценка — 0.0064, percentile 0.4763 (ниже медианы). Массовая эксплуатация маловероятна, но паттерн атаки универсален: любое приложение с полем pending_email или аналогичным одиночным хранилищем промежуточного состояния потенциально уязвимо. На PortSwigger Web Security Academy есть лаба, воспроизводящая этот сценарий — «Race conditions: single-endpoint».

Скрытые подтипы и многопоточные атаки на веб-приложения

Limit overrun и single-endpoint collision — видимая часть айсберга. Исследование PortSwigger описывает подтипы, которые в русскоязычных материалах почти не разбирают.

Partial construction race conditions

Некоторые ORM-фреймворки создают объект поэтапно: INSERT пустой записи, затем UPDATE с данными. Между операциями объект уже существует в БД, но не инициализирован полностью. Если второй поток обращается к этому объекту в промежуточном состоянии — он может получить доступ к частично созданной сущности: пользователю без пароля, сессии без привязки к аккаунту, токену без валидации. Звучит как фантастика, но я видел это в Django ORM с кастомным User-модулем.

Работает если: ORM создаёт объект несколькими SQL-запросами вне единой транзакции, между INSERT и UPDATE есть доступное race window, приложение не проверяет полноту инициализации при чтении объекта. Не работает если: создание обёрнуто в SERIALIZABLE-транзакцию или используется INSERT с полным набором полей.

[Применимо: внутренний пентест, web-приложения на фреймворках с Active Record, legacy-код]

Time-sensitive атаки

Даже без прямого обхода лимита разница во времени обработки раскрывает информацию. Эндпоинт сброса пароля: запрос для существующего email занимает 200–300 мс (генерация токена + отправка письма), для несуществующего — 50 мс (только проверка в БД). Single-packet attack позволяет отправить группу запросов с разными адресами, свести сетевую задержку к нулю и увидеть тайминг с точностью до микросекунд. Разница может составлять порядка секунды — тривиальная энумерация пользователей. Это пересекается с OWASP A07:2021 — Identification and Authentication Failures.

Тестирование бизнес-логики приложений: ограничения и контрмеры

Когда race condition НЕ работает

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

Атомарные операции. Если check и update объединены в один запрос: UPDATE coupons SET used=true WHERE code=? AND used=false с последующей проверкой affected rows — race window отсутствует. Нет промежутка между проверкой и действием. Встретили такое — ищите другой эндпоинт.

Rate limiting на уровне инфраструктуры. WAF или API Gateway с ограничением запросов в секунду может заблокировать параллельную группу. Но single-packet attack может обойти rate-limiter, работающий на уровне отдельных TCP-соединений: 20 запросов приходят в одном пакете через одно соединение. По классификации OWASP API Security, это пересекается с API4:2023 — Unrestricted Resource Consumption.

Контрмеры для разработчиков

Атомарные транзакции с уровнем изоляции READ COMMITTED или выше — базовая защита. SELECT ... FOR UPDATE блокирует строку. Идемпотентные операции с уникальным ключом запроса предотвращают повторную обработку. Advisory locks на уровне приложения — для случаев, когда между check и use есть взаимодействие с внешним сервисом.

Каждая мера имеет свою цену: FOR UPDATE снижает throughput при высокой конкурентности, idempotency key требует отдельного хранилища, advisory locks создают риск deadlock при неосторожной реализации. Серебряной пули нет — есть trade-off между безопасностью и производительностью.

Большинство CTF-игроков, с которыми я общаюсь, воспринимают race condition как «отправь запросы побыстрее и надейся». На простых лабах это работает — на реальных таргетах и сложных CTF-тасках нет. Разница между решившим и нерешившим задачу обычно не в скорости отправки, а в умении найти race window: понять, какие эндпоинты обращаются к общему состоянию, в какой момент возникает sub-state, как выровнять тайминг в multi-endpoint сценарии. Методология predict, probe, prove — не академическое упражнение, а рабочий чеклист. На трёх последних соревнованиях я применял именно этот подход, и race condition-таск решался за 15–20 минут вместо часа вслепую.

Что удивляет — в русскоязычном сообществе почти никто не обсуждает partial construction races и time-sensitive атаки. Все зациклены на limit overrun с купонами, хотя single-endpoint collision (как CVE-2022-4037 в GitLab) часто опаснее по импакту: не «сэкономил $20 на промокоде», а «захватил чужой аккаунт через OAuth». Думаю, через год-два partial construction станет стандартным вектором на CTF — фреймворки усложняются, ORM-слой генерирует всё больше промежуточных состояний, и разработчики не подозревают, что их create_user() — два SQL-запроса с race window между ними. Если хочешь не просто writeup прочитать, а пройти всю атаку от predict до prove — на WAPT эту цепочку разбирают в двух модулях с лабами.

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

Поделиться

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

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

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