PoC indépendant et environnement de laboratoire reproductible pour CVE-2026-97332 — divulgation non authentifiée de fichiers privés dans User Private Files < 2.2.0 sur WordPress Multisite.
[!NOTE] Ce dépôt est une reproduction indépendante et une preuve de concept pour CVE-2026-97332.
Découverte originale de la vulnérabilité : Akshat Parikh (SN1PER). CNA / avis : WPScan. PoC et reproduction indépendantes : Kolya080808.
Divulgation non authentifiée des fichiers privés d'autres utilisateurs dans le plugin WordPress User Private Files (slug wp.org user-private-files) antérieur à 2.2.0 sur les installations multisite. Le plugin stocke les documents privés de chaque utilisateur dans l'arborescence d'uploads en clair et les protège par une unique RewriteRule ajoutée au .htaccess racine, qui fait passer les requêtes de fichiers par une vérification d'accès en PHP. Sur un réseau multisite, cette règle n'est jamais atteinte : un simple GET — sans cookies, sans en-têtes, sans connexion — renvoie le fichier privé d'un autre utilisateur en tant que contenu statique. Contrôle d'accès défaillant, CWE-284, CVSS 3.1 5.3 (AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N).
Ce dépôt contient un exploit Python fonctionnel et sans dépendances, ainsi qu'un lab Docker à deux conteneurs (version vulnérable 2.1.9 et version corrigée 2.2.0, toutes deux en WordPress multisite) pour reproduire l'attaque de bout en bout.
[!CAUTION] Ce contenu est fourni uniquement à des fins de recherche en sécurité autorisée et de tests défensifs (voir l'avertissement en bas de page). Attaquer des systèmes que vous ne possédez pas ou n'exploitez pas sans autorisation écrite est illégal.
Le plugin conserve les fichiers privés sous forme de pièces jointes ordinaires dans un sous-répertoire d'uploads plat, propre à chaque site — wp-content/uploads/upf-docs/ (site principal) et wp-content/uploads/sites/N/upf-docs/ (sous-site N) — sans isolation par utilisateur. Le contrôle d'accès ne se situe pas sur le répertoire ; il réside dans le PHP (dl-file.php : 403 pour les utilisateurs anonymes, puis vérification de l'auteur ou de la liste d'autorisation) et n'est atteint que via une règle de réécriture émise à l'activation du plugin dans le .htaccess racine :
# appended by 2.1.9 (upvf_actdeact.php), AFTER the WordPress block
RewriteRule ^wp-content/uploads/upf-docs/(.*)$ https://site/?file=$1 [QSA,L]
Sur un site multisite, cela ne se déclenche jamais, pour deux raisons indépendantes :
Le fichier .htaccess du réseau court-circuite d'abord les fichiers existants. WordPress
multisite écrit son propre ensemble de règles dans le fichier .htaccess racine :
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
Une requête correspondant à un fichier physiquement existant termine la réécriture à cet endroit — Apache le sert comme contenu statique, et mod_rewrite n'atteint jamais le bloc du plugin à la fin du fichier. (WordPress mono-site utilise des conditions négatives !-f/!-d, ce qui explique pourquoi le même plugin fonctionne dans ce cas.) Cela expose déjà les fichiers du site principal du réseau.
Les chemins des sous-sites ne correspondent pas du tout au motif. Sur multisite, chaque site possède ses propres uploads — wp-content/uploads/sites/N/…, plus le préfixe d'URL /siteN/ dans les réseaux en sous-répertoire — alors que la règle est codée en dur sur ^wp-content/uploads/upf-docs/ à partir de l'URL de base du site principal. Même après que la réécriture interne du réseau a retiré le préfixe /siteN/, le segment sites/N/ ne correspond toujours pas.
Ainsi, pour tout réseau multisite (en sous-répertoire comme en sous-domaine), un attaquant non authentifié récupère directement les fichiers privés :
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 réponse est 200 avec le corps du fichier — servi par Apache directement depuis
uploads/, la vérification d'accès du plugin n'est jamais exécutée.
Rien dans l'URL n'est secret. Le plugin construit les noms à partir de l'identifiant numérique du propriétaire et du timestamp unix (en secondes) de l'upload :
| Générateur (2.1.9) | Nom sur disque |
|---|---|
interface moderne, inc/functions-file.php:73-77 | usr{ID}_{unixtime}_{original} |
template classique, inc/classic-user-functions.php | user_{ID}_doc_{unixtime}.{ext} |
Les identifiants utilisateur sur un réseau multisite sont de petits entiers
séquentiels issus d'une table wp_users partagée, donc un attaquant n'a besoin que
d'une estimation approximative de l'heure d'upload (une fenêtre de ±2 minutes
représente ≈240 requêtes) et soit du nom de fichier original, soit simplement de son
extension. Les suffixes de collision (-1, -2) existent mais sont rares.
La version 2.2.0 a remplacé la réécriture du fichier racine par un .htaccess
upf-docs/ au niveau du dossier — RewriteRule ^(.*)$ {home}/?file=$1 [R=302,QSA,L],
plus Require all denied lorsque mod_rewrite est absent — recréé à chaque
admin_init (auto-réparateur, par sous-site). Une règle par répertoire s'applique à
tout chemin physique sous upf-docs/ quel que soit le préfixe de l'URL, donc le
GET direct devient désormais un 302 vers la vérification d'accès et, pour les
utilisateurs anonymes, un 403. Les fichiers ne sont pas déplacés ; seul le
mécanisme d'interception change. Cette distinction 302-vs-statique est aussi ce que
l'exploit utilise comme détecteur comportemental.
.htaccess et que le fichier était accessible en écriture à l'activation (nginx
nécessite la réécriture manuelle du fournisseur — voir le readme du plugin).exploit.py est en Python 3 pur, bibliothèque standard uniquement (http.client),
ne suit aucune redirection et n'envoie aucun cookie :