
PoC indipendente e laboratorio riproducibile per CVE-2026-97332 — divulgazione non autenticata di file privati in User Private Files < 2.2.0 su WordPress Multisite.
[!NOTE] Questo repository è una riproduzione indipendente e Proof of Concept per CVE-2026-97332.
Scoperta originale della vulnerabilità: Akshat Parikh (SN1PER). CNA / advisory: WPScan. PoC e riproduzione indipendenti: Kolya080808.
Divulgazione non autenticata dei file privati di altri utenti nel plugin
WordPress User Private Files (slug wp.org user-private-files) prima della 2.2.0
su installazioni multisite. Il plugin memorizza i documenti privati di ogni utente
all'interno del normale albero degli upload e li protegge con una singola RewriteRule
aggiunta al .htaccess root, che instrada le richieste dei file attraverso un
controllo di accesso PHP. Su una rete multisite quella regola non viene mai raggiunta: una semplice
GET — senza cookie, senza header, senza login — restituisce il file privato di un altro utente
come contenuto statico. Broken Access Control, CWE-284, CVSS 3.1 5.3
(AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N).
Questo repository contiene un exploit Python funzionante e senza dipendenze e un lab docker a due container (vulnerabile 2.1.9 e corretta 2.2.0, entrambe WordPress multisite) per riprodurre l'attacco end-to-end.
[!CAUTION] Questo materiale è fornito esclusivamente per ricerca di sicurezza autorizzata e test difensivi (vedi il disclaimer in fondo). Attaccare sistemi di cui non si è proprietari o che non si gestiscono senza autorizzazione scritta è illegale.
Il plugin conserva i file privati come normali allegati in una sottodirectory piatta degli upload per sito
— wp-content/uploads/upf-docs/ (sito principale) e
wp-content/uploads/sites/N/upf-docs/ (sottosito N) — senza alcun
isolamento per utente. Il controllo di accesso non è sulla directory; risiede in PHP
(dl-file.php: 403 per utenti anonimi, poi un controllo autore/allow-list) ed
è raggiunto solo tramite una rewrite emessa all'attivazione del plugin nel .htaccess
root:
# appended by 2.1.9 (upvf_actdeact.php), AFTER the WordPress block
RewriteRule ^wp-content/uploads/upf-docs/(.*)$ https://site/?file=$1 [QSA,L]
Su multisite questo non viene mai attivato, per due ragioni indipendenti:
Il file .htaccess della rete intercetta prima i file esistenti. WordPress
multisite scrive il proprio set di regole nel file .htaccess radice:
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
Una richiesta che corrisponde a un file fisicamente esistente termina la riscrittura proprio lì — Apache lo serve come contenuto statico, e mod_rewrite non raggiunge mai il blocco del plugin alla fine del file. (WordPress a sito singolo usa condizioni negative !-f/!-d, ed è per questo che lo stesso plugin funziona lì.) Questo espone già i file del sito principale della rete.
I percorsi dei sottositi non corrispondono affatto al pattern. Su multisite ogni sito ha i propri upload — wp-content/uploads/sites/N/…, più il prefisso URL /siteN/ nelle reti con sottodirectory — mentre la regola è codificata in modo fisso su ^wp-content/uploads/upf-docs/ dalla base URL del sito principale. Anche dopo che la riscrittura interna della rete rimuove il prefisso /siteN/, il segmento sites/N/ continua a non corrispondere.
Quindi per qualsiasi rete multisite (sia con sottodirectory che con sottodominio) un attaccante non autenticato recupera i file privati direttamente:
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 risposta è 200 con il corpo del file — servito da Apache direttamente da
uploads/, il controllo di accesso del plugin non viene mai eseguito.
Nulla nell'URL è segreto. Il plugin costruisce i nomi a partire dall'id utente numerico del proprietario e dai secondi unix del caricamento:
| Generatore (2.1.9) | Nome su disco |
|---|---|
UI moderna, inc/functions-file.php:73-77 | usr{ID}_{unixtime}_{original} |
template classico, inc/classic-user-functions.php | user_{ID}_doc_{unixtime}.{ext} |
Gli id utente su una rete multisito sono piccoli interi sequenziali da una
tabella wp_users condivisa, quindi un attaccante necessita solo di un orario
di caricamento approssimativo (una finestra di ±2 minuti è ≈240 richieste) e
del nome file originale oppure solo della sua estensione. I suffissi di
collisione (-1, -2) esistono ma sono rari.
La versione 2.2.0 ha sostituito la riscrittura del file radice con un
.htaccess a livello di cartella in upf-docs/ — RewriteRule ^(.*)$ {home}/?file=$1 [R=302,QSA,L],
più Require all denied quando mod_rewrite è assente — ricreato a ogni
admin_init (auto-riparante, per sottosito). Una regola per-directory scatta
per qualsiasi percorso fisico sotto upf-docs/ indipendentemente dal prefisso
dell'URL, quindi la GET diretta ora diventa un 302 verso il controllo di
accesso e, per gli utenti anonimi, un 403. I file non vengono spostati; cambia
solo il meccanismo di intercettazione. Tale distinzione tra 302 e statico è
anche ciò che l'exploit usa come rilevatore comportamentale.
.htaccess e il file era scrivibile all'attivazione (nginx
richiede la riscrittura manuale del fornitore — vedi il readme del plugin).exploit.py usa solo la libreria standard di Python 3 (http.client), non
segue redirect e non invia cookie:
GET /wp-content/plugins/user-private-files/readme.txt, analizza Stable tag:.
< 2.2.0 → procedi; >= 2.2.0 → riquadro rosso NOT VULNERABLE - plugin 2.2.0+ (patched) e codice di uscita 6. Se readme.txt non è leggibile, viene
mostrata una sonda comportamentale su un nome upf-docs/ inesistente (302 = riscrittura in
atto, 404 = consegna statica) e l'esecuzione continua
(--skip-check disabilita l'intero passo).{base}/wp-content/uploads/upf-docs/{file}
oppure, con --site site2 --site-id 2,
{base}/site2/wp-content/uploads/sites/2/upf-docs/{file}.GET, senza Cookie, senza
Authorization.200 → * LEAKED con le prime righe del file come prova, uscita 0;302 → "redirected to the access check - not vulnerable / patched", uscita 1;404 → "file not found (wrong name?)" — prova --guess, uscita 1.