0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

#0214(2025/08/14)ディレクトリトラバーサルの概要

Posted at

ディレクトリトラバーサルの概要(実務者向けガイド)

「ディレクトリトラバーサル」とは、ユーザ入力を用いたパス操作を悪用し、本来アクセスできないファイルやディレクトリに到達させる攻撃である。


なぜ起きる?(まず押さえる概念)

  • 信頼できない入力をパス結合に使うと、..(親ディレクトリ)や区切り文字(/\)でルート外へ脱出できる。
  • 正規化(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\..\..\ など

まず比較で理解(アンチパターン 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_NOFOLLOWlstat
  • 拡張子/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.ini
    • C:..\..\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/lstatrealpath で実体確認。
  • 解凍Rel/relative_to チェックを忘れない。
  • 運用:ログ検知とレート制限。

「パスを文字列として信じない」。常に “base 配下保証” を先に作る——これが最短の『事故らない』道。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?