Race Condition – где архитектура ломает логику
В мире, наполненном SAST/DAST сканерами (инструментами статического и динамического анализа кода), допустить ошибку в написании кода с каждым днем становится все сложнее. Однако есть класс уязвимостей, которые могут появиться в результате работы как опытного программиста, так и обученной нейросети. Одна из таких уязвимостей — Race Сondition.

Что представляет из себя состояние гонки?
Race Condition, или состояние гонки, представляет из себя архитектурную уязвимость, возникающую там, где система предполагает последовательность действий, но в реальности выполняет их параллельно. В итоге логика, корректная в случае одиночного потока, становится уязвимой при параллельных запросах.
Технически уязвимость возникает в момент, когда проверка и использование данных разделены во времени. Система сначала читает состояние, затем принимает решение, и только после этого изменяет данные. Если между этими шагами другой поток успевает изменить состояние, оба запроса могут пройти проверку и выполнить действие.

Классический пример — проверка баланса перед списанием средств. Два параллельных запроса читают одно и то же значение, оба считают операцию допустимой и оба уменьшают баланс. Например, два человека одновременно снимают деньги с одной карты. Каждый запрос видит баланс до того, как другой успел его изменить. В одиночном потоке код работает корректно, но при их конкуренции возникает двойное списание.
Архитектурно эта уязвимость почти всегда возникает из паттерна read-modify-write. Сервис читает данные из базы, изменяет их в памяти и записывает обратно. Пока операция выполняется, другой поток может сделать то же самое. Ситуацию усугубляют микросервисные архитектуры, очереди сообщений, кэш перед базой данных и другие компоненты современных приложений. Каждый дополнительный сетевой переход увеличивает временное окно, в котором может возникнуть гонка.
С точки зрения эксплуатации, задача злоумышленника — увеличить вероятность совпадения. Он отправляет несколько параллельных запросов, инициирует повторные операции или создаёт искусственные задержки. Даже если окно гонки составляет миллисекунды, при достаточном количестве попыток оно становится воспроизводимым.
На практике race condition помогает злоумышленникам перебирать пароли, обходить ограничения на количество запросов, накручивать промокоды и ломать бизнес-логику сайта. В редких случаях состояние гонки может стать ключевым шагом для получения удалённого выполнение кода (Remote Code Execution).
Примеры
Рассмотрим классические примеры, где может быть примерена Race Condition.
TOCTOU
Наиболее распространённый сценарий — это классический Time-of-check to time-of-use. Проблема появляется, когда система проверяет условие отдельно от действия, что-то вроде проверки уникальности юзернейма в базе данных:
user = db.query("SELECT * FROM users WHERE username = ?", username) if not user: db.query("INSERT INTO users(username) VALUES(?)", username)При двух параллельных запросах оба
SELECTвернут пустой результат, и обаINSERTсоздадут дубликат.Race Condition при работе с файлами
Интересный сценарий, при котором RC может привести к получению удаленного выполнения кода или к повышению привилегий на уже скомпрометированной системе — работа с файлами.
<?php $upload_dir = "/var/www/uploads/"; $tmp_name = $_FILES["file"]["tmp_name"]; $name = basename($_FILES["file"]["name"]); $path = $upload_dir . $name; move_uploaded_file($tmp_name, $path); $content = file_get_contents($path); if (strpos($content, "<?php") !== false) { unlink($path); die("Malicious file detected"); } echo "Uploaded successfully";Что тут делается:
1. файл загружается
2. проверяется на наличие PHP-кода
3. если код найден — файл удаляется
Однако между проверкой и удалением есть небольшое временное окно, в которое можно успеть вызвать файл и получить выполнение кода.
Гонки в API
В таком сценарии уязвимость может позволить нам провести одну и ту же операцию дважды:
order = db.get_order(order_id)
if order.status == "pending":
order.status = "paid"
process_payment(order)
db.save(order)Для эксплуатации достаточно будет послать два параллельных запроса – каждый из них прочитает состояние status=="pending", затем каждый из них запустит process_payment
Исправление
Самый надёжный способ исправить такие уязвимости — перенести логику изменения в базу данных и выполнять всё в рамках одной транзакции: проверка и изменение данных должны быть одним действием, которое нельзя разорвать посередине. Когда обновление выполняется условно и атомарно (неразрывно), база данных сама гарантирует согласованность состояния, и никакой параллельный запрос уже не сможет проскочить между проверкой и изменением.
Альтернативно может быть использован архитектурный принцип single writer, при котором только один компонент в системе отвечает за изменение конкретного ресурса (баланса, счётчика и тд.), и гонка просто не может возникнуть из-за отсутствия конкуренции.
Заключение
Важно понимать, что Race Condition — это не баг конкретного языка или фреймворка. Это следствие того, что разработчик исходил из предположения, что запросы будут выполняться последовательно, а в реальности они приходят одновременно.
Во время ручных тестирований специалисты METASCAN неоднократно находили эту уязвимость в различных сценариях — от накрутки баланса с помощью промокодов, до брутфорса по многотысячному словарю при 5 доступных попытках ввода пароля.
Подписывайтесь на METASCAN News & PoC, чтобы больше узнавать о новых функциях, полезных фичах и главных новостях.