Featured image of post 403 Forbidden длиной 310 байт: разбор взломанного WordPress

403 Forbidden длиной 310 байт: разбор взломанного WordPress

Сайт отдавал 403 всем, кроме атакующего. Как я нашёл точку входа и бэкдор, имея только доступ в панель хостинга и логи.

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

Всё, что видел посетитель сайта

Оказалось что сайт взломали через уязвимость в ядре WordPress, поставили бэкдор в mu-plugins, и этот бэкдор сам отдавал 403 всем, у кого нет пароля атакующего. То есть страница выше нарисована не веб-сервером, а самим взломщиком. Ниже как я до этого дошёл и что сделал.

Все пути, домены и префиксы таблиц в статье условные.

Отсекаем очевидное

403 на весь сайт это обычно права на файлы, правило в .htaccess или блокировка хостингом. Права оказались нормальными (644 и 755), в .htaccess только стандартные правила WordPress, хостинг блокировку не ставил.

Зацепка была в другом, 403 прилетал и на robots.txt, и на картинки. Статику отдаёт веб-сервер напрямую, WordPress к ней отношения не имеет. Раз конфигурацию Apache я уже исключил, значит запрос перехватывает PHP до того, как дело доходит до отдачи файла.

Профилирование логов

Логи в таких случаях читать подряд бессмысленно, сначала нужна структура, сколько записей, за какой период, с каких адресов, какие ошибки повторяются.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
wc -l error_log

# кто стучался и сколько раз
grep -oP '\[client \K[0-9.]+' error_log | sort | uniq -c | sort -rn | head -20

# какие типы ошибок
grep -oP '(?<=\] )(WordPress database error [^f]{0,60}|PHP [A-Za-z ]+error[^:]*)' \
  error_log | cut -c1-90 | sort | uniq -c | sort -rn | head -20

# распределение по часам
grep -oP '\[Wed Aug 12 \d{2}' error_log | sort | uniq -c

Больше 180 записей за сутки, из них 161 однотипная, три адреса, один сделал 155 запросов за десять минут ночью. Практически весь лог состоял из ошибок базы данных.

Точка входа

У всех 161 записи один и тот же стек вызовов:

1
2
3
rest_api_loaded  WP_REST_Server->serve_request  serve_batch_request_v1
   serve_batch_request_v1 (вложенно, несколько раз)
   WP_REST_Posts_Controller->get_items  WP_Query->get_posts

Бьют в REST API, в эндпоинт /wp-json/batch/v1, внутрь которого вложены запросы к /wp/v2/posts. Итоговый SQL выглядел так:

1
WHERE 1=1 AND wp_posts.post_author NOT IN (1) UNION SELECT ...

Инъекция сидит в параметре author__not_in. Уязвимость оказалась в самом ядре WordPress. Это связка двух багов:

  1. SQL-инъекция в author__not_in. Если значение приходит строкой, а не массивом, проверка типа пропускается и значение уезжает прямо в SQL. Обычный REST-обработчик валидирует параметр author_exclude как массив целых чисел, строка до WP_Query не доходит.
  2. Рассинхронизация в /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.

Записи, оставшиеся после атаки

Кто и когда зашёл

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

Чужие администраторы в wp_users. Почта скрыта

Тридцать с лишним аккаунтов администратора, все со случайными суффиксами. Первый, 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. В начале лежало это:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
$_pw_hash = 'xxxxxxxxxxxx';
$inputPw = isset($_GET['password']) ? $_GET['password'] : '';
...
if ($inputPw !== $_pw_hash) {
    http_response_code(403);
    echo '<!DOCTYPE html><html><head><meta charset="utf-8"><title>403</title>'
       . '<style>body{background:#0a0a0f;color:#ef5350;...}</style></head>'
       . '<body><h1>403 Forbidden</h1></body></html>';
    exit;
}

А в конце файла безусловный вызов Purge::run();.

Нет параметра ?password=, значит 403 и exit, запрос до WordPress не доходит вообще. Отсюда и 403 на статику. Атакующий со своим паролем при этом работал с сайтом спокойно.

Это та самая страница, с которой начинается статья, длина заглушки ровно 310 байт, и во всех 403-х в access-логе в поле размера ответа стоит 310.

Устранение

Снять сайт с публики заглушкой на уровне хостинга, чтобы не работать по живому.

Найти всех администраторов. Тут есть грабля. ключ wp_capabilities содержит префикс таблиц конкретного сайта, и на нестандартном префиксе запрос по точному имени вернёт пустой результат.

1
2
3
4
5
SELECT u.ID, u.user_login, u.user_email, u.user_registered, m.meta_value
FROM wp_users u
JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key LIKE '%capabilities'
  AND m.meta_value LIKE '%administrator%';

Удалять точечно DELETE FROM wp_users WHERE ID != 1 снесёт покупателей, редакторов и второго настоящего админа вместе с привязкой заказов. Сначала разбивка по ролям, потом удаление по конкретным ID:

1
2
SELECT meta_value, COUNT(*) FROM wp_usermeta
WHERE meta_key LIKE '%capabilities' GROUP BY meta_value;

Вычистить файлы и контент: бэкдор из mu-plugins, записи с заголовком x, проверка wp-content/uploads на PHP (его там быть не должно), сверка контрольных сумм ядра, осмотр wp-config.php и .htaccess на посторонние вставки.

Обновить ядро до возврата сайта в онлайн. Иначе всё вернётся тем же путём, проверял явно через wp core version.

Сменить всё: пароли администраторов, пароль базы, доступы FTP и панели, соли в wp-config.php (это разлогинит всех, включая атакующего).

Что настроили, чтобы не повторилось

Список начинается не с паролей, потому что вход был анонимный, через дыру в ядре.

Автообновления. В wp-config.php:

1
define('WP_AUTO_UPDATE_CORE', true);

И системный крон на wp-cron.php в панели хостинга.

Запрет PHP в загрузках. Файл wp-content/uploads/.htaccess:

1
2
3
<FilesMatch "\.(?i:php|phtml|php[0-9]|phar|shtml)$">
    Require all denied
</FilesMatch>

Редактор кода из админки убрать:

1
define('DISALLOW_FILE_EDIT', true);

Даже с угнанной админкой нельзя будет вписать код в тему через браузер.

Закрыть batch-эндпоинт как временную меру, пока не обновились. Отдельным файлом в mu-plugins, чтобы не отключили из админки:

1
2
3
4
5
6
7
8
add_filter('rest_endpoints', function ($endpoints) {
    foreach (array_keys($endpoints) as $route) {
        if (strpos($route, '/batch/v1') === 0) {
            unset($endpoints[$route]);
        }
    }
    return $endpoints;
});

Удалить неиспользуемые плагины и темы. Неактивный плагин лежит на диске и уязвим ровно так же, многие атаки бьют прямо в его файл, минуя WordPress.

Проверить соседние сайты на том же аккаунте хостинга по тому же чеклисту, начиная с версии ядра.

Итог

Сайт вернули в работу тем же вечером. На разбор ушло несколько часов, из них большая часть на логи.

Ссылки

Создано при помощи Hugo
Тема Stack, дизайн Jimmy