Ко мне обратились с сайтом на WordPress, который отдавал 403 Forbidden на любой адрес, включая админку. Хостинг блокировку не ставил, разработчика у сайта нет. Из доступов только личный кабинет хостинга: файловый менеджер, phpMyAdmin и логи.

Оказалось что сайт взломали через уязвимость в ядре WordPress, поставили бэкдор в mu-plugins, и этот бэкдор сам отдавал 403 всем, у кого нет пароля атакующего. То есть страница выше нарисована не веб-сервером, а самим взломщиком. Ниже как я до этого дошёл и что сделал.
Все пути, домены и префиксы таблиц в статье условные.
Отсекаем очевидное
403 на весь сайт это обычно права на файлы, правило в .htaccess или блокировка хостингом. Права оказались нормальными (644 и 755), в .htaccess только стандартные правила WordPress, хостинг блокировку не ставил.
Зацепка была в другом, 403 прилетал и на robots.txt, и на картинки. Статику отдаёт веб-сервер напрямую, WordPress к ней отношения не имеет. Раз конфигурацию Apache я уже исключил, значит запрос перехватывает PHP до того, как дело доходит до отдачи файла.
Профилирование логов
Логи в таких случаях читать подряд бессмысленно, сначала нужна структура, сколько записей, за какой период, с каких адресов, какие ошибки повторяются.
| |
Больше 180 записей за сутки, из них 161 однотипная, три адреса, один сделал 155 запросов за десять минут ночью. Практически весь лог состоял из ошибок базы данных.
Точка входа
У всех 161 записи один и тот же стек вызовов:
| |
Бьют в REST API, в эндпоинт /wp-json/batch/v1, внутрь которого вложены запросы к /wp/v2/posts. Итоговый SQL выглядел так:
| |
Инъекция сидит в параметре author__not_in. Уязвимость оказалась в самом ядре WordPress. Это связка двух багов:
- SQL-инъекция в
author__not_in. Если значение приходит строкой, а не массивом, проверка типа пропускается и значение уезжает прямо в SQL. Обычный REST-обработчик валидирует параметрauthor_excludeкак массив целых чисел, строка доWP_Queryне доходит. - Рассинхронизация в
/batch/v1. Запрос валидируется по схеме одного эндпоинта, а выполняется обработчиком другого. Проверка проходит там, где параметр вроде как безобиден, а исполняется там, где он попадает в SQL.
Отсюда и вложенный serve_batch_request_v1 в стеке. Вместе два бага дают анонимному запросу выполнение кода.
Затронуты версии 6.9.0 до 6.9.4 и 7.0.0 до 7.0.1 (полная цепочка до RCE), исправлено в 7.0.2.
Инъекция не отбита, она работает

Видно, что именно пытались подсунуть HTML с подписью hacked by trenggalek6etar. Свою заглушку на главную он поставить не успел, но если загуглить ник trenggalek6etar, то гугл выдаст много сайтов, которые были взломаны и так и висят с надписью hacked by trenggalek6etar на главной.
Другие попытки из лога: веб-шелл через UNION SELECT UNHEX(...) INTO OUTFILE с перебором типовых путей вроде /var/www/html/ (мимо, реальный путь на шареде выглядит как /home/c/userXXXX/site_ab12/public_html/, да и привилегия FILE там обычно не выдана), запись в customize_changeset для закрепления и запрос хеша из таблицы wp_duplicator_packages, чтобы скачать архив с полной копией сайта и базы по прямой ссылке.
Сама по себе инъекция только читает данные, писать в базу через неё нельзя. Но она позволяет подсунуть WordPress поддельные объекты записей, и дальше он сохраняет их в базу уже сам. По той же цепочке он в какой-то момент повторно выполняет запрос на создание пользователя, который до этого отклонил, но уже с правами администратора. Так у атакующего и появляется свой админ.
Следы этого видно прямо в админке: девять пустых записей с заголовком x, автором admin и датой 01.01.2020.

Кто и когда зашёл
Дальше я посмотрел таблицу пользователей.

Тридцать с лишним аккаунтов администратора, все со случайными суффиксами. Первый, 1ae9367587c9, зарегистрирован 22 июля, дальше по несколько штук в неделю вплоть до 11 августа. Настоящий владелец на сайте один, admin от 2022 года.
Логины говорят сами за себя: w2s_..., wp2shell_..., admins.... Тот же wp2shell мне попался в access-логе как User-Agent.
Теперь понятно, почему сайт вообще попал под удар. Патч вышел 17 июля, первый чужой аккаунт появился 22 июля. Уязвимость опубликовали, PoC выложили на GitHub, начались массовые сканы и сайт нашли скриптом.
Причина, по которой обновление не приехало, нашлась в wp-config.php: там стоял DISABLE_WP_CRON, а системный крон вместо него никто не настроил. Планировщик отвечает в том числе за установку обновлений, и сайт просто перестал обновляться.
Откуда взялся 403
В логе причины 403 не было. Записи client denied by server configuration относились только к xmlrpc.php, это старая защита самого владельца. Раз не Apache, остаётся PHP.
В wp-content/mu-plugins/ лежал файл с невнятным именем, датированный днём инцидента, must-use плагины WordPress подгружает при каждом запросе, раньше обычных плагинов, и отключить их через админку нельзя, они идут отдельной вкладкой без кнопки деактивации.
Внутри заголовок LiteSpeed Cache - Purge Handler и строка eval(gzinflate(base64_decode('...'))). Распаковал ту же конструкцию вручную, заменив eval на file_put_contents, и получил 875 строк PHP. В начале лежало это:
| |
А в конце файла безусловный вызов Purge::run();.
Нет параметра ?password=, значит 403 и exit, запрос до WordPress не доходит вообще. Отсюда и 403 на статику. Атакующий со своим паролем при этом работал с сайтом спокойно.
Это та самая страница, с которой начинается статья, длина заглушки ровно 310 байт, и во всех 403-х в access-логе в поле размера ответа стоит 310.
Устранение
Снять сайт с публики заглушкой на уровне хостинга, чтобы не работать по живому.
Найти всех администраторов. Тут есть грабля. ключ wp_capabilities содержит префикс таблиц конкретного сайта, и на нестандартном префиксе запрос по точному имени вернёт пустой результат.
| |
Удалять точечно DELETE FROM wp_users WHERE ID != 1 снесёт покупателей, редакторов и второго настоящего админа вместе с привязкой заказов. Сначала разбивка по ролям, потом удаление по конкретным ID:
| |
Вычистить файлы и контент: бэкдор из mu-plugins, записи с заголовком x, проверка wp-content/uploads на PHP (его там быть не должно), сверка контрольных сумм ядра, осмотр wp-config.php и .htaccess на посторонние вставки.
Обновить ядро до возврата сайта в онлайн. Иначе всё вернётся тем же путём, проверял явно через wp core version.
Сменить всё: пароли администраторов, пароль базы, доступы FTP и панели, соли в wp-config.php (это разлогинит всех, включая атакующего).
Что настроили, чтобы не повторилось
Список начинается не с паролей, потому что вход был анонимный, через дыру в ядре.
Автообновления. В wp-config.php:
| |
И системный крон на wp-cron.php в панели хостинга.
Запрет PHP в загрузках. Файл wp-content/uploads/.htaccess:
| |
Редактор кода из админки убрать:
| |
Даже с угнанной админкой нельзя будет вписать код в тему через браузер.
Закрыть batch-эндпоинт как временную меру, пока не обновились. Отдельным файлом в mu-plugins, чтобы не отключили из админки:
| |
Удалить неиспользуемые плагины и темы. Неактивный плагин лежит на диске и уязвим ровно так же, многие атаки бьют прямо в его файл, минуя WordPress.
Проверить соседние сайты на том же аккаунте хостинга по тому же чеклисту, начиная с версии ядра.
Итог
Сайт вернули в работу тем же вечером. На разбор ушло несколько часов, из них большая часть на логи.
Ссылки
- CVE-2026-63030, путаница маршрутов в batch-эндпоинте
- CVE-2026-60137, SQL-инъекция в
author__not_in - Разбор от нашедших цепочку
- Хронология эксплуатации после выхода патча, Patchstack