Облачное хранилище
wp2shell — взлом WordPress: логи IIS с запросами batch/v1 и список чужих администраторов
Следы wp2shell: POST на /wp-json/batch/v1 с ответом 207 и админы @wp2shell.invalid

Девятого октября в Telegram-канал блога прилетел пост «Coronavirus disease 2019». Я его не писал. Автопостинг честно взял свежую запись с сайта и отправил подписчикам — а на сайте в это время уже висела страница «Hacked by Panataran» с пунктом в главном меню.

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

Ниже — что это за уязвимость, как найти её следы, как я вычистил сайт на Windows Server + IIS и на какие грабли наступил по дороге. Если у вас WordPress, который летом 2026 года хоть неделю пожил на версии 6.8–7.0.1, — проверьте его, даже если он сейчас обновлён.

Что такое wp2shell

wp2shell — это цепочка из двух уязвимостей в ядре WordPress, не в плагине и не в теме. Она позволяет анонимному запросу создать на сайте администратора, а дальше дело техники: админ ставит плагин-шелл и получает выполнение кода.

  • CVE-2026-63030 — путаница маршрутов в batch-обработчике REST API (/wp-json/batch/v1). Проверка и выполнение подзапросов идут в разных циклах, и в зазор между ними пролезает то, что не должно.
  • CVE-2026-60137 — SQL-инъекция в WP_Query, до которой и добираются через первую.

Уязвимы версии 6.8–6.8.5, 6.9–6.9.4 и 7.0–7.0.1. Исправления вышли 17 июля 2026 года: 6.8.6, 6.9.5 и 7.0.2. Публичные эксплойты появились почти сразу, и боты пошли по сайтам массово. Никаких плагинов, паролей или действий админа для атаки не нужно — хватает чистой установки.

Главное, что нужно понять: обновление закрывает дыру, но не выгоняет тех, кто уже зашёл. У меня ядро обновилось в конце июля, а созданные до этого админы спокойно жили до октября. Подробный технический разбор есть у Wordfence.

Как проверить, задело ли вас

Самый быстрый признак — администраторы, которых вы не создавали. Запрос к базе (префикс wp_ замените на свой из wp-config.php):

SELECT u.ID, u.user_login, u.user_email, u.user_registered
FROM wp_users u
JOIN wp_usermeta m ON m.user_id = u.ID
WHERE m.meta_key = 'wp_capabilities'
  AND m.meta_value LIKE '%administrator%'
ORDER BY u.user_registered DESC;

У меня вывалилось 30 строк вместо одной. Логины вида wp2_a8d2c1, bob_e822dfc612a5, w2s_ee020d3b51f9, wp_admin_4dd83f, почты @wp2shell.invalid, @bobresearchlabs.com, @local.host. Разные шаблоны имён — разные боты: сайт по очереди находили несколько сканеров. Все учётки созданы с 19 по 25 июля.

Второй признак — в логах веб-сервера: POST на batch/v1 с ответом 207 (Multi-Status), часто с User-Agent wp2shell или rezwp2shell. На IIS ищется так:

Select-String -Path "C:\inetpub\logs\LogFiles\W3SVC*\u_ex2607*.log" -Pattern 'batch/v1|wp2shell' |
  Select-Object -First 50

У меня первые запросы пришли 18 июля в 23:34, на следующий день после выхода патча, а 20 июля их было около трёх тысяч. Если сайт стоит за реверс-прокси, как у меня (nginx перед IIS), в логах IIS вместо адресов атакующих будет IP прокси — реальные источники смотрите в access.log прокси.

Третий признак — мусор в таблице записей. Каждый заход эксплойта оставляет типовой набор: запись oembed_cache, запись customize_changeset с датой 2020-01-01 и запись нестандартного типа request со статусом parse. Плюс у меня нашлись две опубликованные записи с заголовком cache и той же датой 2020-01-01:

SELECT post_type, post_status, COUNT(*) AS n, MIN(post_modified), MAX(post_modified)
FROM wp_posts
WHERE post_type IN ('customize_changeset', 'request', 'oembed_cache')
   OR post_name LIKE 'cache%'
GROUP BY post_type, post_status;

Если там десятки записей с датами после 17 июля — эксплойт у вас отрабатывал. В одном из changeset-ов у меня даже лежал заготовленный пункт меню на https://wp2shell.com.

Чистка на Windows Server + IIS

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

  1. Удалить чужих админов. У меня они шли подряд по ID, так что хватило двух запросов DELETE по wp_usermeta и wp_users с BETWEEN. Проверьте список дважды, прежде чем удалять.
  2. Сменить соли в wp-config.php. Восемь ключей AUTH_KEY … NONCE_SALT — новые значения даёт api.wordpress.org/secret-key/1.1/salt/. Это убивает все сессии разом, включая сессии уже удалённых учёток. Сохраняйте файл в UTF-8 без BOM, иначе получите «headers already sent».
  3. Переустановить ядро той же версии. Искать глазами изменённые файлы в wp-admin и wp-includes бессмысленно, проще залить эталон с wordpress.org через robocopy /MIR — он заодно удалит лишние файлы. Перед этим сравните хеши и сохраните лишнее в карантин — это улики.
  4. Сверить плагины и темы с оригиналами. Для каждого плагина берём версию из заголовка, качаем https://downloads.wordpress.org/plugin/<slug>.<version>.zip и сравниваем хеши. Всё, чего нет на wordpress.org, смотрим руками.
  5. Поискать PHP там, где его быть не должно, и типичные сигнатуры шеллов (см. ниже).
  6. Сменить секреты, которые видел админ: пароль своей учётки, пароль БД, токен бота автопостинга (в @BotFather — /revoke), ключи SMTP и других интеграций.
  7. Запретить редактор кода в админке: define( 'DISALLOW_FILE_EDIT', true ); в wp-config.php. Обновлениям это не мешает.

Поиск исполняемых файлов в каталогах данных и сигнатур в wp-content:

$site = 'D:\inetpub\sites\example.ru'

# PHP в uploads/cache/upgrade — там его быть не должно
Get-ChildItem "$site\wp-content\uploads","$site\wp-content\cache","$site\wp-content\upgrade" -Recurse -File -Force -ErrorAction SilentlyContinue |
  Where-Object { $_.Name -match '\.(php\d?|phtml|phar)$' -and $_.Name -notmatch '\.l10n\.php$' } |
  Select-Object LastWriteTime, FullName

# Типичные конструкции веб-шеллов
$sig = 'eval\s*\(\s*(base64_decode|gzinflate|str_rot13|\$_(POST|GET|REQUEST|COOKIE))',
       'assert\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)',
       '(system|shell_exec|passthru|exec)\s*\(\s*\$_(POST|GET|REQUEST|COOKIE)',
       'wp2shell|FilesMan|b374k|IndoXploit'
Get-ChildItem "$site\wp-content" -Recurse -Include *.php,*.phtml,*.ico -File -Force |
  Select-String -Pattern $sig | Select-Object Path, LineNumber, Line

Сигнатуры дадут ложные срабатывания: base64-картинки в админках плагинов, закомментированный create_function в старых библиотеках. У меня из 42 совпадений все оказались такими. Зато попутно в папке тем нашёлся архив старой темы с тремя обфусцированными шеллами вроде cetaiaqo.php — наследие прошлого хостинга, переехавшее вместе с сайтом. Пока это zip, он не исполняется, но держать такое в webroot нельзя. Мораль: архивы и бэкапы внутри сайта тоже проверяйте.

Чистка базы данных

Кроме админов, в БД остаются следы самого эксплойта. У меня их было 135: по 38 customize_changeset и request, 57 oembed_cache и две опубликованные записи cache. Последние видны посетителям и поисковикам, их убирать обязательно, остальное — безвредный мусор.

Удаляю с бэкапом прямо в ту же базу — так откат не требует поиска дампа:

-- WordPress-таблицы с датами '0000-00-00' не копируются в строгом режиме
SET SESSION sql_mode = '';

CREATE TEMPORARY TABLE ir_ids (ID BIGINT UNSIGNED PRIMARY KEY) AS
SELECT ID FROM wp_posts
WHERE (post_type IN ('customize_changeset','request') AND post_modified >= '2026-07-18')
   OR (post_type = 'oembed_cache' AND post_date >= '2026-07-18')
   OR (post_type = 'post' AND post_name LIKE 'cache%' AND post_date = '2020-01-01 00:00:00');

CREATE TABLE ir_bak_posts    AS SELECT p.* FROM wp_posts p JOIN ir_ids i ON i.ID = p.ID;
CREATE TABLE ir_bak_postmeta AS SELECT m.* FROM wp_postmeta m JOIN ir_ids i ON i.ID = m.post_id;

DELETE m FROM wp_postmeta m JOIN ir_ids i ON i.ID = m.post_id;
DELETE r FROM wp_term_relationships r JOIN ir_ids i ON i.ID = r.object_id;
DELETE p FROM wp_posts p JOIN ir_ids i ON i.ID = p.ID;

Перед DELETE посмотрите, что попало в ir_ids, и подставьте свою дату начала атаки. Если строгий режим не отключить, CREATE TABLE … AS SELECT упадёт с ошибкой 1067 Invalid default value for ‘post_date’ — я на неё наступил.

Заодно проверьте опции и виджеты на вставки <script>, роли в wp_user_roles (у подписчика должно быть только read), задачи в опции cron и что users_can_register = 0. У меня там было чисто — боты просто копили админов про запас.

Грабли, на которые я наступил

Переименование wp-admin кладёт сайт. Первым делом я переименовал папку, чтобы отрезать админку. Пока отдавал кэш nginx, всё выглядело живым, а после перезапуска пула — «На сайте возникла критическая ошибка». В WordPress 7.0 wp-settings.php на каждом запросе подключает wp-admin/includes/plugin.php, так что без этой папки не работает и фронт. Настоящую ошибку быстро покажет консольный PHP:

& 'C:\php\php.exe' -d display_errors=1 'D:\inetpub\sites\example.ru\index.php' 2>&1 |
  Select-String 'Fatal|Parse error|Uncaught' | Select-Object -First 5

Закрывать админку надо на уровне веб-сервера или прокси, а не переименованием. Учтите, что за прокси IIS видит только адрес прокси, так что ограничение по IP в самом IIS не сработает.

Обновление ядра из админки отдаёт 500. После чистки WordPress предложил обновиться, и страница обновления упала с ошибкой IIS 500. В PHP-логе — пусто: FastCGI просто оборвал долгий запрос, пока WordPress качал и распаковывал архив. Решение — обновлять тем же способом, что и при переустановке: скачать архив нужной версии с ru.wordpress.org, залить wp-admin и wp-includes через robocopy /MIR, скопировать корневые файлы и открыть /wp-admin/, чтобы WordPress обновил базу.

Скрипт ломается из-за кодировки. PowerShell 5.1 читает .ps1 без BOM как ANSI, и кириллица в строках превращается в мусор, который ломает разбор кавычек. Ошибки при этом указывают на строки, где кириллицы нет вообще. Самое надёжное — писать служебные скрипты чисто на ASCII.

Функция Diff в PowerShell 5.1 не вызывается. В Windows PowerShell diff — встроенный псевдоним Compare-Object, и он важнее одноимённой функции. В PowerShell 7 этого псевдонима нет, поэтому на тестовой машине всё работало. Не называйте функции diff, sort, select, where, compare и подобными короткими словами.

Сторож, который пишет в Telegram

Самая дорогая часть этой истории — три месяца незамеченного доступа. Поэтому теперь на сервере раз в 15 минут от SYSTEM работает задача планировщика. Она сравнивает состояние сайта с прошлым снимком и пишет в отдельный Telegram-бот, если:

  • появился или пропал пользователь, изменился список админов;
  • включилась регистрация или сменилась роль по умолчанию;
  • появилась новая папка плагина или темы;
  • в uploads, cache, upgrade или корне wp-content появился PHP-файл;
  • сама проверка упала — чтобы тишина не означала «сломалось».

Доступы к БД скрипт читает из wp-config.php и передаёт mysql.exe через временный --defaults-extra-file, а не в командной строке. Токен бота лежит в C:\ProgramData\ в папке с правами только для SYSTEM и администраторов. Бот для уведомлений — отдельный, не тот, что постит в канал: токен автопостинга видел любой админ сайта. Будь такой сторож летом, о первом чужом админе я бы узнал 19 июля, а не 9 октября.

Выводы

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

FAQ

Сайт уже на 7.0.2 или новее — можно не проверять? Нет. Если между 17 июля и обновлением прошло хотя бы несколько часов, чужие админы могли появиться и остаться.

Достаточно удалить чужих админов? Нет. Админ мог поставить плагин-шелл, и он переживёт удаление учётки. Нужны сверка файлов, смена солей и секретов.

Поможет ли переименовать wp-admin? Нет: атака идёт через REST API, а без папки wp-admin WordPress 7.0 не запустится вообще.

Где смотреть IP атакующих, если сайт за прокси? В логах прокси (nginx, Cloudflare). В логах IIS и в сессиях WordPress будет адрес самого прокси.

Облачное хранилище