Race Condition недооценивают. Это гонка одновременных запросов, и оборачивается она двойными списаниями, обходом лимитов и захватом чужих аккаунтов. Такие баги до сих пор находят в платёжных системах, авторизации и бонусных программах крупных сервисов.
Чтобы разобраться, как это работает в реальных веб-приложениях и где искать слабые места, я поговорил с Дмитрием Панасенко, ведущим специалистом АБП2Б. Собрали основные выводы: что такое Race Condition, какие у неё типы, как её находят и что помогает от неё защититься.
Для начала разберёмся, что такое Race Condition и почему эта уязвимость заслуживает внимания.
Race Condition — это класс уязвимостей, которые возникают из-за того, что сервер обрабатывает несколько запросов одновременно без должной синхронизации. Когда два или более запроса приходят на сервер в один и тот же момент и затрагивают одни и те же данные, между ними происходит коллизия.
Результаты такой коллизии могут быть разными — от незначительных багов до критических уязвимостей, которые позволяют обойти проверки безопасности, списать средства дважды, получить доступ к чужому аккаунту или превысить установленные лимиты.
Перейдём к тому, какие виды этой уязвимости вообще бывают.
Можно выделить 3 вида:
1. Limit Overrun
Первый и самый распространённый — это Limit Overrun или его другое название Time of Check, Time of Use (TOCTOU).
Limit Overrun — уязвимость заключается в том, что атакующий обходит ограничение, установленное бизнес-логикой приложения, отправляя несколько запросов параллельно в тот момент, когда сервер ещё не успел обновить состояние после первого отправленного запроса из них.
Как это может выглядеть в коде
# Time of Check — проверяемcoupon = db.query("SELECT used FROM coupons WHERE code = ?", code)if coupon.used: return "Купон уже использован"# Time of Use — действуемdb.execute("UPDATE coupons SET used = true WHERE code = ?", code)apply_discount(order)
Принцип работы всегда один и тот же: сервер сначала проверяет условие (хватает ли средств, не использован ли купон, не превышен ли лимит), затем совершает действие (списывает деньги, применяет скидку, активирует подписку). Между этими двумя шагами есть короткий промежуток времени — race window. Если успеть отправить в это окно несколько запросов, все они пройдут одну и ту же проверку и все выполнят действие, потому что ни один из них ещё не успел обновить состояние.
Этот тип встречается везде, где есть лимиты или разовые операции:
· Применение промокодов и купонов (обход ограничения «использовать один раз»)
· Списание бонусов, кешбэков, внутренней валюты
· Вывод средств, переводы между счетами
· Активация подписок, триалов, бесплатных доменов
· Приглашения в команду, добавление участников, создание воркспейсов
· Голосование, лайки, отзывы с ограничением «один раз на пользователя»
Во всех этих сценариях приложение верит, что проверка лимита надёжна. В действительности, как правило, достаточно отправить 20–30 запросов параллельно, и большинство проверок рассыпается.
Так, а что по практике-то? Есть ли что-то интересное по реальным кейсам?
Кейс 1 — бонусы превращаются в скидку мечты
Ну начнём с самого интересного, что я смог найти. Сидя на ББ и долго тыкая один таргет, я вспомнил, что есть такая прекрасная уязвимость, как Race Condition — и под этот таргет она подходила идеально. Из переменных у нас были товары и бонусы, которые считались 1 бонус = 1 рубль.
Вот я и подумал: а что, если отправить эти запросы параллельно? Можно ли купить товар, используя одни и те же бонусы несколько раз? Сформировал запрос с максимальным количеством бонусов, отправил 10 копий через Burp Repeater (Send group in parallel) — и в ответе на каждый запрос получил ссылку на оплату товара.

В итоге получил несколько товаров с отличной скидкой, хотя бонусов хватало лишь на один.
Кейс 2 — все кешбэки и сразу
Второй кейс произошёл тоже со мной на ББ. Сайт, как и многие другие, предоставляет море кешбэков, но они ограничены — приходится выбирать с умом: полетишь ли ты со скидкой 5% на Мальдивы или купишь сыр со скидкой 7% в магазине. А я хотел всё и сразу.
Думаю: как получить все кешбэки? Первое, что можно предположить, — отправить все ID в одном запросе, но, увы, сработали ограничения MAX 3. И тут пришла мысль: похоже, сервер проверяет общий лимит активированных кешбэков как счётчик, а значит, это классический Limit Overrun. Собрал все ID, сформировал группу из 5–6 запросов (каждый со своим кешбэком) и отправил параллельно.


БИНГО — лечу на Мальдивы с вкусным сыром.
Кейс 3 — классика с HackerOne
Ну и для любителей классики — аналогичный кейс с подарочными картами: один сертификат погашался параллельными запросами и применялся многократно. Купив одну карту, пользователь мог оплачивать ею снова и снова. Подробности: https://hackerone.com/reports/759247
2. Single-endpoint Collision
Теперь перейдём ко второму виду — Single-endpoint Collision.
Ранее рассмотренный Limit Overrun связан с обходом ограничений, а Single-endpoint представляет собой коллизию. Она возникает, когда два запроса одновременно изменяют одно и то же поле, и второй запрос успевает перезаписать данные первого до того, как первый их использует. В итоге приложение работает с подменёнными данными.
Такой тип встречается в подобном функционале:
· Смена email с подтверждением через токен в письме
· Смена номера телефона с SMS-верификацией
· Сброс пароля (когда reset token хранится в сессии)
А теперь снова к практическому кейсу.
Один из самых показательных багов этого типа — уязвимость в GitLab, CVE-2022-4037. Это сценарий, где атакующий привязывает к своему аккаунту чужой email и получает токен себе на почту.
Как работала смена email нормально
У любого пользователя в GitLab есть возможность сменить email. Логика выглядит так:
-
Отправляешь запрос POST /change-email с новым адресом
-
Сервер записывает его в поле pending_email в базе
-
Сервер генерирует уникальный токен подтверждения
-
Сервер отправляет письмо на этот новый адрес со ссылкой /confirm?token=…
-
Переходишь по ссылке → email меняется
Одно поле pending_email, один токен, одно письмо. Всё понятно. Для нас это ключевое наблюдение.
Если отправить два запроса на смену email подряд (не параллельно, а последовательно), первая ссылка становится невалидной. Из этого делаем вывод: сервер хранит только один pending email за раз. Новый запрос перезаписывает предыдущее значение в базе. Это и есть та самая «одна ячейка», вокруг которой можно устроить коллизию.
Что делает атакующий
Атакующий отправляет два параллельных запроса на один эндпоинт с разными email.
Что происходит на сервере
Оба запроса обрабатываются в разных потоках и топчутся на одном поле pending_email:
Поток A: записывает pending_email = «attacker@evil.com«
Поток B: записывает pending_email = «victim@target.com» ← перезаписал!
Поток A: читает pending_email для генерации токена
но там уже «victim@target.com«
→ токен test123 привязывается к victim@target.com
Поток A: отправляет письмо
адрес получателя берётся из своего запроса → attacker@evil.com
Здесь и ломается логика. Сервер рассинхронизировал две вещи:
Кому отправить письмо берётся из параметра запроса (attacker@evil.com).
К какому email привязать токен берётся из БД (victim@target.com), потому что второй поток был перезаписан.
Как это может выглядеть в коде
def change_email(request, user): new_email = request.body['email'] # Шаг 1: записываем новый email в базу db.execute("UPDATE users SET pending_email = ? WHERE id = ?", new_email, user.id) # ← Второй поток перезаписывает pending_email прямо тут # Шаг 2: читаем pending_email и генерируем токен pending = db.query("SELECT pending_email FROM users WHERE id = ?", user.id) token = generate_token(pending.email) # Шаг 3: отправляем письмо на адрес из запроса, а не из БД send_email(to=new_email, link="/confirm?token=" + token)
Практически разобрать кейс, описанный выше, можно тут https://portswigger.net/web-security/race-conditions/lab-race-conditions-single-endpoint
3. Multi-endpoint Race
Ну и третий тип — Multi-endpoint Race, как понятно из названия, мы будем работать с несколькими эндпоинтами.
Здесь мы ловим приложение между шагами многошагового процесса, в тот момент, когда оно уже начало одно действие, но ещё не закончило второе.
Почти любой серьёзный процесс в веб-приложении является цепочкой шагов. Например, авторизация через OTP: ввёл email → получил код → ввёл код → вошёл. Оплата заказа: выбрал товар → подтвердил цену → оплатил. Смена email: запросил смену → получил письмо → подтвердил. Каждый шаг это отдельный запрос на отдельный эндпоинт. И между этими шагами приложение находится в состоянии, когда часть данных уже обновлена, а часть ещё нет.
Именно в этот промежуток мы и бьём: отправляем один запрос, который меняет состояние, и параллельно отправляем второй, который использует это состояние, пока приложение ещё не успело всё соединить.
Главный признак уязвимого места — два эндпоинта, которые работают с одной и той же сущностью (пользователь, заказ, транзакция, сессия) и по логике приложения должны вызываться последовательно, но технически ничто не мешает отправить их параллельно.
Рассмотрим эту уязвимость на практике.
Account Takeover через OTP race
Разберём реальный кейс с e-commerce платформой, где авторизация работала через одноразовый код на email.
Как работал логин нормально
Процесс авторизации состоял из трёх шагов, связанных между собой через flowId — уникальный идентификатор текущего потока авторизации:
POST /init-email — указываешь свой email, сервер привязывает его к flowId
POST /send-otp — сервер генерирует OTP и отправляет на привязанный email
POST /check-otp — вводишь полученный код, сервер проверяет и логинит
Все три запроса несут один и тот же flowId. Сервер хранит запись в базе:
· flowId: abc-123
· email: user@mail.com
· otp: 482901
Один flowId, один email, один код. Всё связано.
Поле email в записи flowId можно перезаписать в любой момент — просто отправив ещё один запрос /init-email с тем же flowId, но другим email. Сервер не блокирует смену email после того, как OTP уже отправлен. Вот тут и начинается веселье.
Что делает атакующий
Шаг 1 — получаем свой OTP легитимно:
1. POST /init-email { flowId: «abc-123», email: «attacker@evil.com» }
2. POST /send-otp { flowId: «abc-123» }
3. → На attacker@evil.com приходит код 482901
В базе сейчас:
· flowId: abc-123
· email: attacker@evil.com
· otp: 482901
Шаг 2 — готовим два запроса и отправляем параллельно:
· Запрос A: POST /init-email { flowId: «abc-123», email: «victim@target.com» }
· Запрос B: POST /check-otp { flowId: «abc-123», code: «482901» }
Запрос A подменяет email в записи на email жертвы. Запрос B проверяет OTP, который мы получили себе на почту.
Что происходит на сервере
· Поток A (/init-email): записывает email = «victim@target.com«
· Поток B (/check-otp): проверяет код 482901 → совпадает, ок читает email из записи → «victim@target.com» выдаёт сессию для victim@target.com
Поток B делает две операции: сначала проверяет OTP, потом читает email. Между этими двумя операциями Поток A успевает подменить email. Проверка прошла по нашему коду, а сессия выдалась для чужого email.
Как это выглядит в коде
# Эндпоинт 1: смена email в потоке авторизацииdef init_email(request): flow_id = request.body['flowId'] email = request.body['email'] db.execute("UPDATE auth_flows SET email = ? WHERE flow_id = ?", email, flow_id) return "OK"# Эндпоинт 2: проверка OTP и логинdef check_otp(request): flow_id = request.body['flowId'] code = request.body['code'] flow = db.query("SELECT * FROM auth_flows WHERE flow_id = ?", flow_id) # Шаг 1: проверяем код if flow.otp != code: return "Неверный код" # ← Между шагом 1 и шагом 2 init_email успевает подменить email # Шаг 2: логиним пользователя по email из базы user = db.query("SELECT * FROM users WHERE email = ?", flow.email) create_session(user)
В результате мы получили реальный OTP себе на почту, ввели его и залогинились как жертва. Со стороны жертвы не получится узнать, что аккаунт был захвачен, так как ей никаких уведомлений не придёт.
Такой тип встречается в подобном функционале:
· Авторизация через OTP / email-код (смена email в потоке + подтверждение кода)
· Оплата заказа (подтверждение оплаты + изменение содержимого корзины)
· Перевод средств (создание перевода + отмена перевода)
· Возврат средств (запрос refund + повторный заказ)
· Регистрация с подтверждением (создание аккаунта + подтверждение с пустым токеном)
· OAuth-авторизация (получение authorization_code + обмен на токен)
· Привилегированные действия (повышение роли + действие от имени новой роли)
· Удаление и доступ (удаление ресурса + чтение до завершения удаления)
Инструменты
А теперь вопрос, как же это всё делать?
Burp сам по себе предоставляет эту возможность через Repeater. Надо продублировать запросы, объединить их в группы и выставить запросы параллельно.

И также есть расширение Turbo Intruder, в котором ты можешь выбирать удобные скрипты для эксплуатации Race Condition и создавать свои.

Защита от Race Condition
Как компании защитить свои ресурсы?
-
Атомарные операции — объединение проверки и действия в одну неделимую операцию. Пока выполняется операция, никто другой не может вклиниться между шагами.
-
Блокировка строки в базе — пока один поток работает с записью, все остальные ждут своей очереди.
-
Ключ идемпотентности — к каждой операции присваивается уникальный ключ. Если операция с таким ключом уже выполнена, повторный запрос игнорируется.
-
Распределённые блокировки — блокировка на уровне всего многошагового процесса. Пока один поток проходит всю цепочку действий, никто другой не может начать ту же цепочку для того же пользователя.
-
Очереди — критичные операции обрабатываются строго по одной.
Практика
Практику по Race Condition можно получить
ссылка на оригинал статьи https://habr.com/ru/articles/1044830/