ディレクトリトラバーサルの概要(実務者向けガイド)
「ディレクトリトラバーサル」とは、ユーザ入力を用いたパス操作を悪用し、本来アクセスできないファイルやディレクトリに到達させる攻撃である。
なぜ起きる?(まず押さえる概念)
-
信頼できない入力をパス結合に使うと、
..(親ディレクトリ)や区切り文字(/、\)でルート外へ脱出できる。 -
正規化(normalization)不足:
%2e%2e%2f、..%2f、%252e%252e%252f(二重エンコード)など、見かけを変えても同じ意味。 -
OS・FS差異:Windows の
\、ドライブレター(C:)、UNC(\\host\share)、NTFSの 8.3 名や代替データストリーム(file.txt:zone.identifier)。 - シンボリックリンク/ジャンクション:表向きは安全な配下でも、リンクを辿るとルート外へ逸脱。
-
アーカイブ展開(Zip Slip):解凍時に
../../etc/passwdのようなエントリが混入。
攻撃の基本メカニズム
-
典型:
/download?file=../../etc/passwd→ サーバ側がBASE + user_inputをそのまま結合して開く。 -
バイパスの小技:
- URL エンコード:
..%2f..%2fetc%2fpasswd - 二重エンコード:
%252e%252e%252f - バックスラッシュ混在:
..\..\windows\win.ini - 末尾にドットや空白(Windows):
secret.、secret... - Unicode 正規化(NFC/NFD)差、疑似スラッシュ文字(全角スラッシュなど)
-
OS パス解釈の差:
C:../Windows、\\?\C:\Windows\..\..\など
- URL エンコード:
まず比較で理解(アンチパターン vs 推奨)
| テーマ | ありがち(NG) | 推奨(OK) |
|---|---|---|
| 検証方針 |
".." を含むか などのブラックリスト
|
ホワイトリスト:事前に定義したキー → 物理ファイル名にマップ |
| パス結合 |
BASE + "/" + userPath をそのまま |
正規化(resolve/realpath)後にルート配下判定してから open |
| シンボリックリンク | 気にしない |
O_NOFOLLOW/lstat でリンク拒否 or 固定化(chroot/jail/containers) |
| エンコード | 入力のまま結合 | デコード → 正規化 → 判定の順で一貫処理 |
| 静的配信 | ルーティングで任意ファイル可 | 固定ディレクトリのインデックスのみ配信 or 拡張子制限 |
| ログ監視 | しない |
../,..\,%2e%2e などのパターンを検知しアラート |
言語別:正規化 API の要点比較
| 言語/ランタイム | 正規化/解決 | 物理解決(リンク解決) | ルート配下チェックの定石 |
|---|---|---|---|
| Python |
pathlib.Path(p).resolve(strict=False) / os.path.normpath
|
resolve(strict=True) で実在&リンク解決 |
resolved.is_relative_to(base)(3.9- は str(resolved).startswith(str(base)+sep) + commonpath) |
| Node.js |
path.normalize, path.resolve
|
実体解決は別途(fs.realpath) |
real.startsWith(base + sep) を 共通パスで確認 |
| Go | filepath.Clean |
filepath.EvalSymlinks |
strings.HasPrefix ではなく filepath.Rel(base, target) で .. を拒否 |
| Java | Path.normalize() |
toRealPath() |
base.relativize(real) で .. を含まないことを確認 |
注意:単純な
startsWithは"/var/www/siteX"と"/var/www/siteX_backup"の混同を起こすため、共通パス計算で厳密に。
脆弱な例と安全な例
Node.js(Express)
NG:
app.get('/view', (req, res) => {
const p = req.query.file; // trust user input
const full = path.join('/srv/files', p);
return res.sendFile(full); // 正規化・配下判定なし
});
OK:
import fs from 'node:fs/promises';
import path from 'node:path';
app.get('/view', async (req, res) => {
const key = req.query.id; // 1) ID -> 物理名のマップ(推奨)
const mapping = {
'manual': 'manual.pdf',
'price': 'price.csv',
};
const filename = mapping[key];
if (!filename) return res.sendStatus(404);
const base = '/srv/files';
const realBase = await fs.realpath(base);
const realTarget = await fs.realpath(path.join(base, filename));
if (!realTarget.startsWith(realBase + path.sep)) return res.sendStatus(403);
res.sendFile(realTarget);
});
Python(Flask)
NG:
@app.get('/download')
def download():
name = request.args.get('name', '')
return send_file(f"/data/{name}") # 検証なし
OK:
from pathlib import Path
from flask import abort, send_file, request
BASE = Path('/data').resolve()
@app.get('/download')
def download():
key = request.args.get('id')
mapping = {'rpt': 'report.csv', 'img': 'photo.jpg'}
fname = mapping.get(key)
if not fname:
abort(404)
target = (BASE / fname).resolve(strict=True)
try:
target.relative_to(BASE) # ルート配下判定
except ValueError:
abort(403)
return send_file(target)
Go(Zip Slip 対策付きの安全解凍)
func safeJoin(base, name string) (string, error) {
target := filepath.Join(base, name)
rel, err := filepath.Rel(base, target)
if err != nil || strings.HasPrefix(rel, "..") {
return "", fmt.Errorf("escape detected")
}
return target, nil
}
func unzipSafe(zipPath, dest string) error {
r, err := zip.OpenReader(zipPath)
if err != nil { return err }
defer r.Close()
for _, f := range r.File {
safePath, err := safeJoin(dest, f.Name)
if err != nil { return err }
if f.FileInfo().IsDir() { os.MkdirAll(safePath, 0755); continue }
if err := os.MkdirAll(filepath.Dir(safePath), 0755); err != nil { return err }
rc, err := f.Open(); if err != nil { return err }
out, err := os.OpenFile(safePath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, f.Mode())
if err != nil { rc.Close(); return err }
io.Copy(out, rc); out.Close(); rc.Close()
}
return nil
}
静的配信サーバ/リバースプロキシでの落とし穴
| ケース | NG 設定 | リスク | 推奨設定 |
|---|---|---|---|
Nginx alias
|
location /img/ { alias /var/www/img; } |
末尾スラッシュ不一致でパス結合の解釈ズレ |
location /img/ { alias /var/www/img/; }(両方 /)+try_files $uri =404;
|
| 任意パス渡し |
location /dl/ { proxy_pass http://app; }(?path= を無検証転送) |
アプリ側で traversal | 固定 ID で参照、拡張子・MIME を限定 |
| エラーページ | 404 をアプリに全部委譲 | 情報露出・ループ | 404/403 を明示ハンドリング、詳細パスを出さない |
開発者が気をつけるべきこと(チェックリスト)
-
ID 参照に置き換え(
?file=...をやめ?id=...) - 入力はデコード → 正規化 → ルート配下判定の順で統一
-
シンボリックリンク・ジャンクションを辿らない(
O_NOFOLLOW、lstat) -
拡張子/MIME をホワイトリスト(
.pdf,.pngなど) -
Zip/Tar 解凍は必ず
Rel/realpathチェック -
ログに疑わしいパターン(
../,..\,%2e%2e)の検知ルール - エラーメッセージでファイル実パスを表示しない
- テストに traversal 用のペイロードを組み込む
テスト用ペイロード(すぐ使える最小セット)
-
Unix 系:
../../etc/passwd..%2f..%2fetc%2fpasswd%2e%2e/%2e%2e/etc/passwd-
%252e%252e%252fetc%252fpasswd(二重)
-
Windows 系:
..\..\windows\win.ini..%5c..%5cwindows%5cwin.iniC:..\..\Windows\win.ini\\\\remote\\share\\..\\..\\windows\\win.ini
-
変種:
-
..././../etc/passwd、..;/../etc/passwd(区切り混在)
-
これらは必ず 403/404 で拒否し、アプリが例外や 500 を返さないことを確認。
ファイル操作を安全にする“パターン”
1) 「ID → 物理名」マップ(最も強力)
- ユーザ入力は ID のみ。サーバ側で
ID -> ファイル名を解決。 - リンク・エンコード・OS 差の影響を受けにくい。
2) ルート配下保証付き open
疑似コード:
1) base を realpath
2) 入力を decode して join
3) target を realpath(実体解決)
4) target が base の配下か共通パスで厳密判定
5) O_NOFOLLOW などで open、拡張子/MIME もチェック
3) 解凍(Zip/Tar)時の共通対策
- エントリ名に
..、絶対パス、ドライブレター、UNC を検出したら拒否。 -
Rel/relative_toで..を排除してから書き込み。
よくある誤解と反例
| 誤解 | 反例(なぜダメか) |
|---|---|
".." を弾けば安全 |
%2e%2e、%252e%252e、..\、Unicode 正規化差で回避される |
startsWith(base) で十分 |
/var/www/app と /var/www/app_backup の混同、/var/www/appX を許してしまう |
path.normalize したから OK |
シンボリックリンクで外へ出る、normalize はリンク解決をしない |
| 404 を返すから安全 | 500 や詳細エラーで存在や権限の情報が漏れる |
ログ監視・運用の実務 Tips
-
WAF/IDS:
../,..\,%2e%2eを含む URI/クエリ/フォームを検知。 - レート制限:同一 IP から連続する失敗ダウンロードを抑止。
- アプリログの要約:拒否理由(配下外・拡張子不許可・リンク検出など)を構造化して可視化。
まとめ(実務の最小セット)
- 設計:ユーザにファイル名を選ばせない(ID 参照)。
- 実装:デコード → 正規化 → 配下判定 → 拡張子/MIME → open。
-
リンク:
O_NOFOLLOW/lstat、realpathで実体確認。 -
解凍:
Rel/relative_toチェックを忘れない。 - 運用:ログ検知とレート制限。
「パスを文字列として信じない」。常に “base 配下保証” を先に作る——これが最短の『事故らない』道。