
PoC independente e laboratório reproduzível para CVE-2026-97332 — divulgação não autenticada de arquivos privados no User Private Files < 2.2.0 em WordPress Multisite.
[!NOTE] Este repositório é uma reprodução independente e Prova de Conceito para CVE-2026-97332.
Descoberta original da vulnerabilidade: Akshat Parikh (SN1PER). CNA / advisory: WPScan. PoC e reprodução independentes: Kolya080808.
Divulgação não autenticada de arquivos privados de outros usuários no plugin
do WordPress User Private Files (slug do wp.org user-private-files) anterior a 2.2.0
em instalações multisite. O plugin armazena os documentos privados de cada usuário
dentro da árvore simples de uploads e os protege com uma única RewriteRule
anexada ao .htaccess raiz, que encaminha as requisições de arquivos por meio de uma
verificação de acesso em PHP. Em uma rede multisite essa regra nunca é alcançada: um simples
GET — sem cookies, sem cabeçalhos, sem login — retorna o arquivo privado de outro usuário
como conteúdo estático. Controle de Acesso Quebrado, CWE-284, CVSS 3.1 5.3
(AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N).
Este repositório contém um exploit Python funcional e sem dependências e um laboratório docker de dois contêineres (vulnerável 2.1.9 e corrigido 2.2.0, ambos WordPress multisite) para reproduzir o ataque de ponta a ponta.
[!CAUTION] Este material é fornecido para pesquisa de segurança autorizada e testes defensivos apenas (consulte o aviso legal no final). Atacar sistemas que você não possui ou opera sem permissão por escrito é ilegal.
O plugin mantém arquivos privados como anexos comuns em um subdiretório plano de uploads
por site — wp-content/uploads/upf-docs/ (site principal) e
wp-content/uploads/sites/N/upf-docs/ (subsite N) — sem isolamento
por usuário. O controle de acesso não está no diretório; ele reside no PHP
(dl-file.php: 403 para usuários anônimos, depois uma verificação de autor/lista de permissões) e
é alcançado apenas por meio de uma regra de reescrita emitida na ativação do plugin no
.htaccess raiz:
# appended by 2.1.9 (upvf_actdeact.php), AFTER the WordPress block
RewriteRule ^wp-content/uploads/upf-docs/(.*)$ https://site/?file=$1 [QSA,L]
Em multisite isto nunca é acionado, por duas razões independentes:
O .htaccess da rede interceta primeiro os ficheiros existentes. O WordPress
multisite escreve o seu próprio conjunto de regras no .htaccess da raiz:
RewriteCond %{REQUEST_FILENAME} -f [OR]
RewriteCond %{REQUEST_FILENAME} -d
RewriteRule ^ - [L]
Uma requisição mapeada para um arquivo fisicamente existente termina a reescrita ali mesmo — o Apache o serve como conteúdo estático, e o mod_rewrite nunca chega ao bloco do plugin no final do arquivo. (O WordPress de site único usa condições negativas !-f/!-d, e é por isso que o mesmo plugin funciona lá.) Isso já expõe arquivos do site principal da rede.
Os caminhos dos subsites não correspondem ao padrão de forma alguma. No multisite, cada site tem seus próprios uploads — wp-content/uploads/sites/N/…, além do prefixo de URL /siteN/ em redes de subdiretório — enquanto a regra está fixada em ^wp-content/uploads/upf-docs/ a partir da URL base do site principal. Mesmo depois que a reescrita interna da rede remove o prefixo /siteN/, o segmento sites/N/ ainda não corresponde.
Assim, para qualquer rede multisite (tanto de subdiretório quanto de subdomínio), um atacante não autenticado obtém arquivos privados diretamente:
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)
A resposta é 200 com o corpo do arquivo — servido pelo Apache diretamente de
uploads/, a verificação de acesso do plugin nunca é executada.
Nada na URL é secreto. O plugin constrói os nomes a partir do id numérico do utilizador proprietário e do segundo unix do upload:
| Gerador (2.1.9) | Nome no disco |
|---|---|
UI moderna, inc/functions-file.php:73-77 | usr{ID}_{unixtime}_{original} |
template clássico, inc/classic-user-functions.php | user_{ID}_doc_{unixtime}.{ext} |
Os ids de utilizador numa rede multisite são pequenos inteiros sequenciais de
uma tabela wp_users partilhada, pelo que um atacante precisa apenas de uma
estimativa aproximada da hora do upload (uma janela de ±2 minutos é ≈240
pedidos) e do nome original do arquivo ou apenas da sua extensão. Sufixos de
colisão (-1, -2) existem, mas são raros.
A versão 2.2.0 substituiu a reescrita do arquivo raiz por um
upf-docs/.htaccess ao nível da pasta — RewriteRule ^(.*)$ {home}/?file=$1 [R=302,QSA,L],
mais Require all denied quando o mod_rewrite está ausente — recriado em cada
admin_init (auto-recuperação, por subsite). Uma regra por diretório é acionada
para qualquer caminho físico sob upf-docs/, independentemente do prefixo da
URL, pelo que o GET direto passa agora a ser um 302 para a verificação de
acesso e, para utilizadores anónimos, um 403. Os arquivos não são movidos;
apenas o mecanismo de interceção muda. Essa distinção entre 302 e estático é
também o que o exploit usa como detetor comportamental.
.htaccess e o arquivo fosse gravável na ativação (o nginx precisa da
reescrita manual do fornecedor — ver o readme do plugin).O exploit.py usa apenas a biblioteca padrão do Python 3 (http.client), não
segue redirecionamentos e não envia cookies:
GET /wp-content/plugins/user-private-files/readme.txt, analisa Stable tag:.
< 2.2.0 → prossegue; >= 2.2.0 → caixa vermelha NOT VULNERABLE - plugin 2.2.0+ (patched) e código de saída 6. Se o readme.txt não for legível, é
mostrada uma sonda comportamental de um nome inexistente em upf-docs/
(302 = reescrita em vigor, 404 = entrega estática) e a execução continua
(--skip-check desativa todo o passo).{base}/wp-content/uploads/upf-docs/{file}
ou, com --site site2 --site-id 2,
{base}/site2/wp-content/uploads/sites/2/upf-docs/{file}.GET, sem Cookie, sem Authorization.200 → * LEAKED com as primeiras linhas do arquivo como prova, saída 0;302 → "redirected to the access check - not vulnerable / patched", saída 1;404 → "file not found (wrong name?)" — tente --guess, saída 1.