WordPressБезопасностьMySQL

WordPress снова взломан после чистки: разбор реального случая с бэкдорами

Взломали сайт на WordPress, а после чистки заражение вернулось? Разбор реального случая: 7 бэкдоров в 4 слоях, из-за которых взлом годами возвращался после чисток хостинга. Что делать и как их найти: контрольные суммы ядра, прямые SQL-запросы, ручной аудит mu-plugins.

Результат7 бэкдоров в 4 слоях - найдены и закрыты
Александр Бутаков
Александр Бутаков
обновлено 17 мин чтения

Заходишь на любой URL со словом «admin» в адресе, и тебя тихо логинит под администратором. Без пароля, без формы входа, вообще без единого запроса к базе на ввод. Просто открываешь /wp-admin/ и уже сидишь в панели под чужой учёткой. Именно такой бэкдор нашёлся на одном из двух сайтов, разбор которых ниже. Он объясняет то, что обычно списывают на невезение: сайт чистят, а через несколько дней он снова заражён.

Ниже: как выглядит такой случай изнутри, почему стандартная чистка взломанного WordPress («откатить файлы, сменить пароль») почти всегда проигрывает такому уровню компрометации, и конкретная методика проверки, которая эту компрометацию всё-таки находит.

Кратко. Сайт на WordPress взламывали годами (в базе нашлись «служебные» учётные записи администраторов, заведённые ещё в 2022-2023), при этом каждая чистка хостинга (откат файлов ядра, смена пароля) давала эффект максимум на несколько дней. Причина: заражение жило сразу в четырёх слоях, в файлах плагинов и тем, в mu-plugins, в базе данных (wp_options) и в подменённом файле ядра WordPress. Файловый скан и восстановление ядра из бэкапа закрывают только первый и четвёртый слой; база данных и mu-plugins при этом остаются нетронутыми, и доступ восстанавливается за один визит на нужный URL. Ниже: полный список найденных бэкдоров и рабочая методика проверки, сверка контрольных сумм ядра, прямые SQL-запросы к таблицам пользователей и опций, ручной аудит mu-plugins.

Контекст

Клиент - сайт баскетбольного клуба на WordPress с заметной посещаемостью и ИКС 400 по Яндексу. Проект рабочий, с живой аудиторией. Много лет взломы повторялись с завидной регулярностью. Хостинг периодически «лечил» сайт: откатывал файлы ядра из бэкапа, иногда переустанавливал WordPress целиком. Уже через несколько дней сайт снова начинал отдавать поисковым ботам и мобильным посетителям спам вместо контента, а десктопным пользователям - обычную страницу. Классическая doorway-схема: показывать разное содержимое в зависимости от того, кто смотрит.

В этот раз всё вскрылось иначе: редактор сайта в очередной раз не смог зайти в панель управления, логин и пароль просто не подходили. При этом весь остальной сайт работал как обычно: страницы открывались, контент отображался, обычный посетитель не заметил бы ничего странного. Разгадка этого несоответствия дальше в статье.

Владелец был уверен, что дыра в SSH-доступе: пароль от аккаунта хостинга лежал открытым текстом в заметке и «наверняка утёк». Проверка логов и файловой системы этого не подтвердила. Реальная картина оказалась куда интереснее и куда хуже.

Почему обычная чистка не срабатывает

Хостинг-провайдеры, которые «лечат» заражённый сайт автоматически, почти всегда работают на одном уровне, файловой системе. Восстанавливают ядро WordPress из чистого архива, иногда чистят wp-content/uploads от подозрительных .php. Это ловит самый примитивный тип заражения: одиночный веб-шелл, залитый через уязвимую форму загрузки.

Но в этом случае бэкдор жил сразу в четырёх разных слоях:

  1. Файлы плагинов и тем. Обычный веб-шелл, тот самый, что ловит любой антивирусный скан.
  2. mu-plugins («must-use» плагины). Код, который WordPress подгружает автоматически. В обычном списке плагинов его нет (только на отдельной вкладке «Must-Use»), и отключить его из админки нельзя.
  3. База данных. Payload, лежащий в значении опции wp_options, который подгружался через eval(get_option(...)). На диске его не было.
  4. Само ядро WordPress. Один из системных файлов wp-admin/ был полностью подменён.

Файловый скан находит первый слой. Возврат ядра из бэкапа частично лечит четвёртый, но переустановка WordPress не трогает базу данных вообще. А без базы остаются нетронутыми и mu-plugins (они физически лежат в wp-content/mu-plugins/, который автоматический реинсталл ядра не задевает), и скрытые администраторы в таблице пользователей. Через день-два после «чистки» у злоумышленника снова есть полный доступ: он просто заходит через оставшийся ход и заново заливает то, что почистили.

Как злоумышленник мог попасть внутрь в первый раз

Логи хранились всего 14 дней, так что доказательно подтвердить самую первую точку входа (то, что случилось до этого окна) нельзя. Но в файловой системе осталась одна конкретная улика: у плагина с полноценным файловым менеджером (на базе elFinder) каталог кэша миниатюр имел права на запись для кого угодно, 777 вместо обычных 755. Это ровно тот тип слабости, через который такие менеджеры чаще всего и эксплуатируют. Если сам компонент доступен без строгой проверки прав или несёт собственную незакрытую уязвимость, злоумышленник получает возможность загрузить произвольный файл прямо в веб-корень.

Прямого лога, который бы это подтвердил, нет. Это рабочая гипотеза, а не установленный факт. Но она согласуется с остальной картиной: сама природа найденных бэкдоров (файловые менеджеры, дропперы, копии, расползающиеся по другим директориям) предполагает, что у атакующего с самого начала была запись в файловую систему шире, чем дала бы одна форма на сайте. Практический вывод не зависит от того, подтвердится ли гипотеза когда-нибудь: если на сайте стоит мощный файловый менеджер, доступный извне, это риск сам по себе, была это точка входа в этот раз или нет.

Какие бэкдоры нашли на взломанном WordPress

Клоакинг в главном файле сайта

index.php сайта был переписан: перед стандартной загрузкой WordPress добавлена проверка User-Agent. Мобильным браузерам и известным поисковым ботам отдавался контент с внешнего сервера, десктопным посетителям - обычная страница. Владелец сайта видел совершенно нормальный ресурс каждый раз, когда заходил проверить; спам видели только поисковики и часть живых посетителей, то есть именно та аудитория, которая до владельца сама эту проблему не донесёт. У Google для этого есть отдельный термин в правилах о спаме: клоакинг, когда пользователям и поисковой системе показывают разное содержимое.

Полноценный набор веб-шеллов под видом плагина

В каталоге плагинов лежала папка с говорящим названием вроде «Security Scan Lite», и внутри был целый набор из пяти скриптов: файловый менеджер с загрузкой/скачиванием/редактированием любых файлов, обработчик произвольных shell-команд через popen(), и отдельный дроппер, который сканировал файловую систему в поисках любой директории, доступной на запись, и раскладывал туда собственные копии. Каждый файл маскировался под правдоподобное описание («WordPress Media Library Handler», «WordPress Site Health Data») и требовал секретный токен в запросе. Обычный скан на «подозрительные файлы» такое пропускает, потому что вредоносный код синтаксически выглядит как обычный PHP, без явных маркеров вроде eval(base64_decode(...)).

Бэкдор, замаскированный под патч безопасности

В mu-plugins лежал файл с комментарием «Security Patch - REST API hardening». На деле он блокировал доступ к одному из REST-маршрутов WordPress для всех запросов, кроме тех, что несли конкретный секретный заголовок. Логика простая и по-своему рациональная с точки зрения атакующего: закрыть дыру, через которую влез сам, от конкурентов и автоматических сканеров, оставив себе персональный ключ.

Скрытые администраторы WordPress

В таблице пользователей нашлись учётные записи, которых владелец никогда не создавал. На одном из сайтов дополнительный mu-plugin активно скрывал такого пользователя из списка «Пользователи» в админке, из REST API и из карты сайта, фильтруя его ID на уровне SQL-запроса. Открыть админку и «проверить пользователей глазами» в такой ситуации бесполезно: страница физически не показывает то, что нужно скрыть.

Почему пропадает доступ в админку, а сайт работает как ни в чём не бывало

Это объясняет ситуацию из начала статьи, когда редактор не мог войти в панель, хотя весь остальной сайт открывался нормально. Никакой мистики: в таблице wp_users не хватало ровно тех двух учётных записей, под которыми обычно заходили реальные администраторы. Их удалили. Логин и пароль владельца оставались правильными, просто самих учётных записей больше не существовало, и WordPress закономерно отвечал «неверные данные для входа».

При этом лента новостей, страницы, карточки, всё, что видит обычный посетитель, рендерится из wp_posts и вообще не завязано на существование какого-либо администратора. Это две почти независимые части WordPress: одна отвечает за то, что видит посетитель, другая за то, кто может это менять. Сломать вторую, не затронув первую, для атакующего с доступом к базе тривиально.

Это расчёт. Сломай атакующий весь сайт, владелец заметит в течение часа и начнёт разбираться. Сломай он только вход в панель, оставив фронтенд работать штатно, жалобы будут поступать только от тех немногих, кому реально нужно попасть внутрь, а сам взлом тем временем продолжает приносить пользу: клоакинг, о котором шла речь выше, существует именно для того, чтобы обычные посетители не увидели вообще ничего подозрительного. Чем дольше владелец не понимает, что происходит, тем дольше держится доступ.

Перехват пароля прямо в момент входа

Отдельный mu-plugin подключался к фильтру authenticate, той самой точке, где WordPress видит логин и пароль в открытом виде до хеширования, и записывал пару в скрытый файл. Смена пароля через админку тут не помогает вообще, потому что новый пароль улетает туда же при следующем же входе владельца. Пароль нужно менять только после удаления перехватчика.

Payload, которого нет на диске

Самый неочевидный слой: в опции wp_options лежала строка с исполняемым PHP-кодом, а в файле активной темы одна строка eval(get_option('...'));, которая эту строку выполняла на каждой загрузке сайта. Файл темы при этом синтаксически абсолютно чист, весь вредоносный код живёт в базе. Файловый антивирус или ручной просмотр исходников такое не находит в принципе: искать нужно в содержимом таблицы опций.

Подмена файла ядра WordPress

Здесь бэкдор занял место системного файла: один из файлов wp-admin/ был полностью переписан. Внутри тот самый механизм из начала статьи: если в URL встречается слово «admin» и посетитель не авторизован, скрипт бутстрапит WordPress, берёт из базы первого попавшегося администратора и логинит под ним, wp_set_auth_cookie(), wp_set_current_user(), редирект в панель. Всё это через str_rot13() и массивы chr()-кодов вместо явных строк функций, так что обычный текстовый поиск по словам вроде wp_redirect или is_user_logged_in такой код не находит.

Как найти скрытый бэкдор в WordPress: методика проверки

Вирус на сайте WordPress в таком случае поиском по одному файлу не найти. Стандартный чек-лист «поискать eval(base64_decode по всем файлам» ловит от силы один из семи найденных бэкдоров, потому что у остальных шести не было этой конкретной сигнатуры вообще: где-то код читался из базы и в файле текстом не хранился, где-то не было ни eval, ни base64_decode, а был обычный file_put_contents или popen, где-то файл маскировался под легитимный код без единого явного маркера. Что реально сработало:

Сверка контрольных сумм ядра. У WordPress.org есть публичный API, который отдаёт официальные MD5-суммы всех файлов ядра для конкретной версии:

Text
https://api.wordpress.org/core/checksums/1.0/?version=X.Y.Z&locale=en_US

Небольшой PHP-скрипт, который сверяет каждый файл ядра (кроме wp-content/) с этим списком, находит подменённый файл за секунды, независимо от того, что внутри него спрятано и как замаскирован код.

Прямой запрос к базе на предмет «лишних» администраторов, минуя админку целиком:

SQL
SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON u.ID = m.user_id
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%';

Список из этого запроса нужно сверять с тем, кого владелец сайта реально создавал. Экран wp-admin/users.php для сверки не годится: его легко подделать.

Поиск исполняемого кода в таблице опций:

SQL
SELECT option_name, LEFT(option_value, 100)
FROM wp_options
WHERE option_value LIKE '%<?php%'
   OR option_value LIKE '%eval(%'
   OR option_value LIKE '%base64_decode%';

Ручная проверка wp-content/mu-plugins/ целиком, файл за файлом, с чтением содержимого. Системно звучащее имя файла ничего не доказывает. WordPress загружает mu-plugins без всякой активации и раньше обычных плагинов.

Расширенный набор сигнатур для поиска обфусцированного кода. Помимо очевидных eval(base64_decode, system($_POST, стоит искать паттерны обфускации сами по себе: str_rot13(, array_map('chr', использование $_COOKIE['PHPSESSID'] в качестве ключа шифрования. Ни один из этих паттернов сам по себе не доказывает вредоносность, но кластер из нескольких в одном небольшом файле, который выдаёт себя за системный, это стабильный сигнал.

Как проверить WordPress на вирусы через WP-CLI

Если на хостинге есть SSH и WP-CLI, сверку ядра писать самому не нужно. Команда wp core verify-checksums скачивает с WordPress.org MD5-суммы для установленной версии и сравнивает с файлами на диске. Для плагинов есть wp plugin verify-checksums, с флагом --all она проверяет все установленные.

Bash
wp core verify-checksums
wp plugin verify-checksums --all

Эталон у обеих команд один, WordPress.org. Поэтому сверить получится только ядро и плагины из официального каталога. Плагины не из каталога WordPress.org, собственный код в mu-plugins и база данных под такую проверку не попадают, для них остаются SQL-запросы и ручное чтение из списка выше.

Почему онлайн-проверка на вирусы не видит такие бэкдоры

Онлайн-сканер проверяет сайт снаружи: запрашивает страницы и смотрит, что сайт отдаёт браузеру. Например, в описании Sucuri SiteCheck сам разработчик пишет, что удалённый сканер видит только то, что доступно на уровне браузера, ничего на стороне сервера не обнаружит, а результат не гарантирован.

Почти всё найденное в этом случае жило на стороне сервера: исполняемый код в wp_options, файлы в mu-plugins, веб-шеллы, которые отвечают только на запрос с секретным токеном, подменённый файл в wp-admin/. Заметнее всего снаружи был клоакинг, и то не всем: спам получали поисковые боты и мобильные браузеры, а проверка с обычного десктопного браузера показывала нормальную страницу. Файл с автологином тоже реагировал на внешние запросы, но только на URL со словом «admin», который сканеру незачем открывать. В этот раз внешних признаков никто не заметил, взлом обнаружили, только когда не получилось войти в админку.

В Google Search Console для этого есть отчёт «Проблемы безопасности», в Яндекс Вебмастере есть раздел «Безопасность и нарушения», и подключить их стоит: это внешний сигнал от самого поисковика. Но уведомлений о взломе владелец не получал ни от Google, ни от Яндекса. Чистый онлайн-скан и пустой отчёт о безопасности в общем случае говорят только о том, что эти инструменты снаружи ничего не нашли. Проверять сайт на вирусы всё равно придётся изнутри, по методике выше.

Как на практике выглядела чистка и почему файл возвращался

Первая попытка была наивной: найти вредоносные файлы и удалить. Через несколько секунд один из веб-шеллов, тот, что лежал в каталоге стандартного плагина Hello Dolly, появился заново, с тем же именем и почти тем же размером.

Логичная реакция («значит, где-то есть ещё один источник, который его пересоздаёт») оказалась только частью правды. Полная блокировка сайта на уровне веб-сервера (deny all) файл тоже не остановила: он продолжал появляться, хотя прямой HTTP-доступ к нему был закрыт. На этом этапе версии были одна тревожнее другой: скрытый процесс на сервере, задание в системном планировщике, что-то, что переживает блокировку конкретного сайта.

Разгадка оказалась проще и одновременно куда показательнее. На том же хостинг-аккаунте жил второй сайт, WordPress-клон первого, оставшийся от старой миграции, и он ещё не был заблокирован. Оба сайта делят одну файловую систему на уровне операционной системы: один и тот же пользователь хостинга владеет файлами обоих. Живой трафик ко второму, всё ещё открытому сайту раз за разом обращался к тому же уязвимому файловому менеджеру, а тот писал файл туда, куда был жёстко запрограммирован заранее, то есть и в первый сайт тоже, за пределы каталога второго.

Заблокировали второй сайт, respawn прекратился. Но стоило открыть первый сайт снова для посетителей (файловый менеджер к тому моменту был уже полностью удалён с диска), файл вернулся ещё раз, уже через 30 секунд. Стало ясно, что мигрировавшим клоном дело не ограничивалось и фантомного процесса тоже не было: живой внешний трафик продолжал находить рабочий вход. Помогло только удаление каталога плагина hello-dolly целиком. У скрипта, который писал файл заново, не было логики «создать директорию, если её нет», и без каталога запись перестала получаться.

Два вывода из этой истории не так очевидны заранее. Если бэкдор продолжает появляться после чистки, причина не обязана быть мистической: стоит в первую очередь проверить, не делит ли сайт файловую систему с чем-то ещё на том же хостинг-аккаунте. И отключённый плагин в WordPress продолжает лежать на диске: его файлы доступны по прямому URL независимо от статуса в базе, пока их не убрать физически.

Взломали сайт: что делать по порядку

Если сайт взломали, сначала закройте его от посетителей и сохраните заражённое состояние. Потом проверьте все четыре слоя: файлы, mu-plugins, базу данных и ядро. Пароли меняйте только после удаления перехватчиков. Порядок важен: часть шагов теряет смысл, если поменять их местами.

  1. Изолируйте сайт немедленно. Пока идёт проверка, сайт не должен быть доступен посетителям и ботам, иначе можно чистить и находить новые копии одновременно, гоняясь за собственным хвостом. Простейший вариант - правило Require all denied в .htaccess (или аналог на уровне nginx), которое снимается только после полной проверки.
  2. Не доверяйте автоматической чистке хостинга как финальному шагу. Она возвращает файлы к чистому состоянию, но не трогает базу данных и почти никогда не проверяет mu-plugins. С неё можно начать, но на ней нельзя останавливаться.
  3. Сохраните заражённое состояние, прежде чем удалять. Скопируйте подозрительные файлы в отдельную папку вне веб-корня и сделайте дамп таблиц wp_users, wp_usermeta, wp_options до чистки. Если позже всплывёт что-то новое, будет с чем сравнивать.
  4. Проверьте все четыре слоя, даже если первый след нашёлся только в одном. Файлы плагинов/тем, mu-plugins, базу (пользователей и опции), ядро через контрольные суммы. Конкретные запросы и команды в разделе выше.
  5. Меняйте пароли только после удаления перехватчиков. Если в системе остался код, слушающий фильтр authenticate, новый пароль достанется ему при первом же входе.
  6. Смените и пароль от базы данных, не только пароли пользователей WordPress: при полном доступе к сайту атакующий видел wp-config.php целиком.
  7. Проверьте логи веб-сервера за период предполагаемого заражения. Конкретные IP и запросы к найденным файлам подтвердят масштаб (заходили ли под другими путями, кроме уже найденных) и помогут понять, закрыт ли вход.
  8. Возвращайте сайт в сеть постепенно. После первой же чистки понаблюдайте за файловой системой и списком пользователей несколько часов: при живом трафике переиспользуемый вход себя проявит быстро, если он остался.
  9. Закройте сам вход, если удалось его определить: устаревший или избыточно мощный плагин, разрешение на запись у чужого процесса, слабый пароль. Без этого шага весь список выше просто откладывает повтор.

Как предотвратить повторение

Ничего из перечисленного не гарантирует стопроцентную защиту (такого не существует), но закрывает именно те ходы, которые обычно и используются повторно:

  • Обновляйте ядро, плагины и темы, но помните: обновление закрывает уязвимость, а уже установленный бэкдор остаётся на месте. Перед любым обновлением делайте бэкап: даже смена версии PHP может уронить и сайт, и админку, как это было после перехода на PHP 8.3.
  • Удаляйте неиспользуемые плагины и темы, включая те, что идут в комплекте с WordPress по умолчанию. Неактивный код тоже код, и он остаётся на диске, даже если WordPress его не запускает.
  • Не держите в проде плагины с полноценным файловым менеджером (на базе elFinder и подобных), если без них можно обойтись. Если такой плагин действительно нужен, ограничьте доступ к нему по IP на уровне веб-сервера.
  • Используйте SSH-ключи вместо пароля там, где хостинг это позволяет. Если доступ только по паролю, не храните его в открытом текстовом файле ни локально, ни в облаке.
  • Включите двухфакторную аутентификацию для входа в wp-admin.
  • Не держите несколько сайтов на одном хостинг-аккаунте, если можете этого избежать. Общий аккаунт - это общая файловая система: взлом одного сайта на аккаунте открывает запись в каталоги всех остальных.
  • Проверяйте контрольные суммы ядра и список mu-plugins регулярно, не дожидаясь подозрений. Это разовый скрипт, который можно поставить на еженедельный запуск.
  • Держите резервные копии отдельно от аккаунта, который бэкапите. Бэкап, который лежит на том же скомпрометированном аккаунте, заражается вместе с сайтом.
  • Ограничьте wp-login.php и отключите xmlrpc.php, если вы им не пользуетесь. Это стандартные точки перебора паролей и DDoS через pingback.

Что из этого стоит унести

Компрометация, которая длится годами, почти никогда не держится на одной дыре. Она держится на многослойности: даже если закрыть исходную уязвимость и почистить очевидное, остаётся достаточно параллельных ходов, чтобы восстановить полный доступ за один визит на нужный URL. Практический вывод простой: после любой находки веб-шелла стоит по умолчанию считать заражение многослойным, пока не проверены как минимум ядро (через контрольные суммы), база данных (пользователи и опции) и mu-plugins, даже если первый след нашёлся в обычных файлах.

FAQ

Что делать, если сайт взломали? Закрыть сайт от посетителей, сохранить копию заражённых файлов и таблиц, проверить файлы, mu-plugins, базу данных и ядро, а пароли менять только после удаления перехватчиков. Полный порядок шагов в разделе «Взломали сайт: что делать по порядку».

Почему сайт взламывают снова после чистки? Обычно потому, что чистка закрыла только часть ходов. Если бэкдор остался в базе данных, в mu-plugins или в виде скрытого администратора, атакующий заходит через него и заново заливает то, что удалили.

Почему смена пароля не остановила взлом? Потому что пароль не единственный ход внутрь. Если в системе остался скрытый администратор, код в mu-plugins или перехватчик на фильтре authenticate, новый пароль либо не нужен атакующему вообще, либо будет украден заново при следующем входе владельца.

Можно ли проверить сайт на вирусы онлайн-сервисом? Онлайн-сервис увидит только то, что сайт отдаёт браузеру. Код в базе данных, в mu-plugins или в файлах за секретным токеном он не найдёт, поэтому чистый результат онлайн-проверки не значит, что сайт чист.

Хостинг сказал, что почистил сайт, этого достаточно? Стандартная автоматическая чистка хостинга обычно работает только с файлами и ядром. Она не проверяет wp_options на предмет исполняемого кода и не ищет посторонних администраторов в базе. Эти два места стоит проверить отдельно, даже после того, как хостинг отчитался об очистке.

Нужно ли переустанавливать WordPress с нуля? Переустановка ядра полезна и решает часть проблемы, но не заменяет проверку базы данных и mu-plugins, переустановка их не трогает. Полная переустановка сайта с нуля и восстановлением контента из заведомо чистого источника - самый надёжный вариант, но не всегда практически возможен.

Как понять, что сайт полностью чист? Прогнать проверку контрольных сумм ядра, свериться со списком администраторов, проверить wp_options на исполняемый код и вручную прочитать mu-plugins, а после этого понаблюдать за файловой системой при включённом сайте хотя бы несколько часов. Закрывать инцидент сразу после чистки рано.

Если у вас похожая ситуация

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

Статья была полезной?

Нужна помощь с похожей задачей?

Поддержка сайтов →