После восстановления сайт клиента снова открывался, страницы вернулись, картинки загружались. Только в футере вместо иконки адреса красовалась панель фильтров товаров, а у 54 страниц в canonical стоял адрес вида /?page_id=101. Обе проблемы я получил уже в процессе ремонта, своими руками.

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

С чего всё началось

В постоянной работе у меня 13 сайтов небольших компаний. В начале сентября на одном из них пропали фотографии товаров, страницы услуг стали отдавать 404, а вместо главной показывалась запись из блога. Одновременно пришло письмо от хостинга: антивирус нашёл и удалил вредоносные файлы. Домен не называю, сайт клиентский.

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

  • 24 июля. В каталоге плагинов появляется «инструмент доступности» с обработчиком, который принимает команду параметром запроса. Во время июльской чистки его не заметили: он выглядел как установленный плагин, а не как посторонний файл.

  • 29 июля, 22:32. Через пять дней после смены пароля создаётся администратор с именем вида support_a73db02f3003.

  • 2–3 августа. Заливаются 66 поддельных плагинов и 56 пустых тем с однотипными именами. Каждый умеет ровно две вещи: принять файл и сообщить, под каким пользователем работает.

  • 10 августа. Я захожу на сайт по другой задаче, ставлю фильтр от ботов в формах. Фильтр сделал, а список пользователей и плагинов не посмотрел. Вот это моя недоработка: о недавнем взломе я знал, и проверить эти два списка стоило независимо от текущей задачи.

  • 17–27 августа. С разных адресов ставятся файловый менеджер, обфусцированный плагин удалённой публикации и плагин с подписанными REST-эндпоинтами для вставки блоков в футер.

  • 29 августа, 17:54. Последовательность действий: вход, правка профиля, установка ещё двух плагинов, создание двух администраторов, обращения к аналитике заказов WooCommerce и настройкам оплаты. Затем удаление пользователя admin и ещё одной учётной записи.

Хроника между двумя чистками
Хроника между двумя чистками

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

Что удаление пользователя сделало с сайтом

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

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

На этом сайте получилось следующее:

  • 59 страниц оказались в корзине, включая главную. Настройка «главная страница сайта» слетела на вывод последних записей, поэтому на месте главной и открывался пост из блога.

  • 192 объекта медиатеки удалились вместе со связанными файлами.

  • У 56 записей исчезли связи с изображениями превью.

Что тянет за собой удаление одного пользователя
Что тянет за собой удаление одного пользователя

Страницы при включённой корзине ещё можно вернуть. С вложениями сложнее: корзина для них по умолчанию отключена, а при окончательном удалении wp_delete_attachment() удаляет и файлы, включая созданные размеры изображений. Поэтому 192 объекта медиатеки — не обязательно 192 файла на диске, их там больше.

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

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

Почему не подошёл полный откат из бэкапа

Дамп базы был двухнедельной давности. За эти две недели на сайте появились заказы, заявки и правки. Подменить рабочую базу старой означало вместе с восстановленным контентом устроить ещё одну потерю данных.

Поэтому дамп развернули рядом, в той же базе под отдельным префиксом таблиц, и использовали для сравнения. Статусы 59 страниц я вернул прямым SQL-запросом, ориентируясь на дамп: опубликованные снова опубликовали, черновики оставили черновиками, вышло 60 и 5. Главную заново назначили в настройках чтения.

Затем восстановили 192 записи вложений, 387 строк их метаданных и связи с превью. Здесь есть момент, который легко упустить: в базе хранится только путь к изображению, а сама фотография лежит на диске. После возврата записей WordPress снова знает, где её искать, но по указанному адресу пока ничего нет.

С файлами помог хостинг: у него оказались ночные копии за 30 дней. В копии за 29 августа фотографии ещё были, резервирование прошло ночью, а удаление вечером. Из неё вернули uploads: 1 194 файла общим объёмом 212 МБ. Это объём восстановленного каталога целиком, его не стоит путать с числом удалённых объектов медиатеки.

Папки плагинов из старой копии не возвращали. Сам uploads тоже требует проверки на вредоносные файлы: название каталога ничего не гарантирует.

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

Первая ошибка: нашёл файл с тем же именем

Пока выгружался тяжёлый архив, мне захотелось сэкономить время. Я поискал недостающие изображения по именам в других каталогах, нашёл b1.png и b2.png и скопировал их на место. Файлы существуют, имена совпадают, на первый взгляд всё в порядке.

На самом деле это были демонстрационные картинки из плагина фильтров товаров. В результате в футере вместо скромной иконки адреса раскорячилась панель фильтрации. Я проверил, что файл есть, но не проверил, что именно в нём нарисовано.

Через 13 минут восстановление из бэкапа перезаписало эти файлы правильными. А ещё через пару часов клиент пишет, что в футере что-то чужое. На сервере уже всё правильно, а у него по-прежнему панель фильтров.

Причина оказалась в кеше. Изображения отдавались со сроком кеширования на год. Тем, кто открыл сайт в эти 13 минут, браузер сохранил неправильную картинку и продолжал показывать её после замены файла на сервере: URL не изменился, значит, можно взять из кеша.

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

Я хотел сэкономить время на поиске нормального бэкапа. Потратил его на разбор чужой картинки в футере.

Вторая ошибка: страница открывается, значит, восстановлена

С возвратом страниц всё выглядело ещё убедительнее. Запрос выполнен, статусы восстановлены, страницы открываются, текст на месте. Открываю исходный код страницы услуг и вижу в canonical адрес вида /?page_id=101 вместо /uslugi/.

Оказалось, что я восстановил основные данные, а производные данные Yoast остались в прежнем состоянии. Плагин хранит SEO-сведения об объектах сайта в собственных таблицах indexables. При обычном изменении записи срабатывает цепочка обработчиков WordPress. Прямой UPDATE в базе эту цепочку не вызывает.

Поэтому страница уже открывалась как опубликованная, а Yoast продолжал отдавать сведения на момент аварии. У 54 страниц неправильными оказались canonical, og:url и идентификаторы в микроразметке. Проверка «ссылка открывается» этого не показывает вообще никак.

Данные Yoast пересобрали точечно, для затронутых страниц. Для полной пересборки у плагина есть штатная команда WP-CLI:

wp yoast index --reindex

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

После исправления восстановленные адреса отправили на переобход.

Вывод, который я забрал себе: после восстановления через SQL надо проверять и исходный HTML, то есть canonical, Open Graph, микроразметку и ссылки на изображения. И заранее выяснять, какие производные данные хранят установленные плагины и как они обновляются. У другого SEO-плагина механизм может отличаться от Yoast.

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

Помимо восстановления контента, удалили четырёх посторонних администраторов, переназначили контент своей учётной записи и обновили соли аутентификации, чтобы старые cookie входа стали недействительными. Подозрительные плагины сохранили для разбора вне каталога, доступного через веб-сервер. У владельца теперь отдельная учётная запись.

Штатную установку и изменение файлов через админку закрыли настройкой DISALLOW_FILE_MODS. Она заодно отключает редактор плагинов и тем, отдельно задавать DISALLOW_FILE_EDIT для этого не требуется. У ограничения есть цена: нужен контролируемый порядок обновлений.

Перед wp-login.php добавили Basic Auth с отдельным паролем. Это дополнительный барьер на входе, не двухфакторная аутентификация и не защита всех остальных путей доступа. И оговорюсь честно: уже оставленный вредоносный код перечисленные ограничения не обезвреживают.

Что закрыли после разбора
Что закрыли после разбора

И ещё две команды, которые стоило выполнить 10 августа, когда я заходил ставить фильтр от ботов:

wp user list --role=administrator
wp plugin list

Полного аудита они не заменяют, но посторонних администраторов я увидел бы за минуту, за три недели до того, как удалили контент.

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

Если сталкивались с похожим восстановлением, интересно, какие ещё данные плагинов приходилось пересобирать после прямых изменений в базе.

Комментарии (8)


  1. dimas846
    10.09.2026 07:19

    Есть ли догадки каким образом взломали?


    1. itmaks Автор
      10.09.2026 07:19

      Точно не скажу, первый вход был ещё до меня.

      Второй заход разобрал по логам, там сошлись две вещи.

      Первая: июльская чистка пропустила закладку. Лежала она в плагинах, с виду обычный «инструмент доступности», а внутри обработчик, который принимает команду прямо в параметре запроса. В uploads, куда лезут первым делом, было чисто. Правда, стрелять эта штука уже не могла: хостинг за три дня до её появления прикрыл своим .htaccess прямой доступ к php внутри wp-content/plugins. Я в сентябре специально в неё постучался, получил 403.

      Вторая, главная: пароль утёк повторно, уже после смены. Видно по неудачным входам: весь день одна подсеть долбилась в wp-login, и в поле пароля у неё лежал адрес самого сайта, log=admin&pwd=https://… Кто-то разобрал строку url:login:password со сдвигом на поле. Это формат стилерских выгрузок, то есть паролей из браузера. Доказать нечем, но только это и объясняет, почему после смены пароля вход снова оказался открыт и почему дальше шли с разных адресов, каждый со своим набором плагинов.

      С тех пор сверяю плагины и темы со списком того, что там должно стоять. Ну и DISALLOW_FILE_MODS, Basic Auth на wp-login, отдельная учётка владельцу.


      1. krasmg_avr
        10.09.2026 07:19

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

        Перед wp-login.php добавили Basic Auth с отдельным паролем.

        К сожалению это не помогло бы избежать взлома, так как уязвимость была в api. На одном из сайтов у клиента Basic Auth на всю wp-admin, и он так-же был взломан.
        А вот своевременное обновление возможно помогло бы)

        Тоже вычищал всё руками

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

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

        В плагинах было подобное:
        /wp-content/plugins/site-health-c459570f9cce/site-health-c459570f9cce.php
        В загрузках по типу такого было много файлов:
        /wp-content/uploads/w2sx3c9f1e.php.jpg
        /wp-content/uploads/wp-cache**.php.jpg и phtml

        В загрузках по типу такого было много файлов:/wp-content/uploads/w2sx3c9f1e.php.jpg/contell.ru/web/wp-content/uploads/wp-cache**.php.jpg

        В базе данных тоже, на медиа файлы:

        В базе данных тоже

        и куча таких записей, не разобрался как они работают (blockquote+iframe:

        и куча таких записей

        Возможно они должны были встраиваться на страницы, но ничего такого не заметил.

        Много администраторов, много каких то записей похожих на индийский спам, и пару вот таких записей в блоге)

        Страницы в базе тоже все были осмотрены.
        В общем, повезло чуть больше - ничего не удалили, ничего не изменили, только намусорили, определенно это были просто автоматические боты.

        На всех сайтах так-же установлено define("DISALLOW_FILE_MODS", true);, но это не помогло. С такой уязвимостью и не должно было, так как через шелл они могли сделать всё что угодно. Но возможно некоторым ботам это помешало что-то автоматически напакостить.