● CWE-362 · CWE-367

Состояния гонки и 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, сохранённый под уникальным индексом, позволяет клиентам повторять платежи, не выполняя их дважды.

Пошаговый сценарий атаки

Шаг 1

У атакующего одна действующая подарочная карта

У него единственная карта на фиксированную сумму и счёт, на который её зачислить.

Шаг 2

Он отправляет много погашений одновременно

Двадцать запросов с одним кодом приходят в пределах миллисекунды. Два параллельных запроса с тестовым кодом TEST-0001 показывают этот случай.

Шаг 3

Каждый запрос проходит проверку

Каждый SELECT выполняется раньше, чем какой-либо запрос отметит карту погашенной, поэтому все видят redeemed = false.

Шаг 4

Баланс пополняется много раз

Каждый запрос добавляет сумму карты до того, как записан флаг. В 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]

Чек-лист по защите системы для инженеров

Источники

← Весь каталог по безопасности Все руководства по уязвимостям →