MAATRIX / Блог / Взломали через плагин, которым не пользовались два года

Взломали через плагин, которым не пользовались два года

MAATRIX

В конце августа мы разбирали инцидент на сайте клиента, арендующего у нас VPS: сервер вдруг начал рассылать спам, а в панели хостинга появилось предупреждение о подозрительном исходящем трафике. Никто ничего не «ломал» руками — вход был не через пароль и не через дыру в свежем коде, а через плагин, который отключили два года назад и с тех пор не вспоминали. Показываю по шагам, как это выглядело в логах, какие версии мы проверяли и отбрасывали, и что в итоге изменили на своих серверах и что стоит проверить у себя.

Что заметили первым

Первый сигнал пришёл не от админа сайта, а от хостинга: абузу на исходящий SMTP-трафик с сервера, который никогда не занимался рассылками. Дальше — по цепочке:

  • Zabbix показал скачок исходящих соединений на 25 и 587 порты, которых в норме на этом сервере почти не было.
  • top на сервере показывал несколько php-fpm воркеров с аномально высокой загрузкой CPU в промежутке между 2 и 5 ночи — не время, когда у сайта обычно есть трафик.
  • Сам сайт продолжал открываться и работать, что важно: это не был дефейс или явная порча контента, поэтому владелец узнал о проблеме не от посетителей, а от письма из хостинга.
  • В wp_users через пару часов разбора нашли лишнюю учётную запись с правами администратора — заведена автоматически, не через обычную форму регистрации.

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

Что показали логи

Начали с nginx access log — благо ротация логов была настроена на 30 дней, и старые записи ещё не успели уйти в архив. Первая находка: серия POST-запросов к пути внутри wp-content/plugins/, который в интерфейсе WordPress числился неактивным плагином.

94.x.x.x - - [POST /wp-content/plugins/pdf-price-gen/upload-logo.php] 200
185.x.x.x - - [POST /wp-content/plugins/pdf-price-gen/upload-logo.php] 200
77.x.x.x - - [POST /wp-content/plugins/pdf-price-gen/upload-logo.php] 200

Таких запросов с разных IP набралось несколько десятков за две недели до инцидента — похоже на автоматическое сканирование известных путей плагинов ботами, а не на целевую атаку. Основная масса запросов возвращала 200, что уже было странно: если плагин «выключен», по логике он не должен ничего обрабатывать.

Дальше нашли переломный момент — один из POST-запросов совпал по времени с появлением нового файла в wp-content/uploads/pdf-logos/, а следом сразу пошёл GET к этому файлу с расширением .php, тоже с кодом 200. Это была загрузка веб-шелла и его немедленный запуск — классическая связка «незащищённая загрузка файла плюс исполнение PHP в директории аплоадов».

Дальше по логам восстановили цепочку действий атакующего:

  • через веб-шелл добавлена запись в cron у пользователя www-data, дергающая внешний адрес каждые 15 минут;
  • через тот же шелл создан новый пользователь WordPress с правами администратора;
  • запущен php-скрипт рассылки — отсюда и исходящий SMTP-трафик, который заметил хостинг.

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

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Гипотезы, которые отбросили

Первая мысль в таких случаях — скомпрометировали пароль или ключ. Проверили по порядку:

Брутфорс wp-login.php. На сервере стоял fail2ban с джейлом под WordPress, лог банов не показывал всплеска попыток входа в интересующий период. Неудачных логинов в security-логе тоже не было — атака явно шла не через форму входа.

Скомпрометированный компьютер администратора. Плагин аудита действий в WP-админке показал: последний реальный вход администратора был за четыре дня до инцидента, с привычного IP, двухфакторная аутентификация не отключалась и не срабатывала повторно в подозрительное время. Версия отпала.

Уязвимость в ядре WordPress или теме. Сверили версию ядра с официальным списком контрольных сумм через wp core verify-checksums — расхождений не нашли, файлы ядра были нетронуты. Тема была кастомной, но git-история показывала, что в файлах темы не было изменений с последнего деплоя — значит, дело не в ней.

Уязвимость в одном из активных плагинов. Свежая версия WPScan по списку активных плагинов не показала совпадений с использованной техникой (загрузка файла без проверки типа). К тому же у большинства активных плагинов автообновления были включены, и версии были свежими.

SQL-инъекция через форму обратной связи. Включили временно general log MySQL, прогнали через форму несколько тестовых значений — запросы шли параметризованными, инъекции не было. К тому же лишний пользователь появился не через прямую вставку в таблицу, а через обычный вызов wp_insert_user() — то есть через код, а не напрямую в базу.

На каждую гипотезу ушло от 20 минут до часа — врозь это немного, но если проверять не по порядку, а хаотично, разбор легко растягивается на день. Из этого разбора у нас родилось отдельное правило для чек-листа реагирования — держать порядок проверки гипотез, а не метаться. Общий план на такой случай мы собрали в статье что делать при взломе.

Как нашли реальную причину

Когда очевидные версии отпали, вернулись к первой находке в access-логе — путь wp-content/plugins/pdf-price-gen/upload-logo.php. Через wp plugin list --status=inactive подтвердили: плагин pdf-price-gen числится в WordPress как неактивный, установлен был два года назад для разовой кампании (генерация PDF-прайсов с логотипом клиента), потом от него отказались и просто выключили тумблер в админке — удалять не стали, «а вдруг понадобится снова».

Дальше — важный технический момент, который и стал сутью инцидента. Отключение плагина в WordPress работает на уровне самого движка: WordPress перестаёт подключать файл плагина через свою систему хуков (admin_init, wp_ajax_* и так далее). Но если у плагина внутри есть отдельный PHP-файл, который не обращается к ядру WordPress напрямую — не проверяет ABSPATH, не зависит от того, активен плагин или нет, — то веб-сервер как ни в чём не бывало продолжает его исполнять по прямому запросу. Файл upload-logo.php был написан именно так: самостоятельным скриптом, который принимал $_FILES и сохранял файл в wp-content/uploads/pdf-logos/, вообще не спрашивая WordPress, включён ли родительский плагин.

Проверили это руками:

curl -F "file=@test.png" https://site.example/wp-content/plugins/pdf-price-gen/upload-logo.php

Запрос отработал — файл принялся, несмотря на то что в панели плагин два года как «выключен». Это и был реальный вход.

Второй элемент проблемы — сам upload-скрипт не проверял тип содержимого файла, только смотрел на расширение в имени, и то не строго: примешать двойное расширение или обойти простую regex-проверку было несложно. А директория wp-content/uploads/pdf-logos/ у сайта не была исключена из исполнения PHP на уровне nginx — то есть загруженный .php файл не просто лежал на диске, а мог быть запущен по прямому URL.

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

Почему «отключили» — не значит «убрали»

Стоит проговорить отдельно, потому что это ловушка, в которую попадают не только новички. Панель администратора показывает состояние плагина глазами самого WordPress: активен/неактивен, конфликтует/не конфликтует, есть обновление или нет. Но она ничего не говорит о том, что физически лежит в wp-content/plugins/ и какие из этих файлов доступны напрямую по HTTP в обход движка.

На практике такое всплывает не только с покупными и самописными плагинами:

  • заброшенные тестовые скрипты, которые разработчик когда-то положил рядом «для отладки» и забыл;
  • старые версии плагинов, оставшиеся после обновления, если апдейт делали копированием новой версии в соседнюю папку, а не заменой;
  • демо- и заглушки-эндпоинты, которые разработчик плагина оставил «на всякий случай» и не убрал даже из официальной поставки.

Мы разбирали похожую логику в статье про взлом WordPress через уязвимость в самом сайте — там же есть общий список типичных точек входа: почему взламывают сайты на WordPress. Разница в том, что в этот раз проблема была не в активном коде, который кто-то обязан был патчить, а в коде, который считался «выключенным» и поэтому выпал из зоны внимания.

Что сделали сразу после обнаружения

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

  1. Изолировали сервер от внешнего трафика на уровне файрвола, оставив доступ только по SSH с доверенных адресов — чтобы прекратить рассылку и не потерять улики раньше времени.
  2. Сняли копию файловой системы и БД «как есть» для разбора — на случай, если понадобится вернуться к деталям позже.
  3. Удалили веб-шелл, вредоносную cron-запись и лишнего администратора.
  4. Полностью снесли неиспользуемый плагин pdf-price-gen — не отключили, а удалили файлы физически.
  5. Сверили все активные плагины и ядро с эталонными контрольными суммами ещё раз, уже пристальнее — убедились, что компрометация не пошла дальше загруженного шелла.
  6. Сменили все секреты: пароли WordPress-пользователей, ключи и соли (AUTH_KEY, SECURE_AUTH_KEY и остальные в wp-config.php), пароль от БД, SSH-ключи с доступом к серверу.
  7. Восстановили сайт из бэкапа, сделанного до первого успешного POST-запроса к уязвимому скрипту — важно было отсчитать точку восстановления не «на всякий случай за вчера», а именно до начала компрометации, иначе легко откатиться на уже заражённую копию.

Отдельно проверили, что сам бэкап не «заражён» — сверили хэши файлов ядра и активных плагинов в восстановленной копии с эталоном, прежде чем снова открывать сайт наружу.

Что изменили после разбора

Разовая чистка снимает симптом, но не причину — причина была системной: файлы неиспользуемого кода годами лежали доступными снаружи. Изменения делали на уровне процесса, а не только конкретного сайта:

  • Правило «выключил — значит удали». Деактивированный плагин или тема хранится не дольше недели, дальше — либо возвращается в работу, либо файлы удаляются целиком. Тумблер в админке перестал считаться достаточной мерой.
  • Запрет исполнения PHP в директориях загрузок на уровне веб-сервера — независимо от того, что происходит с плагинами. Правило в nginx:
location ~* /wp-content/uploads/.*\.php$ {
    deny all;
}

Это защита не от конкретного плагина, а от целого класса уязвимостей «загрузили файл — выполнили как код», даже если завтра всплывёт дыра в каком-то другом компоненте.

  • Периодическая сверка файлов на диске со списком, который знает WordPress. Раз в неделю cron сравнивает find wp-content/plugins -maxdepth 1 -type d с выводом wp plugin list и присылает уведомление, если на диске есть директории, которых WordPress не видит в своём реестре, — то есть установленные вручную, не через штатный механизм.
  • Контроль целостности файлов. Настроили auditd на отслеживание записи в wp-content вне окна деплоя — подробно о том, как это делается, у нас есть статья настройка аудита логов сервера через auditd.
  • Регулярное сканирование уязвимостей самого сайта снаружи, а не только зависимостей — обзор инструментов для этого есть в статье сканирование уязвимостей сервера: инструменты.
  • Централизованное и более долгое хранение логов веб-сервера — локальные логи с ротацией в 30 дней в этот раз хватило, но чуть позже разведка ботов могла бы уйти за это окно и её было бы не восстановить.

Ни одна из этих мер не была экзотикой — большинство пунктов уже фигурировало в общих гайдах по защите сервера, но именно этот инцидент заставил перевести их из «неплохо бы» в обязательный чек-лист для всех сайтов клиента, а не только для пострадавшего.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Почему WordPress не блокирует прямой доступ к файлам отключённого плагина?

Потому что деактивация — это состояние внутри самого WordPress (список хуков и опций в базе), а не файловое разрешение. Веб-сервер ничего не знает о состоянии плагинов и продолжает отдавать и исполнять любой PHP-файл, до которого дотягивается прямой запрос, если это явно не запрещено на уровне конфигурации сервера.

Как быстро понять, есть ли на сервере такие «мёртвые» файлы?

Сравните список директорий в wp-content/plugins и wp-content/themes с тем, что показывает wp plugin list и wp theme list. Дополнительно стоит вручную проверить содержимое директорий загрузок (wp-content/uploads) на посторонние .php-файлы — в норме там не должно быть исполняемого кода вообще.

Достаточно ли просто деактивировать неиспользуемый плагин, чтобы обезопасить сайт?

Нет, как показал этот разбор. Деактивация не гарантирует, что все файлы плагина недоступны напрямую — надёжнее удалять их физически, если функциональность точно не понадобится в ближайшее время, и держать резервную копию плагина отдельно от продакшен-директории, если хочется сохранить возможность вернуть его позже.

Как отличить сканирование ботами от целевой атаки, если в логах много одинаковых POST-запросов?

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

Нужно ли после такого инцидента менять хостинг или сервер целиком?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →