Перейти к содержимому
Alex Kovalev

Взломали WordPress? 5 мест, где прячется взломщик

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

Guide8мин чтения
«Фокусник» Иеронима Босха в виде мема: фокусник с подписью «Я» показывает трюк разинувшему рот зрителю с подписью «ТЫ», на столе между ними логотип WordPress. Если приблизить: мужчина в очках сзади, который срезает кошелёк, подписан admin_123, а шарик в руке фокусника - *.php.
«Фокусник» Иеронима Босха в виде мема: фокусник с подписью «Я» показывает трюк разинувшему рот зрителю с подписью «ТЫ», на столе между ними логотип WordPress. Если приблизить: мужчина в очках сзади, который срезает кошелёк, подписан admin_123, а шарик в руке фокусника - *.php.

Странный редирект на onion-сайт? Письмо от хостера? Или сайт просит доказать, что вы не робот, и для этого вставить в терминал какую-то странную команду? А может, сайт выглядит совершенно нормально, просто вы ему больше не доверяете. Вопрос в любом случае один: живёт ли в этом WordPress кто-то кроме вас?

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

Пять мест, в том порядке, в каком смотрел бы я:

  1. Админы, которых никто не знает
  2. PHP-файлы там, где им делать нечего
  3. Плагины и автозагрузка
  4. Кроны и хуки
  5. Спам, который видит только Google

Понадобятся SSH и WP-CLI. Запускайте все команды из корня сайта, из папки, где лежит wp-config.php. Вместо example.com подставьте свой домен. Всё это можно сделать и через phpMyAdmin с файловым менеджером, просто медленнее и с большим количеством кликов.

Почти все команды ниже только читают. Две, которые что-то меняют, об этом предупреждают.

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

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

Значит, спрашиваем базу. wp db query отдаёт SQL прямо в MySQL, и ни один хук WordPress не получает права голоса. Эта команда показывает 20 самых новых аккаунтов. Первая строка сохраняет префикс таблиц (обычно wp_), вторая подставляет его в запрос:

Terminal window
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 печатает другой префикс, подставьте его.

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

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

Проверьте и пароли приложений. С ними скрипт входит через REST API без обычного пароля пользователя и без 2FA, так что украденный пароль приложения переживает смену основного:

Terminal window
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).

Мем «Паника, спокойствие, паника». Паника: кто-то зашёл админом в 3 ночи. Спокойствие: в админке только мои 2 админа. Паника: а в базе их 3.

2. Веб-шеллы и PHP там, где его быть не должно

Веб-шелл — это PHP-файл, который даёт тому, кто его нашёл, панель управления прямо в браузере: ходить по файлам, запускать команды, править базу. Обычно он маленький, часто обфусцирован и назван так, чтобы не выделяться: wp-cache.php, class-wp-xyz.php, index.php в папке, где он никогда не был нужен.

Начните с папки загрузок. В ней должны лежать картинки, PDF и изредка шрифт. PHP там лежать не должен:

Terminal window
find wp-content/uploads -iname '*.ph*'

Она находит все файлы .php, .phtml и .phar. Хороший результат: пусто. Пустой index.php с // Silence is golden — тоже нормально, некоторые плагины кладут такие, чтобы закрыть листинг папки. Всё, что длиннее, стоит открыть.

Дальше пусть WordPress сверит свои файлы с официальными контрольными суммами релиза. Сначала ядро:

Terminal window
wp core verify-checksums

Хороший результат: Success: WordPress installation verifies against checksums. Плохой: File doesn't verify against checksum (файл ядра изменён) или File should not exist (кто-то добавил лишний).

Потом плагины с wordpress.org:

Terminal window
wp plugin verify-checksums --all

Плохо, если в ответе есть плагин с изменённым или лишним файлом. Премиум-плагинов в этой базе нет, их команда пропустит, поэтому скачайте чистую копию у разработчика и сравните через diff.

Теперь посмотрите, что менялось недавно:

Terminal window
find . -name '*.php' -ctime -14

Команда покажет PHP-файлы, изменённые за последние 14 дней. Плохо, если там файлы, которых вы не трогали, особенно вне плагина, который вы только что обновили.

Используйте -ctime, а не -mtime. Взломщики любят сделать своим файлам touch на 2019 год, и недельный шелл выглядит так, будто пришёл вместе с темой. touch подделывает время изменения содержимого, а время изменения inode без root подделать не может.

И наконец, поищите обычную обфускацию:

Terminal window
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 и при этом спокойно загружаться дальше.

Поэтому смотрите на папки, а не на экран. Сначала must-use плагины:

Terminal window
ls wp-content/mu-plugins/

“No such file or directory” — это нормально: must-use плагинов нет. Каждый файл, который там есть, должен быть делом рук вашего хостера или известного плагина (управляемые хостинги любят добавлять свои).

Потом drop-in’ы, они лежат прямо в wp-content:

Terminal window
ls wp-content/*.php

index.php — это нормально. Каждое имя из списка drop-in’ов выше должно принадлежать плагину кэширования или хостеру.

Потом плагины, которые WordPress на самом деле загружает, прямо из базы:

Terminal window
wp db query "SELECT option_value FROM wp_options WHERE option_name = 'active_plugins'"

В ответе сериализованный PHP-массив, страшненький, но читаемый: a:2:{i:0;s:19:"akismet/akismet.php";...}. Каждая запись должна быть плагином, который вы узнаёте. Теперь сравните с тем, что лежит на диске:

Terminal window
ls wp-content/plugins/

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

4. Код, который запускается раньше WordPress или по расписанию

Здесь живёт повторное заражение. Вы удаляете шелл, идёте спать, а утром он лежит в той же папке. Значит, что-то его вернуло.

auto_prepend_file. PHP можно попросить выполнять файл перед каждым скриптом на сайте. Проверьте все места, где эта настройка может прятаться:

Terminal window
grep -r auto_prepend_file --include='*.ini' --include='.htaccess' .

Хороший результат: пусто. Wordfence ставит её намеренно, для своего файрвола (wordfence-waf.php). Всё остальное, что указывает на незнакомый файл, — проблема.

wp-config.php. Откройте и прочитайте сверху донизу. Там должны быть настройки: доступы к базе, ключи и соли, несколько строк define(). Ищите include или require со странным путём, простыню закодированного текста или код перед первым блоком комментариев.

Каждый .htaccess, а не только корневой. Любимый трюк — редирект, который срабатывает только для посетителей из Google. Владелец набирает адрес вручную и ничего не видит:

Terminal window
grep -rlE 'HTTP_REFERER|HTTP_USER_AGENT' --include='.htaccess' .

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

Плагины безопасности и кэширования тоже пишут такие условия, так что читайте правила, а не просто считайте совпадения.

Задачи по расписанию. Оба вида. Сначала свои, вордпрессовские:

Terminal window
wp cron event list

Плохо, если там хук со случайным на вид именем вроде wp_x9f3k_check, который не объясняет ни один ваш плагин.

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

Terminal window
crontab -l

“No crontab” — это нормально. Плохо, если там строка, которая запускает curl ... | sh или wget с незнакомого домена. Это машина повторного заражения.

Мем с удивлённым Пикачу. Я: удаляю веб-шелл. Тоже я: не проверяю крон. Шелл: утром снова на месте. Пикачу смотрит с открытым ртом.

5. Спам, который видит только Google

SEO-спам сделан так, чтобы владелец ничего не заметил. Вы видите свои обычные страницы. Googlebot видит тот же сайт, к которому прикрутили тысячи страниц про аптеки и казино. Посетители встречают его, только когда кликают по результату поиска.

Проверьте, что видит внешний мир:

  1. Поищите в Google site:example.com и пролистайте выдачу. Страницы на языке, на котором вы не пишете, или адреса вида /?p=cheap-... — плохой знак.
  2. Откройте раздел Проблемы безопасности и меры, принятые вручную в Google Search Console. Google часто сообщает о взломе раньше, чем клиент.
  3. Запросите страницу у своего же сервера, притворившись Googlebot. Команда покажет только строки, которые отличаются между тем, что получаете вы, и тем, что получает Googlebot:
Terminal window
diff <(curl -sL https://example.com/) <(curl -sL -A Googlebot https://example.com/)

Потом то же самое для посетителя, который пришёл из выдачи Google:

Terminal window
diff <(curl -sL https://example.com/) <(curl -sL -e https://www.google.com/ https://example.com/)

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

Потом поищите внедрённые скрипты в базе:

Terminal window
wp db search '<script' wp_posts wp_options

Она показывает каждый тег <script, сохранённый в записях и настройках.

Часть находок будет законной: аналитика, виджеты чатов, HTML, который сохранил конструктор страниц. Ищите скрипты с доменов, о которых вы никогда не слышали, или String.fromCharCode и atob(, которые изо всех сил прячут адрес.

Раз уж вы в wp_options, проверьте два адреса, по которым WordPress считает, что живёт:

Terminal window
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 по умолчанию анонимные комментарии отклоняет. Убедитесь, что у вас он делает это до сих пор. Это одна из двух команд, которые могут что-то изменить: если адрес открыт, она отправит настоящий тестовый комментарий.

Terminal window
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. Выясните, что именно, и выключите, если не знаете, зачем он там.

Потом посмотрите, сколько уже набежало:

Terminal window
wp comment count

Она печатает, сколько комментариев одобрено, ждёт проверки и лежит в спаме. Тысячи в ожидании значат, что боты вас нашли.

Дальше выведите одобренные комментарии со ссылками:

Terminal window
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 выдаёт ваши логины минимум в пяти местах, и боты, которые подбирают пароли, читают их все, прежде чем начать. Попробуйте на своём сайте:

Terminal window
# REST API: look for "slug" - one per user who has published a post
curl -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 post
curl -s https://example.com/feed/ | grep creator
# oEmbed: "author_name" and "author_url" for any page
curl -s 'https://example.com/wp-json/oembed/1.0/embed?url=https://example.com/'
# Core sitemap (WordPress 5.5+): one author archive URL per user
curl -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, и это уже текстовый файл с паролем от базы, на расстоянии одного запроса.

Мем с Дрейком глазами взломщика. Дрейк отворачивается от wp-config.php: 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. Если сервер её отдаёт, кто угодно скачает ваш код и всю его историю, включая любой пароль, который когда-либо попал в коммит.

Боты спрашивают эти имена на каждом сайте, который сканируют. Спросите первым:

Terminal window
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.

Этот список покрывает только типичные имена. Остальное ищите на сервере:

Terminal window
find . -maxdepth 3 | grep -E '\.(sql|gz|zip|bak|old|save|swp|log)$|~$'

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

И остаток деплоя:

Terminal window
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.

Чирикну, когда выйдет новый пост

Одно письмо на каждый новый пост, обычно по пятницам. Больше ничего, а отписаться можно в любой момент.

Удобнее читать в RSS-ридере? Есть RSS-лента.

© Alex Kovalev. Все права защищены. · Политика конфиденциальности