Уязвимость race condition в плагине WordPress для бронирования билетов
WordPress остается одной из самых популярных CMS‑платформ, а большая часть его возможностей работает через плагины — формы, оплата, бронирование, интеграции. Сегодня создать такой плагин стало намного проще: рабочий код можно получить с помощью LLM даже без глубокого опыта в PHP и безопасной разработке.
Проблема в том, что «работает» не значит «безопасно». Нейросеть может реализовать основной сценарий, но пропустить проверки прав доступа, конкурентные запросы или некорректные параметры. Так в плагинах появляются IDOR, race condition, type juggling и другие логические уязвимости.
Рано или поздно такие уязвимости находят и эксплуатируют, причем далеко не всегда исследователи, которые предупредят разработчика об угрозе. В этой статье рассмотрим пример из нашей практики. Мы нашли и разобрали race condition в одном из таких плагинов, вышедшем несколько дней назад.
О плагине
Исследованный плагин превращает страницу мероприятия в форму регистрации: гость вводит имя, email, телефон, выбирает количество билетов и получает QR‑код на вход. У организатора есть настройка capacity — сколько мест доступно всего.
Что мы проверили
Эксперты BI.ZONE WAF протестировали плагин для бронирования билетов следующим образом: создали тестовое мероприятие с capacity = 1 — форма сообщила: «Only 1 ticket left». После этого отправили восемь параллельных запросов на бронирование. Но вместо одного успешного заказа и семи отклоненных получили в итоге восемь успешных заказов.
Логика уязвимости
Плагин проверяет, есть ли еще место, и отдельно от этого резервирует билет, а между этими двумя действиями проходит время. Вот сам фрагмент кода (имена классов и файлов изменены, чтобы не указывать на конкретный плагин):
$left = Event::seats_left( $event ); // SELECT остатка мест
// Проверяем, хватает ли мест на запрошенное количество
if ( $quantity > $left ) {
// Если нет – возвращаем ошибку
return new WP_Error( 'capacity_exceeded', ... );
}
$wpdb->insert( self::table(), $row ); // обычный INSERT, без блокировки
SELECT и INSERT здесь — два независимых обращения к базе, не объединенных транзакцией и никак не блокирующих строку события. WordPress обрабатывает входящие запросы параллельно: пока один запрос читает остаток, проверяет гостей, применяет скидку и готовится вставить заказ, второй, третий, восьмой запросы успевают выполнить тот же SELECT и получить то же самое число. Каждый из них по отдельности видит, что место свободно, и каждый проходит проверку.
Как эксплуатируется
Никаких специальных инструментов или точного попадания в миллисекундное окно не требуется: разрыв между чтением и записью в коде достаточно широкий, поскольку между ними плагин еще успевает провалидировать гостей и применить скидку.
Мы воспроизвели это так: на тестовое мероприятие с capacity = 1 отправили восемь параллельных POST‑запросов на бронирование — фоновыми PowerShell‑джобами, каждая со своим независимым HTTP‑запросом к форме регистрации. Все восемь получили HTTP 200 Success=True, то есть сервер обработал каждый как самостоятельный успешный запрос, не отклонив его на этапе проверки лимита. В итоговом состоянии базы это дало восемь заказов вместо ожидаемого одного — лимит в одно место был обойден семь раз подряд.
То есть для эксплуатации достаточно уметь отправить несколько запросов на форму бронирования одновременно любым способом, дающим параллельные HTTP‑запросы (несколько вкладок браузера, синхронный скрипт с параллельными потоками, curl в фоне). Порог входа минимальный, а окно гонки открывается на каждой попытке бронирования лимитированного мероприятия, а не только при целенаправленной атаке.
Как это можно исправить
Решение, есть свободное место или нет, должен принимать не PHP‑код по устаревшим данным, а база данных — в момент записи, одной неделимой операцией:
$updated = $wpdb->query( $wpdb->prepare(
"UPDATE {$events} SET taken = taken + %d
WHERE id = %d AND taken + %d <= capacity",
$quantity, $event->id, $quantity
) );
if ( ! $updated ) {
// Возвращаем ошибку, если мест не хватило
return new WP_Error( 'capacity_exceeded', ... );
}
// После успешного обновления вставляем запись бронирования
$wpdb->insert( self::table(), $row );
UPDATE … WHERE taken + quantity ≤ capacity проверяет условие и резервирует место одной SQL‑командой над одной строкой — MySQL сериализует параллельные изменения одной строки сам, поэтому два одновременных запроса физически не могут оба получить affected rows > 0. Кто не успел, получает отказ, а не заказ поверх лимита.
Мы сообщили о проблеме разработчику. Он оперативно отреагировал, поблагодарил за находку, и на момент публикации уязвимость уже устранена в новой версии.
Гипотеза: почему баг выглядит именно так
У нас нет подтверждения, что плагин написан с использованием LLM. Но сама природа бага характерна для кода, который выглядит правильным при обычном последовательном тестировании (один пользователь — одна бронь — все работает), но не учитывает параллельные запросы. Это типичный результат, когда задача сформулирована как «сделай бронирование мест с лимитом» без явного уточнения про конкурентный доступ. И человек, и LLM в такой формулировке чаще всего выдадут последовательную проверку «прочитать → сравнить → записать» — самый прямой способ решить задачу, если про race condition отдельно не спросили. Не был учтен эффект масштабирования решения и особенности его реальной эксплуатации.
При этом тот же инструмент справляется и с обратной задачей: если попросить нейросеть проверить код на race condition, она подсветит место, где проверка и запись разнесены во времени, и предложит атомарный вариант с UPDATE … WHERE. Разница здесь не в модели, а в том, поставлена ли задача с учетом безопасности и проверил ли результат человек, который понимает, что такое race condition.
Почему это не единичный случай
Дело не в конкретном разработчике и не в конкретном плагине. Программисты при современной скорости разработки вручную построчно вычитывают код перед релизом все реже — не хватает времени. Вместо этого полагаются на инструменты: линтеры, SAST‑сканеры, LLM для ревью и генерации кода. Эти инструменты ускоряют работу, но они хороши ровно настолько, насколько правильно поставлена задача и насколько внимательно человек потом проверяет результат. Статический анализатор без четко сформулированного запроса разработчика не заметит атомарность операции с базой данных и не оценит возможность конкурентного доступа к ней. В итоге ошибка, которую раньше выявили бы на code review, попадает в продакшен, потому что проверить работоспособность кода проще и быстрее, чем его безопасность.
И это не разовый случай, а массовое явление. По данным Patchstack, в 2025 году около 91% новых уязвимостей во всей экосистеме WordPress пришлось именно на плагины, а не на ядро CMS. Это тысячи находок в год — IDOR, race condition, инъекции, обход авторизации — в продуктах, которые устанавливают на боевые сайты одним кликом.
Важен и экономический аспект: в случае с разобранной уязвимостью восемь заказов на одно место — это овербукинг у владельца сайта. Ему придется либо возвращать деньги за лишние билеты, либо в последний момент искать места еще для семи человек. И то и другое — прямые убытки, а также испорченное впечатление от билетного сервиса.
Мораль простая: любой плагин, который вы ставите на свой сайт, — это чужой код с полным доступом к вашей базе данных. Прежде чем добавлять новую функциональность, стоит посмотреть, кто и как давно поддерживает плагин, была ли у него история уязвимостей. Если плагин работает, это не значит, что он безопасен.