Состояния гонки и TOCTOU (CWE-362, CWE-367): как параллельные запросы обходят проверки и как это исправить
Код, который читает значение, проверяет его и затем записывает, позволяет двум запросам, пришедшим одновременно, оба пройти проверку, и подарочная карта погашается дважды или баланс уходит в минус. Как устроено окно между проверкой и действием, почему проверки в приложении не могут его закрыть и как один условный UPDATE ... RETURNING, блокировки строк и ограничения базы данных делают операцию атомарной, на примере Python и PostgreSQL.
Простыми словами (ELI5)
В кинотеатре две кассы и одно свободное место в ряду F. Оба кассира в одну и ту же секунду смотрят на схему зала, оба видят свободное место F7 и оба его продают. Каждый проверил перед продажей; проблема в том, что проверка и продажа были двумя разными моментами, а другой кассир действовал между ними. Исправление — единая пачка билетов, из которой должны отрывать оба: кто оторвёт последний билет F7, тот его и получит, а другой обнаружит пачку пустой. Проверка и взятие становятся одним действием.
Ключевые понятия и термины
- TOCTOU (от момента проверки до момента использования)
- Проверенное состояние может измениться до того, как его используют. В веб-коде проверка обычно — это
SELECT, а использование — более позднийUPDATE, и между ними выполняются другие запросы. - Окно гонки и параллельные запросы
- Окно часто длится несколько миллисекунд, но атакующий может выстроить десятки запросов так, чтобы они пришли одновременно. Исследование PortSwigger 2023 года показало, что отправка последних байтов многих запросов HTTP/2 одним пакетом заставляет их прийти с разницей примерно в миллисекунду.
- Атомарное условное обновление
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ...проверяет и меняет строку одной инструкцией. Строку получает только один вызывающий, остальные не получают ничего.- Блокировки строк при Read Committed
- В PostgreSQL второй
UPDATEтой же строки ждёт первую транзакцию, а затем заново вычисляет своё условиеWHEREдля зафиксированной строки. Именно эта повторная проверка делает условное обновление безопасным без повышения уровня изоляции. - Ограничения и ключи идемпотентности
- Индексы
UNIQUEиCHECK (balance_cents >= 0)заставляют проигранную гонку явно завершиться ошибкой, а не потратить средства дважды.Idempotency-Key, сохранённый под уникальным индексом, позволяет клиентам повторять платежи, не выполняя их дважды.
Пошаговый сценарий атаки
У атакующего одна действующая подарочная карта
У него единственная карта на фиксированную сумму и счёт, на который её зачислить.
Он отправляет много погашений одновременно
Двадцать запросов с одним кодом приходят в пределах миллисекунды. Два параллельных запроса с тестовым кодом TEST-0001 показывают этот случай.
Каждый запрос проходит проверку
Каждый SELECT выполняется раньше, чем какой-либо запрос отметит карту погашенной, поэтому все видят redeemed = false.
Баланс пополняется много раз
Каждый запрос добавляет сумму карты до того, как записан флаг. В 2015 году Егор Хомяков показал, что параллельные переводы между подарочными картами Starbucks позволяли создавать баланс из ничего.
Исходный код: Уязвимый vs Защищённый вариант
# gift_cards.py: проверка и использование — разные инструкции
from .db import pool
# каждый параллельный запрос может увидеть redeemed = false, пока никто не записал
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn:
card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
if card is None or card[2]:
raise ValueError("invalid or used card")
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
return card[1]
# gift_cards.py: проверка и использование в одной инструкции
from .db import pool
# строку получает только один; остальные ждут блокировку, и условие NOT redeemed для них не выполняется
def redeem_gift_card(user_id: int, code: str) -> int:
with pool.connection() as conn, conn.transaction():
card = conn.execute(
"UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
"WHERE code = %s AND NOT redeemed RETURNING balance_cents",
(user_id, code),
).fetchone()
if card is None:
raise ValueError("invalid or used card")
# зачисление в той же транзакции, оба изменения фиксируются вместе или ни одно
conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
return card[0]
Чек-лист по защите системы для инженеров
- Заменяйте последовательности «прочитать — проверить — записать» для денег, остатков, купонов и квот одним условным
UPDATE ... WHERE ... RETURNINGилиSELECT ... FOR UPDATEвнутри транзакции. - Подкрепляйте одноразовые действия ограничениями базы данных (
UNIQUE,CHECK (balance_cents >= 0)), чтобы проигранная гонка завершалась ошибкой, а не срабатывала дважды. - Принимайте
Idempotency-Keyв эндпоинтах оплаты и погашения и храните его под уникальным индексом. - Не полагайтесь на блокировки и флаги внутри процесса, если запросы обрабатывают несколько воркеров или серверов.
- Добавьте тест на конкурентность, который запускает 20 параллельных погашений одной тестовой карты и проверяет, что успешно ровно одно.