ファイル名を受け取って返すだけのコード。たった6文字を足されると、本番DBのパスワードが返ってきます。特別な道具もログインも要りません。ブラウザのURLを書き換えるだけです。
サーバーは「置き場所」と「もらったファイル名」をくっつけて、パスを組み立てます。
ファイル名のつもりで受け取った文字列に ../ が混ざっていると、OSは素直に一段上のフォルダへ移動します。枠の外に出ても、誰も止めません。
../ を2回で app/.env、5回でサーバーのルート。そこから etc/passwd でも何でも指定できる。サーバー側の書き方を3通り切り替えて、同じ文字列を投げてみてください。すべてこのページの中だけの擬似サーバーです。
③ を選ぶと、どの文字列を入れても 403 で止まります。止め方を「文字列の見た目」ではなく「最終的にどのファイルを開くか」で決めているからです。
実務でいちばん痛いのは /etc/passwd ではなく、アプリ自身の設定ファイルです。
DBのパスワード、決済APIの秘密鍵、暗号化キー。ここを取られた時点で、外から本番DBに入られます。
直接の被害は小さいが「トラバーサルが刺さる」証明になる。次に狙われるのは鍵ファイルや設定です。
認証の抜け道、ハードコードされた接続情報、別の脆弱性。攻撃者に設計書を渡すのと同じです。
読み取りだけの話ではありません。アップロード保存先に受け取った名前をそのまま使うと、../../public/shell.php のような名前で書き込みされ、サーバーを乗っ取られます。ZIP展開(Zip Slip)も同じ形の穴です。
消した結果、また ../ が現れます。上の実験の ② で再現できます。1回だけ消す・順番に消す、どちらも破られます。
URLエンコード、二重エンコード、Windowsの円記号区切り。抜け道の方が多く、追いかけきれません。
拡張子は「どこにあるか」を何も保証しません。場所の検証にはなりません。
攻撃者はあなたの画面を使いません。検証は必ずサーバー側で行います。
ユーザーからは ID を受け取り、実際のファイル名はDBやマップから引く。文字列がパスに混ざらないので、この脆弱性は構造的に発生しません。新規実装ではまずこれを検討してください。
// file=report.pdf ではなく file_id=1042 で受ける
const row = await db.files.findById(req.query.file_id);
res.sendFile(path.join(BASE, row.stored_name));
文字列を見て判断するのをやめ、OSが最終的に開くパスを求めてから、それが公開フォルダの中かを確かめます。../ が何回あっても、エンコードされていても関係ありません。
const BASE = path.resolve('/var/www/app/public/files');
const target = path.resolve(BASE, req.query.file ?? '');
// BASE の外に出たら問答無用で拒否
if (target !== BASE && !target.startsWith(BASE + path.sep)) {
return res.status(403).end();
}
res.sendFile(target);
$base = realpath('/var/www/app/public/files');
$target = realpath($base . '/' . $_GET['file']);
// realpath はシンボリックリンクも解決する。false = 存在しない
if ($target === false || !str_starts_with($target, $base . DIRECTORY_SEPARATOR)) {
http_response_code(403);
exit;
}
readfile($target);
Path base = Paths.get("/var/www/app/public/files").toRealPath();
Path target = base.resolve(name).normalize();
// normalize() を忘れると ../ が残ったまま比較されて素通りする
if (!target.startsWith(base)) {
throw new AccessDeniedException(name);
}
return Files.newInputStream(target);
base = Path("/var/www/app/public/files").resolve()
target = (base / name).resolve()
# Python 3.9+ 。Flask なら send_from_directory が同じ検証をしてくれる
if not target.is_relative_to(base):
abort(403)
return send_file(target)
補足:解決前に path.basename() 相当でディレクトリ成分を捨てられるなら、さらに安全です。フレームワークの既製品(Express の res.sendFile の root 指定、Flask の send_from_directory)を使うのが結局いちばん確実です。
リクエスト由来の変数が、下のどれかに渡っていないか grep してください。1つでも当たったら、上の ② の検証が入っているか確認します。