Типичная заявка звучит так: «сайт редиректит на казино, в админке куча неизвестных плагинов, Wordfence ничего не нашёл / Wordfence нашёл 400 файлов, я нажал Repair, через сутки опять». Иногда без редиректа: просто «гугл пометил», «хостер прислал abuse», «в Search Console странные URL», «письма клиентам уходят с левыми ссылками».
Я не начинаю с установки ещё одного антивируса в плагины. Сканер внутри заражённого WordPress — это программа, которой уже могли подменить файлы, отключить хуки и спрятать строки в базе. Он полезен как подсказка. Он не является разбором инцидента.
Ниже — порядок, которым я хожу по взломанному WP, когда нужно не «почистить то, что нашёл сканер», а понять, чем держится зараза, откуда зашли и что сделать, чтобы после смены пароля админа сайт не поднял шелл снова через час. Примеры путей и имён — типовые, не с конкретного клиента. Хосты и IP в логах подставляйте свои, чужие из интернета копировать вместе с «готовым лечением» не стоит.
Сначала стоп, снимок, доступ мимо wp-admin
Первое желание — зайти в админку и «поудалять левых пользователей». Если в теме или в mu-plugins сидит стилер куку, вы только подтвердите злоумышленнику, что хозяин на сайте, и отдадите свежую сессию.
Пока сайт публичный и пишет в те же файлы, вы затираете улики. Поэтому в первые минуты я делаю три вещи, и только потом открываю редактор.
Снять снимок, который можно откатить. На VPS — снапшот диска у хостера плюс архив каталога сайта и дамп базы на свою машину, не «бэкап плагином на этот же wp-content/uploads». На shared-хостинге — хотя бы tar корня сайта и mysqldump через панель или SSH. Если снимка нет, любое удаление файла потом не докажет, что именно он слал спам.
Посадить сайт на заглушку для посетителей, админку не оставлять открытой в интернет. Варианты, которые работают:
- В nginx/apache — отдать 503 на все PHP, кроме своего IP. Не «maintenance mode плагином»: его как раз и обходят.
- Временно подменить
index.phpв корне на статическую заглушку, оригинальный файл сохранить какindex.php.origрядом. Корень WP трогать аккуратно: не путать сindex.phpтемы. - На хостинге без SSH — закрыть каталог через панель /
.htaccessпо IP, если хостер это умеет. Если не умеет — хотя бы сменить пароли FTP и панели до того, как начнёте качать файлы на домашний ПК.
Дальше захожу по SSH или SFTP. wp-admin использую только когда уже понял, что сессия оттуда не исполняет чужой PHP. Почту, Telegram-ботов сайта и SMTP-плагин на время инцидента тоже считаю скомпрометированными: с них часто уходит фишинг от имени магазина.
Пока не закрыли вход, параллельно снимаю «кто ещё живой»: соседние сайты на том же аккаунте, cron пользователя, панели ISPmanager/cPanel. Один шелл в соседнем каталоге /var/www/site2 через час снова положит «уже почищенный» WP.
Что считать уликой, а не ощущением
«Выглядит странно» — плохой критерий. Мне нужны время, путь и факт изменения. Минимальный набор, который я забираю целиком, до того как начну удалять:
- Корневой
wp-config.php,.htaccessв корне, вwp-adminи вuploads(их часто дописывают отдельно). - Drop-in файлы в
wp-content:object-cache.php,advanced-cache.php,db.php,sunrise.php. Их сканеры ядра не считают «ядром», и туда любят класть одну строкуinclude. - Каталог
wp-content/mu-plugins— must-use подключается всегда, без галочки в админке. Если зараза там, «деактивировать плагины» ничего не делает. - Список пользователей ОС и crontab:
crontab -l,/etc/cron.d/,/var/spool/cron/. PHP-шелл, который раз в пять минут дописывает себя вindex.php, переживает любую «очистку файлов сайта». - Логи веб-сервера за неделю-две, не за последний час. Нужны access и error, плюс PHP-fpm slowlog, если есть. POST на странный
.phpв uploads — классика.
По файлам смотрю не «все php в uploads», а свежие и с чужим владельцем:
# каталог сайта подставьте свой, не копируйте путь с чужого сервера SITE=/var/www/example-site/public_html find "$SITE" -type f -mtime -14 -printf '%TY-%Tm-%Td %TH:%TM %u:%g %p\n' | sort find "$SITE/wp-content/uploads" -type f \( -name '*.php' -o -name '*.phtml' -o -name '*.phar' -o -name '.*.php' \) -print find "$SITE" -type f \( -name '.*.ico' -o -name 'wp-*.php' -o -name '*timthumb*' \) -path '*/uploads/*' -print ls -la "$SITE/wp-content/" "$SITE/wp-content/mu-plugins" 2>/dev/null ls -la "$SITE/wp-content/"object-cache.php "$SITE/wp-content/"advanced-cache.php "$SITE/wp-content/"db.php 2>/dev/null
Свежий wp-login.php с датой «сегодня» при том, что ядро вы не обновляли — уже повод сверить контрольные суммы, а не «ну это же ядро, его не трогают». Свежий файл в wp-includes с именем вроде wp-vcd.php / class-wp-http-proxy.php рядом с настоящим классом — ещё чаще.
Владелец тоже врёт, если зараза работает от php-fpm того же пользователя, что и сайт. Тогда все файлы «как надо». Смотрите дату и содержимое, не только ls -l.
Ядро сверяю с эталоном, плагины — с zip из репозитория
WordPress умеет сам сказать, какие файлы ядра не совпали. С заражённой админки этой кнопке я не доверяю: подменённый update-core.php вам улыбнётся и покажет зелёное. С хоста, где есть wp-cli и исходящий HTTPS, или просто скачав zip той же версии:
# версию брать из wp-includes/version.php, не «ну наверное 6.4» grep wp_version "$SITE/wp-includes/version.php" # на чистой машине: # curl -fsSLO "https://wordpress.org/wordpress-X.Y.Z.zip" # unzip и rsync -avnc --delete ядра БЕЗ wp-content и без wp-config.php wp core verify-checksums --path="$SITE" wp plugin verify-checksums --all --path="$SITE" wp theme verify-checksums --all --path="$SITE"
verify-checksums по плагину работает только для того, что лежит в wordpress.org. Платный Elementor, самопис, «нулёвая» тема с warez-сайта — для них эталона нет. Нулёвая тема — отдельный красный флаг: в ней бэкдор часто был с момента установки, и «вирус пришёл сам» тут ни при чём.
Что делать с расхождениями в ядре: не править файл руками «вырезать eval». Заменить пакет ядра целиком из официального zip. Исключения — wp-config.php и каталог wp-content. Если правили сами wp-config.php (соль, префиксы, константы) — файл не из zip, его сверяют глазами, не перезаписывают молча.
Плагин с расхождением: снимаю копию, ставлю тот же zip с wordpress.org. Если плагин платный — zip с кабинета разработчика, не «с форума, он тот же». Самопис, который «нам верстальщик оставил», читаю целиком. Там же часто лежит загрузчик файлов без проверки типа.
Где прячут PHP, когда «в файлах чисто»
Поиск по eval(base64_decode — полезный, но наивный. Нормальный PHP тоже бывает некрасивым. Я ищу сочетание: обфускация + место, куда CMS не должна писать исполняемое.
grep -R --include='*.php' -nE 'eval\s*\(|assert\s*\(|gzinflate\s*\(|gzuncompress\s*\(|str_rot13\s*\(|create_function\s*\(|preg_replace\s*\(.+/e' "$SITE" | head grep -R --include='*.php' -nE 'FilesMan|c99shell|WSO\s|wp_vcd|wp-tmp|predli|\$GLOBALS\[.as.\]' "$SITE" | head # одна строка в конце легитимного файла — классика find "$SITE" -name '*.php' -size -8k -mtime -14 -print # php внутри картинки / «.ico» find "$SITE/wp-content/uploads" -type f -name '*.php.*' -o -name '*.jpg.php' -o -name '*.png.php'
Типовые гнёзда, которые я открываю всегда, даже если grep молчит:
wp-content/uploadsи кэш плагинов:*.phpтам не должен жить. Исключение — редкие плагины, которые сами кладут PHP в uploads; это повод такой плагин выкинуть, а не «ну так задумано».wp-content/upgrade,wp-content/upgrade-temp-backup,wp-content/blog.dirна мультисайте — мусор после обновлений, его не смотрят.- Старая неактивная тема. «Мы на Astra, Twenty Twenty лежит на всякий» — в Twenty часто и сидит инжект в
functions.php. Неактивная тема всё равно исполняется, если её кто-то дернул напрямую по URL. wp-config.phpвыше корня (правильная схема) и копияwp-config.phpв корне/бэкапах*.bak,wp-config.php.old,config.php.save. Через них читают ключи БД без всякого шелла.
Отдельно — .htaccess. Не только редирект на чужой домен. Ещё auto_prepend_file / auto_append_file, php_value, RewriteRule на левый файл в uploads, закрытие .php «для всех кроме». Я сравниваю текущий файл с тем, что должен быть у выбранных постоянных ссылок. Если permalinks — «название записи», в корневом htaccess должен быть стандартный кусок WP, а не три килограмма чужих правил.
База. Файловый сканер её не видит. Смотрю:
- Лишние администраторы в
wp_users+wp_usermeta(wp_capabilities= administrator). Пользовательwp_update/wpadmin1/ почта на одноразовом домене — не «сотрудник хостера». siteurlиhomeвwp_options. Если там чужой домен — редирект может быть не в файлах.active_plugins,recently_activated, опции с автозагрузкойautoload=yesи телом в десятки килобайт: туда кладут PHP, который потомevalят из темы одной строкой.- Контент записей и виджетов:
iframe,eval, короткие ссылки, спам-страницы в черновиках и ревизиях. Ревизии чистить отдельно: «удалил пост» не удаляетwp_postsсpost_type=revision. - Cron внутри WP:
cronвwp_optionsилиwp cron event list. Хук, которого нет ни в одном вашем плагине, раз в 10 минут дергает URL — это не «оптимизация».
wp user list --role=administrator --path="$SITE" wp option get siteurl --path="$SITE" wp option get home --path="$SITE" wp plugin list --path="$SITE" wp cron event list --path="$SITE" wp db query "SELECT option_name, LENGTH(option_value) AS sz, autoload FROM wp_options ORDER BY sz DESC LIMIT 30;" --path="$SITE"
Префикс таблиц берите из wp-config.php, не считайте что он всегда wp_.
Дыру ищу до «лечения», иначе она зашьёт файл обратно
Удалить шелл и не закрыть вход — значит пригласить того же бота через пару часов. Пока файлы ещё на месте, по логам видно, какой URL принял POST до появления файла.
Ищу в access-логе не «все 404», а успешные запросы к тому, чего в чистом WP нет, и POST на плагины с известными дырами. Имена ниже — примеры шаблона, не диагноз конкретного сайта:
# nginx: подставьте свой лог
grep -E 'POST /(wp-admin/admin-ajax\.php|xmlrpc\.php|wp-login\.php)' /var/log/nginx/example.access.log | awk '{print $1}' | sort | uniq -c | sort -nr | head
grep -E ' 200 | 302 ' /var/log/nginx/example.access.log | grep -Ei 'uploads/.+\.php|wp-content/plugins/.+/upload|filemanager|wp-setup|radio\.php|about\.php'
# php в uploads, который уже отработал
grep 'wp-content/uploads/.*\.php' /var/log/nginx/example.access.log | grep -E ' 200 | 500 '
Что чаще всего оказывается входом на обычных магазинах и лендингах, которые мне приносят:
- Слабый пароль администратора + открытый
xmlrpc.phpсsystem.multicall. Брут идёт не через форму логина, лимит наwp-login.phpего не видит. - Устаревший плагин формы, слайдера, «добавить товар из Excel», файловый менеджер, который кто-то поставил «на пять минут».
- Нулёвая тема / «пак плагинов». Бэкдор там с завода.
- Утекла сессия админа: тот же пароль, что в панели хостинга, что в FTP, что в почте. После взлома пароль «тот же, я его недавно менял» меня не успокаивает.
- Соседний сайт в том же пользователе UNIX. Дыра была не в этом WP.
Версии плагинов фиксирую сразу, до обновления: wp plugin list + даты файлов. Обновить всё «на всякий случай» до разбора — значит затереть следы, какой именно компонент принял запрос. Обновлять надо, но после снимка и записи версий.
Если вход не находится за час — это нормально. Тогда я всё равно меняю секреты и убираю известные дыры, но в отчёте пишу честно: persistence сняли, вектор не доказан. Не пишу «всё закрыто», если в логах дырка.
Чистить на месте или накатывать чистый слепок
Правило, которым я пользуюсь. Если есть проверенный бэкап до даты взлома — проще поднять его на чистый каталог, накатить контент (uploads без PHP, дамп таблиц контента без левых пользователей) и заново поставить ядро и плагины из официальных zip. «Лечить» живой каталог имеет смысл, когда бэкапа нет или взлом свежий, и заказчик не может быть сутки в 503.
Чего не делаю:
- Не ставлю поверх заразы «свежий WordPress» в тот же каталог. Старый
mu-pluginиobject-cache.phpпереживут. - Не верю кнопке Repair у сканера как единственному действию. Она вычищает известные сигнатуры. Не вычищает новый include в
wp-blog-header.phpиз трёх символов. - Не оставляю «карантин» плагина внутри
wp-content, если этот карантин всё ещё исполняется по прямому URL. - Не правлю обфусцированный файл «вырезать кусок». Файл целиком из эталона или удалить.
Порядок очистки на месте, если слепка до взлома нет:
- Снимок, логи, список админов, crontab, drop-in, mu-plugins — уже сняты.
- Ядро заменить официальным zip той же (или уже новой, если дыра в ядре) версии.
- Каждый плагин и тему — либо zip с официального источника, либо удалить. Неактивное — тоже удалить с диска, не «выключить».
- Из uploads удалить любой PHP/phtml/phar и файлы с двойным расширением. Картинки не «лечить антивирусом», а не исполнять: в nginx для uploads —
location ~ \.php$deny. - База: лишние админы, option с длинным autoload, спам-посты и ревизии, подмена siteurl, чужие cron-хуки.
- Секреты: ключи в
wp-config.php(AUTH_KEYи остальные — сгенерировать заново), пароли всех админов, пароль БД, FTP, панели хостинга, SMTP, токены платёжек и Telegram, application passwords, ключи REST. Сессии: сменить соли, чтобы старые куки умерли. - Только после этого обновлять плагины до текущих версий и включать сайт для своих IP на проверку.
Запрет исполнения PHP в uploads — это не «оптимизация», это то, без чего чистка часто бессмысленна:
location ~* ^/wp-content/uploads/.*\.php$ {
deny all;
}
На apache — в uploads/.htaccess что-то в духе php_flag engine off плюс <FilesMatch> на php. Точный синтаксис зависит от того, mod_php у вас или php-fpm через cgi. Если не уверены — лучше сломать загрузку шелла, чем угадать директиву и оставить выполнение. Проверка простая: положить свой test.php с echo 1; в uploads, открыть в браузере, получить 403/скачивание, файл удалить.
Проверка, что не всплыло через ночь
Сайт открываю с своего IP. Смотрю исходник главной и пары внутренних: нет ли внешнего скрипта с левого домена, нет ли display:none простыни ссылок, не подменён ли каноникал. В базе ещё раз siteurl/home. В файлах — даты за последние часы: если после «чистки» снова появились php в uploads, persistence жива (cron, соседний сайт, украденный FTP, незакрытая дыра).
Технически полезно:
# повтор через несколько часов, не сразу find "$SITE" -type f -mmin -180 -printf '%TY-%Tm-%Td %TH:%TM %p\n' | sort crontab -l wp user list --role=administrator --path="$SITE" wp plugin list --path="$SITE"
Снаружи: Search Console на странные URL, письмо хостера про исходящий спам, очередь почты на сервере (mailq, логи exim/postfix). Исходящий спам после «уже чистого сайта» почти всегда значит, что PHP-шелл или SMTP-креды живы.
Сканер после этого можно поставить. Один. Как датчик на будущее, не как доказательство чистоты. Я смотрю на него так же, как на fail2ban: хорошо, что пишет, плохо, если им заменяют работу руками.
И ещё: пароли, которые «и так сложные», после инцидента всё равно меняю. В том числе в панели домена и у регистратора. Бывали случаи, когда после чистки сайта через сутки меняли NS — это уже не WordPress, это украденный аккаунт регистратора, и файловый grep этого не увидит.
Что я не обещаю одним плагином
Плагин-сканер не видит crontab ОС, соседний vhost, подмену NS и письмо, которое уже ушло ночью. Он плохо видит инжект в базе и drop-in на одну строку. Он может быть сам заражён, если его поставили в уже дырявый WP.
Разбор — это снимок, сверка ядра, поиск persistence, закрытие входа, смена секретов и проверка через несколько часов. Скучно, зато сайт не «зеленеет» в сканере и не краснеет у клиентов на следующий день.
Если нужно разобрать конкретный случай — файлы, логи и доступ по SSH, не «скрин админки и надежда». Это обычная работа по сопровождению, без магии и без прайса на каждый найденный eval.
Нужна профессиональная удалённая помощь с сервером, сайтом, компьютером или ноутбуком?
Свяжитесь со мной любым удобным для вас способом, и получите её быстро и не дорого.
Обсудить задачуПомогла статья? Поблагодари автора!
Остались вопросы, или есть что добавить? Добро пожаловать в комментарии.
Угостить автора чашечкой кофе