Promo Code Race
Состояние гонки при активации промокодов (PHP).
mediumphpPro
Задача
# Race Condition: TOCTOU при активации промокода
## Сценарий
SecureShop — интернет-магазин на PHP, в котором реализована система промокодов: при активации действующего кода баланс пользователя пополняется на фиксированную сумму. По задумке каждый промокод должен активироваться **только один раз на одного пользователя**, однако в реализации обработчика `/cart/coupon` есть классическая уязвимость **Race Condition (TOCTOU)** — Time-of-Check to Time-of-Use.
Между моментом проверки «использовал ли уже этот пользователь данный купон?» и моментом записи факта использования существует временной промежуток. В этот промежуток несколько параллельных запросов могут одновременно пройти проверку — каждый из них увидит, что купон ещё не был применён, — и независимо друг от друга начислят бонус. В результате одноразовый купон срабатывает многократно, а на счёте появляется сумма, кратная номиналу купона.
## Цели
1. Исследуйте логику обработчика применения промокода — как сервер проверяет, что купон ещё не использован, и как фиксирует факт его использования.
2. Найдите способ многократно активировать один и тот же промокод от лица одного пользователя — без подмены параметров, без обхода CSRF, исключительно за счёт свойств параллельной обработки запросов.
3. Накопите достаточно средств для покупки служебного товара **CTF Flag** ($9999), оформите заказ и получите флаг со страницы профиля.
## Теория
**TOCTOU (Time-of-Check Time-of-Use)** — класс уязвимостей гонок состояний, при которых между моментом проверки условия и моментом фактического использования его результата существует временное окно, в которое состояние может измениться. Типичный паттерн — «прочитать → проверить → действовать», когда эти шаги выполняются как отдельные SQL-запросы без транзакции и без блокировки строки. При параллельном доступе несколько запросов успевают пройти шаг проверки до того, как любой из них завершит запись, и все они действуют на основании одного и того же устаревшего состояния.
**Уязвимый PHP-паттерн** в обработчике применения купона: проверка факта использования и фактическая запись о применении вынесены в две отдельные SQL-операции. Внутри обработчика последовательность такая — сначала read-запрос проверяет, есть ли уже запись в таблице использованных купонов для этой пары `(user_id, code)`, и только потом отдельный write-запрос фиксирует использование и начисляет бонус. Между read и write нет атомарного контекста.
CHECK и USE — два **независимых** SQL-запроса. Между ними нет ни транзакции с немедленным захватом write-lock, ни блокировки строки, ни UNIQUE-constraint'а на паре `(user_id, code)`. SQLite в WAL-режиме допускает множественные одновременные read-запросы, поэтому все параллельные клиенты успевают прочитать «не использовано» ещё до того, как любой из них выполнит INSERT в таблицу использованных купонов.
При параллельной отправке N запросов на `/cart/coupon` сервер обрабатывает их в разных процессах PHP-FPM / потоках. Все они успевают выполнить шаг CHECK до того, как любой завершит USE; все видят «не использовано»; все проходят проверку; все вызывают INSERT + UPDATE баланса. Итог: одноразовый промокод срабатывает столько раз, сколько запросов попало в окно — баланс растёт кратно номиналу купона.
## Точка входа атаки
| Параметр | Значение |
|----------|----------|
| Учётные данные | `demo` / `demo` (начальный баланс $500) |
| Уязвимый endpoint | `POST /cart/coupon` (параметры `code`, `csrf_token`) |
| Промокоды | `PROMO50` ($50), `PROMO100` ($100) |
| Целевой товар | CTF Flag за $9999 |
| Завершение | `POST /cart/add` → `POST /cart/checkout` → `GET /profile` |
## Подсказки к среде
- Сессия хранится в cookie `session_token`; CSRF-токен находится в HTML-странице `/profile` в поле `name="csrf_token"`.
- Внутри `redeemCoupon()` стоит `usleep(300000)` — искусственная задержка 300 мс перед записью использования, имитирующая обращение к внешнему сервису. Эта задержка расширяет окно гонки и упрощает эксплуатацию.
- Эндпоинт возвращает HTTP 302 на `/profile` или `/cart` независимо от результата — судите об успехе атаки по изменению баланса.