パストラバーサル(ディレクトリトラバーサル)は、攻撃者ではなくサーバー自身に、許していないファイルを読ませる・書かせる欠陥です。「ダウンロードするファイルを選んでください」「このテンプレートを読み込みます」。こうした処理の裏では、サーバーが受け取った名前をパスに組み立てて開いています。この組み立てが無防備だと、受け取った値で基底ディレクトリの外へ連れ出され、設定ファイルや秘密鍵まで読まれます。

本記事は連載「事故から逆算するWebセキュリティ」の第24回です。ファイル名やパスを受け取る処理を書く開発者に向けて、何が危険で、どう防ぐかを手元で確かめられる形で整理します。セキュリティ全体の地図は「サプライチェーン攻撃とは|手口・防御ツール比較・npm 12の新機構まで実践解説」にまとめています。

STAY INSIDE THE BASE守りの置き場所

../ を消すのではなく、正規化してから基底の中か確かめる

入力から ../ を削る

ブラックリスト除去

  • 多重やエンコードで元に戻る
  • 絶対パスは ../ を含まない
  • symlink は字句では見えない

正規化+基底ディレクトリ確認

実パスに解決してから境界で判定

  • 相対も絶対も1つの実パスに畳む
  • 基底で始まるかを境界つきで判定
  • 識別子マッピングなら経路ごと断つ
考え方: OWASP Path Traversal / Input Validation Cheat Sheet・CWE-22

30秒でわかる

  • ・何が起きるか:ファイル名やパスを受け取る処理で、許していない基底ディレクトリの外のファイルを読まれる・書かれる(CWE-22)
  • ・どこに潜むか:ダウンロード・プレビュー・テンプレート読み込み・zip展開(Zip Slip)・アップロードの保存先
  • ・なぜ除去では不十分か:../ の文字列除去は、多重・エンコード・絶対パス・symlink ですり抜ける
  • ・効く設計:基底に連結して実パスに解決(正規化)してから、その実パスが基底の中かを境界つきで確かめる/もっと固いのは識別子をサーバー側のマップで引く

同じ「サーバーに外を触らせる」でも、URLを渡してネットワークへ取りに行かせる攻撃はSSRF(サーバーに内側を叩かせる攻撃)で、こちらは別物です。パストラバーサルはファイルシステム上のパスを、SSRFはネットワーク上の宛先を、それぞれ意図しない先へ向けさせます。

パストラバーサルはどこで起きるか

ファイル名やパスの一部をユーザーから受け取り、それをパスに組み立てて開く・書く・展開する処理は、すべて候補です。

・ダウンロード/プレビュー:クエリで渡されたファイル名を保存ディレクトリに連結して返す
・テンプレート・言語ファイルの読み込み:言語コードやテーマ名をそのままパスにする
・アーカイブの展開:zip/tar のエントリ名を展開先に連結して書き出す(後述の Zip Slip)
・アップロードの保存先:クライアントが申告したファイル名を保存パスに使う
・ログ・レポートの出力先:出力名をユーザーに決めさせる

読み取りだけでなく、書き込み側も危険です。展開や保存で基底の外へ書ければ、設定ファイルや起動スクリプトを上書きされる余地が生まれます。実際に起きた例では、Excel を扱う MCP サーバーで保存先のパスが検証されず基底の外へ到達できた事例(excel-mcp-server のパストラバーサル)が公表済みです。影響範囲は「どのファイルを読めるか/書けるか」で決まり、秘密情報の露出から任意ファイルの上書きまで幅があります。

なぜ../の除去ではパストラバーサルを防げないのか

いちばんありがちな守りが、入力から ../ を文字列で削るブラックリスト方式です。これは次の理由で破られます。

MYTH VS REALITYよくある誤解

こう守ったつもり

実際には

こう守ったつもり: ../ を文字列で削った

実際には: 多重やエンコードで戻り、削り残る

こう守ったつもり: 先頭を固定したから安全

実際には: 絶対パスなら基底の外を直接指せる

こう守ったつもり: 拡張子で許可した

実際には: 基底の外の同じ拡張子を読める

こう守ったつもり: 字句の正規化で足りる

実際には: symlink は実パスを解決しないと分からない

考え方: OWASP Path Traversal・CWE-22

・多重・残留:重ねた区切りを一度削るだけでは、削った結果からまた区切りが現れて残る
・エンコード:URLエンコードなどで表した区切りは、デコードや正規化の順番しだいで除去の後に復活する
・絶対パス:絶対パスは ../ を一つも含まないのに、基底を無視して任意の場所を指す
・symlink:文字列としては基底の中でも、途中が基底の外へ張られたシンボリックリンクなら到達先は外

多重デコードの取りこぼしは実際の脆弱性にもなりました。Open WebUI では、デコードを繰り返す前提のすり抜けが成立し、多重デコード対策の穴として修正されています(Open WebUI の多重デコードのすり抜け)。記号を消す発想は、消し方の順番と表現の種類を攻撃側に合わせ続けなければならず、守り手が不利です。

攻撃者が送れる入力と、守りが無いときに起こることを整理すると、問題は「記号」ではなく「最終的にどの実ファイルを指すか」だと分かります。

攻撃者が送れる入力 守りが無いと起こること
相対パスの断片 基底の外のファイルを読む
絶対パスそのもの 基底を無視して任意の場所を指す
エンコードした区切り ブラックリスト除去の後で復活する
展開前アーカイブ内のエントリ名 展開時に基底の外へ書き込む(Zip Slip)
公開ディレクトリ内の symlink リンク先の実ファイルへ到達する

正規化してから基底ディレクトリ内か確かめる

効くのは、記号を消すことではなく、最終的な実パスを一つに確定してから、それが許した基底の中かを確かめることです。OWASP の Path Traversal / Input Validation Cheat Sheet も、ユーザー入力をそのままパスに使わず、正規化(canonicalization)した上で許可された基底に対して検証することを勧めています。順序はこうです。

RESOLVE THEN CHECK安全な読み込みの順序

受け取った値は実パスに解決してから基底内か判定する

  1. 入力を受ける

    識別子かパスの一部

  2. 基底に連結

    base と入力をつなぐ

  3. 正規化で解決

    realpath / resolve / getCanonicalPath

  4. 基底内か判定

    commonpath・is_relative_to・base+sep で始まるか

  5. 許可か拒否

    外を指すなら開かずエラー

出典: 各言語公式ドキュメント(os.path.realpath・File.getCanonicalPath・fs.realpath・os.Root)

解決は実パスまで行う必要があります。字句だけの正規化(文字列として .. を畳むだけ)では symlink をたどれません。ファイルシステムに問い合わせて symlink を解決する関数を使い、その結果が基底で始まるかを区切り文字つきで判定します(base と basement を取り違えないため、基底そのものか、基底+区切り文字で始まる場合だけを許します)。

守り方ごとに、何を防ぎ、何を防がないかを並べます。

守り方 何を防ぐか 何を防がないか
ブラックリスト除去(../ を削る) 単純な相対指定の一部 多重・エンコード・絶対パス・symlink
正規化+基底ディレクトリ確認 相対も絶対も、解決後は symlink も 基底自体の誤設定・解決と利用の間の差し替え(TOCTOU)
識別子→サーバー側マップ 経路そのもの(ファイル名を受けない) マップの管理漏れ・認可(見てよい人か)の確認は別途

OWASP ASVS のファイル処理の章(v4.0.3 では V12.3)も、ユーザーが申告したファイル名のメタデータをそのままファイルシステムに渡さないこと、ファイルパスのインジェクションを防ぐことを要件に挙げています。いちばん固いのは、そもそもファイル名を受けず、識別子を受けてサーバー側のマップで実ファイルに引く設計です。パスを組み立てる経路が無ければ、正規化や判定の書き漏らしがあっても基底の外へは出られません。

言語ごとの正しい書き方の要点

どの言語も「実パスに解決する関数」と「基底内かを判定する手段」が分かれています。公式ドキュメントの関数名で対応づけると次のとおりです。

言語 実パスに解決(symlinkまで) 基底内かの判定 標準で基底を強制
Python os.path.realpath() / Path.resolve() os.path.commonpath() / Path.is_relative_to()(解決後に・文字列判定) —
Java File.getCanonicalPath() 解決後に基底で startsWith —
Node.js fs.realpathSync()(path.resolve() は字句のみ) 解決後に base + path.sep で始まるか —
PHP realpath()(存在しないと false) 解決後に基底で str_starts_with —
Go (filepath.Clean は字句のみ) 自前で境界判定 os.Root / os.OpenRoot(1.24以降)

注意点が二つあります。一つ目は、Python の Path.is_relative_to() は公式ドキュメントのとおり文字列ベースで、.. を特別扱いせずファイルシステムも見ません。だから必ず resolve() で実パスに解決してから使います。二つ目は、Node の path.resolve() は字句の正規化だけで symlink をたどらないことです。symlink まで畳むなら fs.realpath を通します。

Go 1.24 以降なら、os.Root(os.OpenRoot で開く)が標準で助けになります。公式ドキュメントは「Root のメソッドは基底ディレクトリの下のファイルとディレクトリにしかアクセスできない。名前のどの要素でも基底の外を指せばエラーを返す。symlink はたどるが、基底の外を指す symlink は許さない」と定めており、境界判定を自分で書く代わりにライブラリに強制させられます。

自分のコードを確認する

ここからは読み取りだけの確認コマンドです。いずれも判定ではなく構成の確認で、ヒットの有無だけでは安全とは言えません。該当箇所を目で追う起点として使います。. は自分のリポジトリのルートに読み替えてください。

まず、ユーザー入力がファイルを開く・読む関数へ渡っていそうな箇所を洗い出します。

grep -rnE "(readFile|readFileSync|open|sendFile|include)\(" . --include="*.js" --include="*.ts" --include="*.py" --include="*.php" --include="*.go" --include="*.java"

次に、正規化と基底判定を使っているかを確かめます。ヒットが0に近いのに前のコマンドでファイルAPIの呼び出しが多い箇所は、正規化していない疑いがあります。

grep -rnE "realpath|\.resolve\(|getCanonicalPath|commonpath|Paths\.get" . --include="*.js" --include="*.py" --include="*.php" --include="*.go" --include="*.java"

最後に、公開ディレクトリにシンボリックリンクが紛れていないかを見ます。基底の外へ張られた symlink は、文字列の判定をすり抜けます。

find . -type l -not -path "*/node_modules/*"

いずれも、小さなダミーのプロジェクトで陽性(危ない連結・symlink を置いた側)と陰性(正規化済み・symlink の無い側)の両方を走らせ、拾うべきものを拾い、無いときに何も出ないことを確かめてから載せています。ヒットした箇所が本当に危険かは、その値が基底に連結されて開かれているか、解決と基底判定が後ろにあるかを、コードを読んで確かめます。

なお、アーカイブを展開する処理には別の確認が要ります。zip/tar のエントリ名に区切りを含めて展開先の外へ書き出させるZip Slipは、展開前に各エントリの最終的な書き込み先を実パスに解決し、展開先ディレクトリの中かを一つずつ確かめることで塞ぎます。アップロードの保存でも同じで、クライアントが申告した名前を保存パスに使わず、保存名はサーバーが決めるのが原則です。Windows ではパス区切りが / と \\ の二種類あり、CON などの予約名もあるため、区切りの正規化と予約名の扱いを前提に置きます。

参照ソース