おい、ちょっと手を止めてくれ。
先週、うちのチームが担当している某SaaSプラットフォームの定期ペネトレーションテスト結果が出たんだが……また「あれ」をやらかしていた。そう、パストラバーサル(ディレクトリトラバーサル)だ。
「フレームワークがよしなにやってくれると思った」「ファイル名のサニタイズはちゃんとしたつもりだった」――インシデントが起きた後に開発現場から聞こえてくるのは、決まってこの言い訳だ。だが、現実は甘くない。攻撃者はフレームワークの隙間を縫い、設定のミドルウェアの癖を突き、平気で /etc/passwd やデータベースの接続設定ファイル、果てはソースコードをごっそり抜き取っていく。
今回は、レッドチームの視点から「なぜ脆弱なコードが生まれ、攻撃者がどうやってそれを突破するのか」の生々しい手口を解説し、二度と社内で同じ過ちが起きないための『完全に塞ぐための実装』を叩き込む。エンジニアとして生き残りたいなら、今日の話を骨の髄まで覚えて帰ってくれ。
—
1. なぜ「パストラバーサル」は今でも消えないのか?
パストラバーサルの本質は、「ユーザーから受け取った入力値が、ファイルシステムのパスを構築する処理に直接、あるいは不十分に加工されて組み込まれること」にある。
攻撃者は、URLパラメータやPOSTデータ、さらにはHTTPヘッダー(RefererやUser-Agentなど、意外と見落としがちな場所だ)に ../../ のような相対パス指定や、URLエンコード、ヌルバイト( %00:言語やバージョンによっては今でも刺さる)を混ぜ込んでリクエストを送ってくる。
よくある勘違いが、「ファイル名の前に特定のディレクトリを結合しているから安全だ」という思い込みだ。
例えば、以下のようなコードを見たことはないだろうか?
// 【やってはいけない典型的な脆弱コード】
$file = $_GET['file'];
$filepath = "/var/www/html/uploads/" . $file;
readfile($filepath);
一見すると /var/www/html/uploads/ の中に閉じ込められているように見える。しかし、攻撃者が file=../../../../etc/passwd とリクエストしたらどうなるか? OSのファイルシステムはこれを素直に解釈し、結合後のパスは /var/www/html/uploads/../../../../etc/passwd、すなわち /etc/passwd を指すことになる。これがトラバーサル(横断・脱出)のメカニズムだ。
—
2. 攻撃者の視点:WAFやブラックリストをどうバイパスするか
セキュリティ意識の高いチームだと、「../ という文字列を検知したら弾く(ブラックリスト方式)」という対策を入れたがる。だが、レッドチームの我々から言わせれば、ブラックリストは「穴だらけのザル」に等しい。
攻撃者は次のような手口でいとも簡単にこれをバイパスする。
- URL二重エンコード:
%2e%2e%2fをさらにエンコードして%252e%252e%252fにする。アプリケーション層のデコード処理のタイミングによっては、WAFをすり抜けて内部で正しく解釈されてしまう。 - バックスラッシュの利用: Windows環境や、一部のPHP関数(
basename()の挙動など)では、..\がパス区切り文字として認識されることがある。 - 絶対パスの指定: ベースディレクトリからの相対パスではなく、最初から
/etc/passwdのような絶対パスを直接渡す(もしアプリケーション側で単純な文字列結合をしている場合、ベースディレクトリが無効化されて一発で通る)。
だからこそ、「不審な文字列を弾く」のではなく、「安全なパス以外は絶対に受け付けない(ホワイトリスト方式と正規化)」という設計にシフトしなければならない。
—
3. 【完全防御】セキュアな実装サンプルコード
では、具体的にどう書けばいいのか。実務でそのまま使えるPHPの実装サンプルを示す。ここで重要なのは、realpath() を使ったパスの正規化と、ベースディレクトリ(ルート)で厳密に前方一致しているかの検証だ。
<?php
/**
* セキュアなファイル読み込み関数(パストラバーサル完全防御)
*
* @param string $filename ユーザーからの入力ファイル名
* @param string $baseDir 許可されたベースディレクトリの絶対パス
* @return string 安全に解決されたファイルの絶対パス
* @throws Exception 不正なアクセスの場合は例外をスロー
*/
function getSecureFilePath(string $filename, string $baseDir): string {
// 1. ベースディレクトリ自体の絶対パスを正規化して取得しておく(末尾のスラッシュを統一)
$realBaseDir = realpath($baseDir);
if ($realBaseDir === false) {
throw new Exception("ベースディレクトリが存在しません。");
}
$realBaseDir = rtrim($realBaseDir, DIRECTORY_SEPARATOR) . DIRECTORY_SEPARATOR;
// 2. 結合前のファイル名に対して、危険な制御文字やヌルバイトが含まれていないかチェック
// basename() を使って、余計なディレクトリパス構造を強制的に削ぎ落とす
$safeFilename = basename($filename);
// 3. 結合して絶対パスに解決する(realpathはファイルが存在しないとfalseを返す点に注意)
// ファイルが存在しない場合は、別途ハンドリングが必要
$targetPath = $realBaseDir . $safeFilename;
$realTargetPath = realpath($targetPath);
// ファイルが存在しない場合の考慮(新規作成や存在チェック用)
if ($realTargetPath === false) {
// ファイルが存在しない場合、パスの存在しないトラバーサルが含まれている可能性を排除するため
// 論理的にベース内かどうかをチェックする(簡易的なフォールバック)
// ただし、基本的には「ファイルが存在すること」を前提とするダウンロード機能等であればここで弾くべき。
throw new Exception("指定されたファイルが見つかりません。");
}
// 4. 【最重要】解決された絶対パスが、ベースディレクトリから始まっているかを厳密に検証する
// strpos() で先頭(0番目)に一致するか確認
if (strpos($realTargetPath, $realBaseDir) !== 0) {
// 攻撃検知:インシデントログに記録することを強く推奨
error_log("[SECURITY ALERT] パストラバーサル攻撃の検知: " . $filename);
throw new Exception("アクセスが拒否されました。");
}
return $realTargetPath;
}
// --- 使用例 ---
try {
$userRequestedFile = $_GET['file'] ?? '';
// 許可された公開用ディレクトリ
$documentRoot = '/var/www/html/safe_downloads/';
$filePath = getSecureFilePath($userRequestedFile, $documentRoot);
// 安全が確認されたファイルを出力
header('Content-Type: application/octet-stream');
header('Content-Disposition: attachment; filename="' . basename($filePath) . '"');
readfile($filePath);
exit;
} catch (Exception $e) {
// ユーザーには詳細なエラー理由を伝えず、一律で404または403を返す
header("HTTP/1.1 403 Forbidden");
echo "Access Denied.";
exit;
}
このコードのポイントは、basename($filename) でディレクトリ構造を一切排除し、さらに realpath() でシンボリックリンクも含めた最終的な絶対パスを割り出した上で、strpos() でベースディレクトリ配下に確実に収まっているかを二重・三重にチェックしている点だ。これさえ守れば、どれだけ巧妙な ../ を仕掛けられようとも、ベースの外に出ることは物理的・論理的に不可能になる。
—
4. アプリケーション層だけじゃない!インフラ層での多層防御
開発者がどれだけ頑張っても、ヒューマンエラーやサードパーティ製ライブラリの脆弱性によって穴が開くことはある。だからこそ、インフラ・Webサーバー層でも多層防御を張るのがプロの仕事だ。
Nginxでの設定例
もしNginxをリバースプロキシや静的ファイル配信として使っているなら、特定のエンドポイントにおける意図しないパスの解釈を防ぐ設定を入れておこう。
location /downloads/ {
alias /var/www/html/safe_downloads/;
# 悪意あるエンコード文字や連続するスラッシュを正規化・ブロック、
# または厳密にアクセス権を制限する
try_files $uri =404;
# プロキシ環境下でのパスの不整合を防ぐため、alias使用時は末尾のスラッシュに注意
}
さらに、アプリケーションが稼働するコンテナやプロセス自体に、必要最小限の権限(Principle of Least Privilege)しか与えないこと。万が一パストラバーサルを突かれても、Webサーバーの実行ユーザー(www-data など)が /etc/shadow や他のユーザーのホームディレクトリにアクセスできなければ、被害を最小限(Blast Radiusの縮小)に抑え込むことができる。
—
さいごに
セキュリティは「完璧な一つの壁」を作るゲームじゃない。攻撃者に「ここを突破しても、次があるぞ」と思わせる、幾重もの防壁(ディフェンス・イン・ディープ)を構築するゲームだ。
今日紹介した実装や考え方は、明日から君たちのプロジェクトにすぐ組み込めるはずだ。コードレビューの際、「このファイル読み込み、realpath とベースパスの検証はちゃんとしてるか?」と後輩に声をかけられるシニアエンジニアであってくれ。頼んだぞ。
コメント