
PoC independiente y laboratorio reproducible para CVE-2026-97332 — divulgación no autenticada de archivos privados en User Private Files < 2.2.0 en WordPress Multisite.
[!NOTE] Este repositorio es una reproducción independiente y Prueba de Concepto para CVE-2026-97332.
Descubrimiento original de la vulnerabilidad: Akshat Parikh (SN1PER). CNA / aviso: WPScan. PoC y reproducción independientes: Kolya080808.
Divulgación sin autenticación de archivos privados de otros usuarios en el plugin de WordPress User Private Files (slug de wp.org user-private-files) anterior a 2.2.0 en instalaciones multisite. El plugin almacena los documentos privados de cada usuario dentro del árbol de uploads sin protección y los protege con una única RewriteRule añadida al .htaccess raíz, que enruta las solicitudes de archivos a través de una comprobación de acceso en PHP. En una red multisite esa regla nunca se alcanza: un simple GET — sin cookies, sin cabeceras, sin inicio de sesión — devuelve el archivo privado de otro usuario como contenido estático. Control de acceso roto, CWE-284, CVSS 3.1 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N).
Este repositorio contiene un exploit funcional en Python sin dependencias y un laboratorio docker de dos contenedores (vulnerable 2.1.9 y parcheado 2.2.0, ambos WordPress multisite) para reproducir el ataque de extremo a extremo.
[!CAUTION] Este material se proporciona únicamente para investigación de seguridad autorizada y pruebas defensivas (véase el descargo de responsabilidad al final). Atacar sistemas que no posee o no opera sin permiso por escrito es ilegal.
El plugin mantiene los archivos privados como adjuntos ordinarios en un subdirectorio de uploads plano por sitio — wp-content/uploads/upf-docs/ (sitio principal) y wp-content/uploads/sites/N/upf-docs/ (subsitio N) — sin aislamiento por usuario. El control de acceso no está en el directorio; reside en PHP (dl-file.php: 403 para usuarios anónimos, luego una comprobación de autor/lista de permitidos) y solo se alcanza a través de una reescritura emitida en la activación del plugin en el .htaccess raíz:
# appended by 2.1.9 (upvf_actdeact.php), AFTER the WordPress block
RewriteRule ^wp-content/uploads/upf-docs/(.*)$ https://site/?file=$1 [QSA,L]
En multisite esto nunca se dispara, por dos razones independientes:
El .htaccess de la red cortocircuita primero los archivos existentes. WordPress
multisite escribe su propio conjunto de reglas en el .htaccess raíz:
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
Una solicitud que se asigna a un archivo físicamente existente termina la reescritura justo ahí — Apache lo sirve como contenido estático, y mod_rewrite nunca llega al bloque del plugin al final del archivo. (WordPress de sitio único usa condiciones negativas !-f/!-d, por lo que el mismo plugin sí funciona allí.) Esto ya expone archivos del sitio principal de la red.
Las rutas de los subsitios no coinciden en absoluto con el patrón. En multisitio cada sitio tiene sus propias cargas — wp-content/uploads/sites/N/…, además del prefijo de URL /siteN/ en redes de subdirectorio — mientras que la regla está codificada de forma fija a ^wp-content/uploads/upf-docs/ desde la URL base del sitio principal. Incluso después de que la reescritura interna de la red elimine el prefijo /siteN/, el segmento sites/N/ sigue sin coincidir.
Así que para cualquier red multisitio (tanto de subdirectorio como de subdominio) un atacante no autenticado obtiene archivos privados directamente:
GET /wp-content/uploads/upf-docs/victim.pdf HTTP/1.1 (main site)
GET /site2/wp-content/uploads/sites/2/upf-docs/victim.pdf HTTP/1.1 (subsite)
La respuesta es 200 con el cuerpo del archivo — servido por Apache directamente desde
uploads/, la comprobación de acceso del plugin nunca se ejecuta.
Nada en la URL es secreto. El plugin construye los nombres a partir del id de usuario numérico del propietario y el segundo unix de la subida:
| Generador (2.1.9) | Nombre en disco |
|---|---|
UI moderna, inc/functions-file.php:73-77 | usr{ID}_{unixtime}_{original} |
plantilla clásica, inc/classic-user-functions.php | user_{ID}_doc_{unixtime}.{ext} |
Los ids de usuario en una red multisitio son enteros secuenciales pequeños de una única
tabla wp_users compartida, por lo que un atacante solo necesita una hora de subida
aproximada (una ventana de ±2 minutos son ≈240 peticiones) y o bien el nombre de archivo
original o solo su extensión. Los sufijos de colisión (-1, -2) existen pero son raros.
La versión 2.2.0 reemplazó la reescritura del archivo raíz por un .htaccess a
nivel de carpeta en upf-docs/ — RewriteRule ^(.*)$ {home}/?file=$1 [R=302,QSA,L],
más Require all denied cuando mod_rewrite no está presente — recreado en cada
admin_init (autorreparable, por subsitio). Una regla por directorio se activa para cualquier
ruta física bajo upf-docs/ independientemente del prefijo de la URL, por lo que la
petición GET directa ahora se convierte en un 302 hacia la comprobación de acceso y, para usuarios anónimos, en un
403. Los archivos no se mueven; solo cambia el mecanismo de intercepción. Esa
distinción entre 302 y estático es también lo que el exploit usa como
detector de comportamiento.
.htaccess
y el archivo fuera escribible en la activación (nginx necesita la reescritura manual del proveedor — véase el readme del plugin).exploit.py es Python 3 puro de biblioteca estándar (http.client), no sigue
redirecciones y no envía cookies: