
Неразрушающий детектор + Docker-лаборатория для wp2shell (CVE-2026-63030 REST /batch/v1 путаница маршрутов + CVE-2026-60137 author__not_in SQLi) в ядре WordPress 6.9.0-6.9.4 / 7.0.0-7.0.1
Автономная лаборатория, неразрушающий детектор и полный proof-of-concept pre-auth RCE для wp2shell — цепочки уязвимостей без предварительной аутентификации в ядре WordPress:
| CVE | Компонент | Класс | CVSS |
|---|---|---|---|
| CVE-2026-60137 | WP_Query::author__not_in | SQL-инъекция (CWE-89) | 9.1 |
| CVE-2026-63030 | путаница маршрутов REST /batch/v1 | конфликт интерпретации (CWE-436) → приводит к RCE | 7.5 |
Затронуты: ядро WordPress 6.9.0–6.9.4 и 7.0.0–7.0.1 (один лишь SQLi-приёмник также затрагивает 6.8.0–6.8.5). Исправлено в 6.8.6 / 6.9.5 / 7.0.2. Сообщено Adam Kues (Assetnote / Searchlight Cyber); SQLi также приписывается TF1T, dtro, haongo. Цепочка RCE на стандартной конфигурации (oEmbed → changeset → re-entry) — Mustafa Can İPEKÇİ (nukedx).
Сначала обновитесь. Для этой уязвимости WordPress выпустил принудительные автоматические обновления. Этот репозиторий существует, чтобы помочь вам проверить, что ваша инфраструктура пропатчена, и понять ошибку — а не для атак на кого-либо. См. SECURITY.md.
Всегда истинный примитив — это неаутентифицированная SQL-инъекция без плагинов в стандартном ядре, дающая полное
чтение базы данных (хеши паролей администраторов, всё содержимое wp_options/wp_users). Уже одно это
заслуживает 9.1 и немедленного патча.
RCE реален и работает на стандартном WordPress — не требуется привилегия FILE, постоянный объектный
кэш, плагины или ошибки конфигурации. Цепочка использует SQLi только на чтение как
примитив подделки строк (UNION ALL SELECT внедряет поддельные строки wp_posts), а затем задействует
собственный конвейер рендеринга контента WordPress, чтобы превратить эти поддельные строки в реальные записи в БД
через кэширование oEmbed. Далее повышение через changeset и реентерабельный parse_request выполняются в контексте
администратора, создавая новую учётную запись администратора — всё из одного неаутентифицированного HTTP-запроса.
POST /?rest_route=/batch/v1)1. Route confusion — double-nested batch desyncs $matches/$validation so a GET
/wp/v2/widgets runs under posts::get_items() (public), reaching
WP_Query's author__not_in with attacker-controlled input.
2. Row forgery — author__not_in is string-concatenated into SQL;
"1) AND 1=0 UNION ALL SELECT <23 cols> -- -" injects fake
WP_Post rows. per_page=-1 bypasses split_the_query (WP_Query
treats -1 as "no limit" → empty $limits → split=false →
full SELECT wp_posts.* → UNION columns match).
3. oEmbed write — forged posts carry [embed]<self-url>[/embed]; rendering via
context=view makes WordPress cache real oembed_cache posts in
the DB (turns read-only SQLi into writes with predictable IDs).
4. Elevation+re-entry — a forged customize_changeset (user_id = real admin) plus a
forged post_type=request row with parent loops drives an
in-process re-entrant parse_request in admin context.
5. Admin creation — POST /wp/v2/users in the same batch passes
current_user_can('create_users') → new administrator.
6. RCE — login → plugin webshell upload → command execution → cleanup.
Новизна — в композиции, а не в какой-то одной ошибке: сами по себе эти механизмы — легитимное
поведение WordPress. Их именование (по разбору Adam Kues) делает граф поддельных строк в exploit()
читаемым и подсвечивает, что нужно повторно аудировать после закрытия точек входа:
wp_update_post() и предпочитает post_type/post_status из памяти, позволяя
строке oembed_cache быть переопределённой в настоящий post/customize_changeset.post_content — фильтр wp_insert_post_parent проходит по цепочке
родителей; при обнаружении цикла он вызывает второй wp_update_post(), который исправляет родителя не
перезаписывая post_content. Это критическое звено: оно позволяет вредоносному post_content поддельного
changeset выжить. (Именно поэтому поддельные строки используют самозамыкающиеся/взаимные циклы родителей.)parse_request — публикация записи запускает do_action("{$status}_{$type}");
поддельная строка с post_status=parse / post_type=request запускает parse_request, заново выполняя
batch-конвейер, пока всё ещё действует предполагаемая личность администратора из changeset (wp_set_current_user).Эти механизмы остаются в пропатченном WordPress — закрыты были только две точки входа (batch-десинхронизация +
обход скаляра author__not_in). Любой новый примитив, подделывающий кэш записей в памяти или записывающий
строку oembed_cache, снова активирует ту же самую завершающую часть захвата администратора.
Все четыре примитива записи во время рендеринга подтверждены вживую на 7.0.1 — каждый подделан как неаутентифицированная запись, отрендерен через batch-путаницу, а итоговая запись в БД проверена слепой SQLi:
| Примитив | Триггерная разметка | Приёмник | Прогнозируемый идентификатор | Проверено |
|---|---|---|---|---|
| oembed | [embed]<url>[/embed] | строка wp_posts (oembed_cache) | post_name = md5(url+attrs) | создан ID записи |
| rss | wp:rss {feedURL} | сайт-транзиент wp_options | _site_transient_feed_<md5(url)> | option_id, закэшировано 5192 Б |
| navigation | wp:navigation | строка wp_posts (wp_navigation) | post_name = 'navigation' (фиксированный) | ID создан, slug navigation |
| calendar | wp:calendar | wp_options | wp_calendar_block_has_published_posts | option_id, значение '1' |
Что доказывает каждый результат:
wp_posts с предсказанным злоумышленником slug
(md5(url+serialize(attrs))) и реальным автоинкрементным ID. Это единственный примитив, дающий
множественные, по требованию, названные злоумышленником строки записей — именно поэтому цепочка RCE использует
его для подкрепления графа поддельных changeset/request._site_transient_feed_<md5(url)> полностью
предсказуем, а хранимые байты — тело фида, которое отдаёт URL злоумышленника, т.е. злоумышленник
контролирует и ключ, и значение. Это запись в wp_options (без ID записи), так что это примитив
отравления опций, а не готовая замена для подкрепления changeset.wp_navigation) с фиксированным slug, поэтому может подкрепить максимум
один поддельный объект — в отличие от N строк у oEmbed.update_option срабатывает без аутентификации, но имя
опции и её значение '1'/'0' фиксированы/выводятся из БД, так что это демонстрация «запись происходит»
без контроля злоумышленника над ключом или значением.