本番環境でファイル配信マイクロサービスを6ヶ月運用し、アクセスログに2回の明確なパストラバーサル探索キャンペーンが記録されるのを目の当たりにした後、入力検証レイヤーをゼロから再構築しました。この記事では、攻撃の仕組み、攻撃者が実際に使うバイパス手法、そして現在も効果を発揮し続けている防御パターンを解説します。
クイックスタート(5分)
パストラバーサルとローカルファイルインクルージョン(LFI)は、根本的な原因が同じです。アプリケーションがユーザー入力からファイル名を取得し、サニタイズせずにそのままファイルシステム呼び出しに渡してしまいます。攻撃者は ../ シーケンスを挿入して意図したディレクトリを脱出し、任意のファイルにアクセスします。
ログでリアルタイムの探索活動を見つけるには、このコマンド一つで十分です:
grep -E '(\.\./|%2e%2e%2f|%252e%252e|\.\.\ %2f|%c0%ae)' /var/log/nginx/access.log | tail -20
攻撃者が最初にアクセスする典型的なターゲット:
/etc/passwd— ユーザー列挙/etc/shadow— パスワードハッシュ(root読み取り権限が必要)/proc/self/environ— 環境変数。シークレット情報が含まれることが多い/var/log/nginx/access.log— LFIからRCEへの昇格を狙ったログポイズニング- アプリケーション設定ファイル:
.env、config.php、database.yml
ユーザー指定のファイル名パラメータに基づいてファイルを配信したりテンプレートをインクルードしたりするアプリケーションには、潜在的な攻撃対象領域があります。まずそれらのエンドポイントを監査してください。
詳細解説:攻撃の実際の仕組み
基本的なパストラバーサル
本番環境に入り込みがちなショートカットの典型例として、このPython Flaskのコードを見てみましょう:
from flask import Flask, request, send_file
app = Flask(__name__)
@app.route('/download')
def download():
filename = request.args.get('file')
return send_file(f'/var/www/uploads/{filename}')
/download?file=../../etc/passwd へのリクエストは /var/www/uploads/../../etc/passwd に解決され、OSがこれを /etc/passwd に正規化します。これが攻撃の全てです — 特殊なツールは不要です。
LFI:インクルードが攻撃ベクターになるとき
ローカルファイルインクルージョンはパストラバーサルの近縁です。ファイルの内容を直接配信するのではなく、アプリケーションがそれを実行またはインクルードします。これはPHPの典型的な問題です:
<?php
$page = $_GET['page'];
include("templates/" . $page . ".php");
?>
?page=../../../etc/passwd%00(ヌルバイト注入)をリクエストすると、.php 拡張子が切り捨てられてpasswdファイルがインクルードされます — これはPHPバージョン5.3.4以前で機能していました。PHPは2010年にファイルシステム関数のヌルバイト処理を修正しました。しかし、安全でないインクルードパターンは、古いスタックで動作するレガシーコードベースに今でも見受けられます。
エンコーディングとフィルタバイパス技術
単純な防御策のほとんどはリテラル文字列 ../ をブロックするだけです。攻撃者にはこれを回避するためのエンコーディングツールキットが揃っています:
- URLエンコーディング:
%2e%2e%2fは../にデコードされる - 二重URLエンコーディング:
%252e%252e%252f— サーバーが一度%2e%2e%2fにデコードし、アプリが再度../にデコードする - Unicodeオーバーロングエンコーディング:
%c0%ae%c0%ae/(古いIISとJavaコンテナ) - 混在スラッシュ:
..\— Windowsのパス正規化 - 冗長なセパレータ:
....//— アプリが一回のパスで../を除去する場合、除去後に....//が../に収束する
# ログでよく見るバイパスの試み
curl "https://example.com/view?file=%2e%2e%2f%2e%2e%2fetc%2fpasswd"
curl "https://example.com/view?file=....//....//etc/passwd"
curl "https://example.com/view?file=%252e%252e%252fetc%252fpasswd"
ログポイズニングによるLFIからリモートコード実行への昇格
ログポイズニングは最も危険なエスカレーションです。攻撃者がすでにPHPコードを書き込んだログファイルをインクルードできれば、リモートコード実行が可能になります。その手順を見てみましょう:
- User-AgentヘッダーにPHPペイロードを含むリクエストを送信する:
User-Agent: <?php system($_GET['cmd']); ?> - その文字列が
/var/log/nginx/access.logに書き込まれる - LFIを悪用してアクセスログをインクルードする:
?page=../../var/log/nginx/access.log - PHPインタープリタが汚染されたログエントリを実行する
他にも3つのエスカレーションベクターを挙げておきます。/proc/self/environ — HTTPヘッダーにPHPペイロードを注入してそのファイルをインクルードする手法です。/var/lib/php/sessions/sess_SESSIONID のPHPセッションファイル — セッションにユーザー制御データが保存されている場合に悪用可能です。そしてEXIFメタデータにPHPペイロードを埋め込んだアップロード画像 — ファイルアップロードの検証が拡張子チェックのみの場合によく使われる手口です。
上級編:本当に機能する防御的プログラミングパターン
正しい修正方法:realpath() + 許可リストのプレフィックスチェック
文字列ベースのフィルタリングはエンコーディングの変形に対して脆弱です。正しいアプローチは、OSそのものを使って正規パスを解決してから、既知の安全なプレフィックスと照合することです:
import os
from flask import Flask, request, send_file, abort
app = Flask(__name__)
UPLOAD_DIR = os.path.realpath('/var/www/uploads')
@app.route('/download')
def download():
filename = request.args.get('file', '')
# realpath() は ../ シーケンスとシンボリックリンクをすべて解決する
requested_path = os.path.realpath(os.path.join(UPLOAD_DIR, filename))
# 厳密なプレフィックスチェック — 攻撃者は UPLOAD_DIR から抜け出せない
if not requested_path.startswith(UPLOAD_DIR + os.sep):
abort(403)
if not os.path.isfile(requested_path):
abort(404)
return send_file(requested_path)
os.path.realpath() はチェックが実行される前に、すべてのシンボリックリンクと ../ シーケンスを解決します。ブラックリストも正規表現も不要 — OSレベルの確実な真実だけです。プレフィックスに os.sep サフィックスを付けることで、/var/www/uploads-secret という名前のディレクトリが startswith チェックを通過するのを防ぎます。
PHPの同等実装
<?php
$base_dir = realpath('/var/www/templates');
$requested = realpath($base_dir . '/' . $_GET['page']);
// realpath() は存在しないパスに対して false を返す — 必ず確認すること
if ($requested === false || strpos($requested, $base_dir . DIRECTORY_SEPARATOR) !== 0) {
http_response_code(403);
exit('アクセス禁止');
}
include $requested;
?>
Node.js / Express のパターン
const path = require('path');
const fs = require('fs');
const express = require('express');
const app = express();
const STATIC_DIR = path.resolve('/var/www/files');
app.get('/file', (req, res) => {
const requested = path.resolve(STATIC_DIR, req.query.name || '');
if (!requested.startsWith(STATIC_DIR + path.sep)) {
return res.status(403).send('アクセス禁止');
}
if (!fs.existsSync(requested) || !fs.statSync(requested).isFile()) {
return res.status(404).send('見つかりません');
}
res.sendFile(requested);
});
Nginx:エッジでトラバーサルをブロック
アプリケーションコードが唯一の防御ラインであってはなりません。これらのNginxルールはエッジでトラバーサルパターンを遮断します — リクエストがアプリに到達する前に:
server {
# エッジで一般的なパストラバーサルパターンをブロック
location ~* (\.\.\/|\.\.\\|%2e%2e%2f|%252e%252e) {
return 403;
}
location /uploads {
alias /var/www/uploads/;
# アップロードディレクトリで実行可能ファイルタイプを拒否
location ~* \.(php|py|sh|exe|pl)$ {
deny all;
}
}
}
現場から学んだ実践的なヒント
パスを構築する代わりに明示的な許可リストマップを使用する。 テンプレートやページシステムを構築する場合、有効な名前からファイルパスへのハードコードされたマップを管理します。ユーザー入力はマップからキーを選択するだけで、パス構築には一切触れません:
TEMPLATES = {
'home': 'templates/home.html',
'about': 'templates/about.html',
'contact': 'templates/contact.html',
}
template_name = request.args.get('page', 'home')
template_path = TEMPLATES.get(template_name)
if template_path is None:
abort(404)
低権限ユーザーで実行する。 トラバーサルが成功しても、/etc/shadow やアプリケーションのシークレットへの読み取りアクセスがない www-data として実行されているプロセスは被害を限定します。本番環境のシークレットは環境変数に保管し、設定ファイルのパーミッションを 600 に制限しています。
すべてのサービスで認証情報を強化する。 トラバーサルで到達可能な認証情報は他の場所で再利用しないこと — これは基本的な被害範囲の封じ込めです。アカウントとサービスパスワードのプロビジョニングには、toolcraft.app/ja/tools/security/password-generator のジェネレーターを使用しています。ブラウザ内で完全に実行されるため、パスワードがネットワークを通じて送信されることはありません。注意すべき点:サーバーサイドにデータを送信するオンラインツールを使用している場合、生成ステップ自体が情報漏洩の対象になります。
攻撃者より先に自分の防御をテストする。 ステージング環境で簡単なLFI探索を実行してみましょう:
for payload in '../etc/passwd' '../../etc/passwd' '%2e%2e%2fetc%2fpasswd' '....//etc/passwd'; do
echo -n "テスト中 [$payload]: "
curl -s "https://staging.example.com/view?file=$payload" | head -1
done
レスポンスに root: が含まれていれば、本番リリース前に修正すべき脆弱性が存在することになります。
本番ログのトラバーサルパターンにアラートを設定する。 1時間ごとの ../ ヒット数をカウントして急増時にアラートを発するcronジョブがあれば、探索キャンペーンを早期に検知するには十分です。冒頭で触れた2回のキャンペーンはどちらも、1時間以内に突然10倍に急増したことで明らかになりました — 監視さえ整っていれば一目瞭然です。
パストラバーサルがバグバウンティレポートに繰り返し登場する理由は一つ:脆弱なコードが意図せず書かれやすいからです。send_file(f'/uploads/{filename}') は無害に見えます。realpath() + プレフィックスチェックの追加は同じくらい簡単です。コードベース内でユーザー入力がファイルシステム呼び出しに渡されているすべての箇所を見つけ、このパターンを適用すれば、この種の攻撃が一切機能しなくなります。

