ディレクトリトラバーサル / CWE-22

../ が開ける裏口

GET /download?file=../../.env

ファイル名を受け取って返すだけのコード。たった6文字を足されると、本番DBのパスワードが返ってきます。特別な道具もログインも要りません。ブラウザのURLを書き換えるだけです。

しくみ ①

ふつうの動きは、こう

サーバーは「置き場所」と「もらったファイル名」をくっつけて、パスを組み立てます。

ブラウザ ?file=report.pdf ファイル名を渡す 中身を返す サーバー /var/www/app/public/files/ report.pdf manual.pdf price.xlsx
点線の枠が「公開してよい置き場所」。サーバーは 置き場所 + もらった名前 でパスを作る。
しくみ ②

../ は「ひとつ上」

ファイル名のつもりで受け取った文字列に ../ が混ざっていると、OSは素直に一段上のフォルダへ移動します。枠の外に出ても、誰も止めません。

/ サーバーの一番上 var/ www/ app/ アプリ本体 public/ files/ 公開フォルダ ../ ../ ../ ../ ../ etc/passwd .env スタート地点はここ。あとは上に登るだけ。
../ を2回で app/.env、5回でサーバーのルート。そこから etc/passwd でも何でも指定できる。
実験

自分で動かしてみる

サーバー側の書き方を3通り切り替えて、同じ文字列を投げてみてください。すべてこのページの中だけの擬似サーバーです。

擬似ファイル配信サーバー
GET /download?file=… 公開フォルダ: /var/www/app/public/files
サーバー側のコード

③ を選ぶと、どの文字列を入れても 403 で止まります。止め方を「文字列の見た目」ではなく「最終的にどのファイルを開くか」で決めているからです。

被害

読まれて困るもの

実務でいちばん痛いのは /etc/passwd ではなく、アプリ自身の設定ファイルです。

.env

本番の鍵束

DBのパスワード、決済APIの秘密鍵、暗号化キー。ここを取られた時点で、外から本番DBに入られます。

/etc/passwd

侵入の下見

直接の被害は小さいが「トラバーサルが刺さる」証明になる。次に狙われるのは鍵ファイルや設定です。

src/*.php

ソースコード

認証の抜け道、ハードコードされた接続情報、別の脆弱性。攻撃者に設計書を渡すのと同じです。

読み取りだけの話ではありません。アップロード保存先に受け取った名前をそのまま使うと、../../public/shell.php のような名前で書き込みされ、サーバーを乗っ取られます。ZIP展開(Zip Slip)も同じ形の穴です。

よくある間違い

効かない対策

../ を文字列から消す
"....//....//.env".replaceAll("../", "") → "../../.env"

消した結果、また ../ が現れます。上の実験の ② で再現できます。1回だけ消す・順番に消す、どちらも破られます。

危ない文字をブラックリストで弾く
..%2f..%2f ..%252f ..\..\ /絶対パス

URLエンコード、二重エンコード、Windowsの円記号区切り。抜け道の方が多く、追いかけきれません。

拡張子が .pdf なら許可する
../../../var/log/app.pdf ../../uploads/evil.pdf

拡張子は「どこにあるか」を何も保証しません。場所の検証にはなりません。

画面側でバリデーションしている
curl "https://app.example.com/download?file=../../.env"

攻撃者はあなたの画面を使いません。検証は必ずサーバー側で行います。

対策

効く対策は2つだけ

いちばん強い

そもそもパスを受け取らない

ユーザーからは 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が最終的に開くパスを求めてから、それが公開フォルダの中かを確かめます。../ が何回あっても、エンコードされていても関係ありません。

受け取った文字列 ../../.env ① 実パスに解決 realpath / resolve ② 配下? startsWith(BASE) はい 200 返す いいえ 403 拒否
判断材料は「文字列の見た目」ではなく「解決後のパスの位置」。これが唯一まともに効く検証。
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);

補足:解決前に path.basename() 相当でディレクトリ成分を捨てられるなら、さらに安全です。フレームワークの既製品(Express の res.sendFileroot 指定、Flask の send_from_directory)を使うのが結局いちばん確実です。

点検

自分のコードを探す

リクエスト由来の変数が、下のどれかに渡っていないか grep してください。1つでも当たったら、上の ② の検証が入っているか確認します。

readFile / createReadStream / sendFile
Node.js のファイル配信
file_get_contents / readfile / include / require
PHP。include 系は実行までされる
new File( / Paths.get( / FileInputStream
Java
open( / send_file(
Python
getName() のZIP展開
Zip Slip。展開先も配下チェックが必要
アップロードの保存先パス
元のファイル名を使わず、こちらで採番する
nginx の alias 設定
末尾スラッシュ抜けで location 外が読める

30秒でまとめ

  1. ユーザーの文字列をパスに連結した時点で、../ でサーバー中どこへでも行けます。
  2. 文字を消す・弾く対策は必ず破られます。実パスに解決してから、公開フォルダ配下かを確認する。
  3. いちばん良いのは、パスではなくIDで受け取ること。書き込み側(アップロード・ZIP展開)も同じです。