Cada plugin de subida de archivos de WordPress camina por la misma cuerda floja. El core de WordPress tiene una lista blanca estricta de tipos de archivo, y por buenos motivos. HTML, SVG, PHP y otros cuantos no llegan a wp-content para un usuario que no sea administrador. Un plugin que ofrece "subida desde el frontend" es, por definición, ensanchar esa lista. La pregunta es cómo la ensancha, y qué red de seguridad pone debajo.
Ahí vivía CVE-2025-4392. Shared Files es un plugin de WordPress con unas 4.000 instalaciones activas. Deja al administrador poner un shortcode [shared_files file_upload=1] en una página pública. El visitante entra, elige un archivo y acaba en wp-content/uploads vía el handler del propio plugin. Sin autenticación. Ese es el reclamo del plugin.
Un saneador iba a parar lo peligroso. Lo hacía para un tipo de archivo. El resto pasaba sin tocar.
Esto es un XSS almacenado. CWE-79. El vector es un SVG fabricado para que el detector de contenido que usa el plugin no lo reconozca como SVG. Basta una línea en blanco delante o una línea de texto plano antes del elemento <svg>. El saneador del plugin se acciona por lo que finfo_file() dice del contenido, así que un archivo que finfo lee como text/plain se salta el saneador entero y aterriza en disco byte a byte. Se guarda con extensión .svg. Apache pinta el Content-Type por la extensión, no por los bytes, así que sirve el archivo guardado como image/svg+xml. El navegador parsea los bytes servidos como SVG, dispara cualquier onload o script que el SVG pidiera, y el script se ejecuta en el origen del sitio contra quien abra el archivo desde el listado de Shared Files. Entra sin autenticar, sale con un script en el origen del sitio.
Aquí está la función que debía parar esto, literal de Shared Files 1.7.48, en admin/class-sf-admin-allow-more-file-types.php:
public static function sanitize_file( $tmp_name ) {
$file_contents = file_get_contents( $tmp_name );
$mime_type = '';
if ( function_exists( 'finfo_open' ) && function_exists( 'finfo_file' ) ) {
$finfo = finfo_open( FILEINFO_MIME_TYPE );
$mime_type = finfo_file( $finfo, $tmp_name );
finfo_close( $finfo );
} elseif ( function_exists( 'mime_content_type' ) ) {
$mime_type = mime_content_type( $tmp_name );
}
if ( $mime_type == 'image/svg+xml' ) {
$sanitizer = new Sanitizer();
$file_contents_sanitized = $sanitizer->sanitize( $file_contents );
return $file_contents_sanitized;
} else {
return $file_contents;
}
}Léete ese else. La función se llama sanitize_file. Sanea exactamente un tipo MIME. Para el resto devuelve los bytes crudos de lo que el usuario haya subido.
No es un fallo de olvidarse de escapar. Es un fallo de alcance, agravado por una puerta que confía en el content-sniffing. El desarrollador pensó en SVG, que es el vector clásico de XSS por subida en WordPress y lo flagea cualquier auditor, y tapó ese agujero concreto con la librería enshrined/svg-sanitize, que es la herramienta correcta. Lo que no contempló es que la puerta del saneador era finfo_file() comparando contra la cadena literal image/svg+xml, y a finfo_file() se le engaña con cambios pequeñísimos en el contenido. Un SVG con una línea en blanco delante, con un comentario de texto fuera del elemento raíz o con una cabecera XML mal formada deja de leerse como image/svg+xml para finfo. Se lee como text/plain. text/plain está en la lista de MIME permitidos del core de WordPress (porque .txt está permitido), así que la puerta de allowed-MIME aguas arriba lo acepta. El archivo es el mismo SVG. El saneador no lo ve.
El nombre de la función miente. Se lee como "devuelve una versión saneada del archivo". El contrato real es "devuelve una versión saneada solo cuando finfo dice que el archivo es exactamente image/svg+xml". Esa distancia entre nombre y contrato es todo el bug. Cualquier auditor que repase el plugin buscando la gestión de uploads ve un $sanitized = sanitize_file( $tmp ); en la ruta de subida y sigue adelante. El nombre compró la confianza. El nombre no decía la verdad.
La ruta de subida que acaba aquí no tiene comprobación de capacidad. Cuelga del shortcode público del frontend, así que cualquier visitante anónimo puede dispararla. Un SVG fabricado entra, sanitize_file() lo pasa por la rama else porque finfo no lo ve como image/svg+xml, y sale por el otro lado byte a byte igual. Los bytes se escriben al directorio de uploads que gestiona el plugin, la URL de descarga se guarda en base de datos y el archivo .svg se queda ahí esperando a que alguien lo abra. Cuando alguien lo hace, Apache lee la extensión, manda Content-Type: image/svg+xml sin mirar los bytes, y el navegador parsea el archivo como SVG.
El parche en 1.7.49 sustituye la comprobación por MIME por una verificación de coherencia entre extensión y contenido:
public static function sanitize_file( $tmp_name, $filename ) {
$extension = strtolower( pathinfo( $filename, PATHINFO_EXTENSION ) );
$file_contents = file_get_contents( $tmp_name );
[ 'ext' => $wp_ext, 'type' => $wp_mime ] = wp_check_filetype_and_ext( $tmp_name, $filename );
if ( $extension !== $wp_ext ) {
die( 'Extension mismatch' );
}
if ( $wp_mime == 'image/svg+xml' ) {
$sanitizer = new Sanitizer();
return $sanitizer->sanitize( $file_contents );
}
return $file_contents;
}wp_check_filetype_and_ext() mira el nombre y el contenido juntos. Devuelve la extensión que WordPress asignaría al archivo a partir de ambas señales. La nueva puerta exige que la extensión declarada por el usuario y la que deriva WordPress coincidan. Un SVG cuyo contenido se fabricó para leerse como text/plain bajo finfo cae aquí por cualquiera de las dos vías: si wp_check_filetype_and_ext tampoco lo reconoce como SVG, las extensiones no coinciden y la subida muere; si lo reconoce como SVG (la función lee más cosas que finfo), entra por la rama SVG y el saneador limpia el archivo. El bypass se cierra.
El nuevo check también generaliza más allá del camino concreto del SVG. El principio es "los bytes tienen que casar con la etiqueta". Cualquier intento de desviar la detección de contenido (magic bytes delante, dobles extensiones, MIME declarado que no encaja) se rompe contra una comprobación que lee por los dos lados y exige convergencia. La sanitización condicionada al tipo solo funciona cuando la detección de tipo no se puede discutir, y el parche cambia un content-sniff de una sola fuente por una comprobación de consistencia entre dos.
Esa es la forma correcta del arreglo. WordPress ya tiene su lista blanca, úsala. El código original intentaba ser permisivo ("permitir más tipos que el core") y añadir encima un saneador de un solo propósito. La parte permisiva hizo su trabajo. Al saneador le dieron un alcance que no encajaba y un detector de contenido al que se le podía argumentar.
La siguiente pregunta obvia: ¿por qué no es RCE? Subes un .php, fuerzas ejecución y listo. Dos cosas lo impiden.
Primero, el plugin tiene otra puerta aparte, allowed_mime_types() en la misma clase, que se ejecuta en el handler de subida antes de que sanitize_file() vea los bytes. Compara el MIME detectado contra get_allowed_mime_types() de WordPress más las añadiduras del propio plugin: SVG, WebP, AVIF, JSON, CSV, TTF, WOFF, WOFF2. El MIME de PHP (application/x-php) no está en ese conjunto. La subida se rechaza con "file mime type is not allowed" antes de que un solo byte toque disco. SVG sí está en el conjunto y por eso el camino SVG era alcanzable. PHP no está y por eso el camino PHP no lo es.
Segundo, aunque el archivo PHP aterrizara en disco (no aterriza, pero aunque así fuera), la mayoría de instalaciones de WordPress bloquean la ejecución de PHP bajo wp-content/uploads por configuración de servidor. Los hosts gestionados (Kinsta, WP Engine, la mayoría de stacks tipo Cloudways) traen esa regla por defecto. En máquinas con Apache a pelo es una de las primeras medidas de hardening. Un .php escrito dentro de /wp-content/uploads/... devuelve 403 o el código fuente, no ejecución.
PHP tiene dos candados. SVG tenía uno, sanitize_file(), y ese candado era la rama else rota. El XSS existía porque el camino SVG estaba abierto por diseño, no porque alguien se saltara una comprobación general de tipo.
El patrón general que busco es el saneo con puerta por tipo. Cualquier función cuyo nombre implica una promesa universal de seguridad (sanitize_file, clean_input, escape_value, validate_payload) pero cuyo cuerpo se bifurca según la forma de la entrada y solo endurece una rama. La ruta por defecto es donde viven los bugs. Cuando revisas código, cada if es una pregunta: ¿qué pasa si la condición es falsa? Si la rama falsa es "devolver sin cambios", la función está mintiendo sobre su nombre.
Un segundo patrón, específico de WordPress. Cualquier plugin cuya descripción incluya "frontend", "anónimo" o "más tipos de archivo" es un sitio donde vale cinco minutos de tu tiempo. El core de WordPress es cuidadoso con las subidas. Cada plugin que ensancha esa superficie reconstruye esa disciplina desde cero y normalmente el autor se centra en un ataque (SVG) y se deja el resto.
Este bug ilustra también por qué no me apasiona perseguir hallazgos de solo XSS en plugins con pocas instalaciones. 4.000 sitios es un impacto limitado. El ángulo interesante aquí es el patrón, no el impacto. Si el mismo desarrollador ha escrito otros plugins relacionados con subidas, probablemente tengan el mismo saneo-de-un-solo-tipo. El reconocimiento del patrón se transfiere. El número de instalaciones no.
Ahora la parte que importa si estás empezando. ¿Cómo encuentras esto tú?
Auditar plugins de subida en WordPress es una porción más estrecha que la caza de endpoints AJAX, pero escala bien cuando le pillas la forma. Todo plugin que acepta archivos tiene más o menos la misma anatomía. Un punto de entrada que captura $_FILES, un saneador de algún tipo, una escritura a wp-content/uploads y una URL de descarga. Los bugs viven en las costuras.
Empieza buscando plugins que anuncien subidas. El directorio de wordpress.org deja filtrar por etiqueta. "frontend upload", "file upload form", "file sharing", "document management", "client portal". Todas son candidatas. Salta lo que pase del millón de instalaciones (ahí ya está todo el mundo) y lo que no llegue a mil (el vendor no responde). La franja útil para bugs específicos de subida es amplia, porque la mayoría de administradores no instala plugins de subida, así que incluso un plugin con 10.000 instalaciones está lo bastante en nicho como para estar poco auditado.
Con objetivo, busca los puntos de entrada:
grep -rn '\$_FILES\|wp_handle_upload\|move_uploaded_file' wp-content/plugins/target-plugin/Toda subida pasa por alguno de los tres. Sigue cada coincidencia hacia atrás hasta el handler que la invocó. Ese handler está enganchado a wp_ajax_*, o registrado como ruta REST, o atado a un shortcode que procesa $_POST, o colgado de un envío de formulario. Sea cual sea, la primera pregunta es la misma: ¿qué autenticación pide? Si se llega por wp_ajax_nopriv_*, por una ruta REST con permission_callback: '__return_true', o por un shortcode que procesa $_POST sin un current_user_can() delante, estás en territorio no autenticado.
Luego, encuentra el saneador. Entre el punto de entrada y move_uploaded_file() casi siempre hay una función que lee el archivo y decide si se acepta. Los nombres varían: sanitize_file, clean_upload, validate_file, check_file. La pista es una llamada tipo $clean = sanitize_upload( $_FILES['file']['tmp_name'] ) justo antes de que el archivo toque disco. Abre esa función y léela entera. No te fíes del nombre. El bug de Shared Files está entero dentro de una función con "sanitize" en el nombre.
Audita la rama else. Si el saneador se bifurca por tipo ("si es SVG limpia, si es PDF limpia, si es imagen redimensiona"), mira qué devuelve cuando no coincide ninguna. Un return silencioso de los bytes originales es el bug. El comportamiento por defecto correcto para una función llamada sanitize_file es rechazar, no dejar pasar.
Comprueba la fragilidad del content-sniffer. finfo_file() lee bytes y devuelve MIME por contenido, pero la detección es frágil. Una línea en blanco al principio, un comentario de texto fuera del elemento raíz, una cabecera XML mal formada, cualquiera de esas cosas puede empujar un SVG de image/svg+xml a text/plain para finfo sin cambiar cómo el navegador parsea esos mismos bytes después. Si el saneador se acciona por el MIME de finfo y el archivo se sirve más tarde por extensión, esa grieta es el bypass. El caso de Shared Files es exactamente esto: <svg ... onload="alert(1)"/> se convierte en text/plain para finfo con una línea en blanco delante, pasa la comprobación de allowed-MIME porque text/plain está en la lista del core vía .txt, se salta el saneador SVG porque la comprobación de MIME falla, aterriza como .svg en disco y Apache lo sirve de vuelta como image/svg+xml desde la extensión. Mismo principio, magia distinta. La defensa es cruzar la extensión que declara el usuario contra la que WordPress habría asignado por contenido, que es lo que hace wp_check_filetype_and_ext() y lo que el parche de Shared Files empezó a llamar.
Encuentra la ruta de servicio. Un archivo subido solo es interesante si se sirve desde el mismo origen de un modo que dispare ejecución. Busca rutas que sirvan /uploads/ directo, o URLs específicas del plugin tipo /?shared-files-download=123. El Content-Type que se devuelve, y si la respuesta usa Content-Disposition: attachment para forzar descarga, es lo que decide la ejecución. Forzar descarga neutraliza la mayoría de los XSS por archivo. Servir inline con el MIME propio lo habilita. Casi todos los plugins sirven inline porque es lo que el usuario espera.
Confirma con un payload inocente. Para un XSS almacenado por subida en plugins que permiten SVG (Shared Files entre ellos), la prueba es:
<!-- Subido como proof.svg, con una línea en blanco delante para engañar a finfo -->
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.domain)"/>Para plugins que permiten subir HTML directamente (más raros, pero los hay), el equivalente es:
<!-- Subido como proof.html -->
<script>alert(document.domain)</script>alert(document.domain) es la PoC estándar. Demuestra el origen, se cierra con un clic, y nadie puede acusarte de haber exfiltrado nada. Cuando el programa espera ver impacto real, sube un paso: exfiltración de cookies a un listener que tú controles, una acción forzada en nombre del admin, un takeover de cuenta encadenado. Wordfence y Patchstack suelen conformarse con el alert para un XSS de plugin WordPress. Los programas privados en HackerOne muchas veces quieren ver cuánto cuesta el bug de verdad. Lee las reglas del programa y ajusta el payload a lo que piden.
Repórtalo. Wordfence y Patchstack llevan la divulgación de plugins de WordPress. Triaje, coordinación con el vendor, asignación de CVE. Shared Files 1.7.49 salió por Wordfence. El 2025-05-07 reporté el bug, el 2025-05-31 salió la 1.7.49 con el parche, el 2025-06-03 se publicó el CVE. Menos de cuatro semanas de principio a fin.
Las auditorías centradas en uploads suman. Una vez has visto un patrón de "saneo del tipo equivocado", lo reconoces en todos lados. El nombre de la función, la bifurcación por MIME, el else que devuelve crudo. Lo volverás a ver dos veces más en el siguiente plugin que abras. La parte difícil de auditar plugins nunca es la clase de bug. Es tener la memoria muscular para leer entera toda función cuyo nombre sugiere que es segura.
