パストラバーサル耐性のあるファイルAPI

Traversal-resistant file APIs by Damien Neil

パストラバーサル脆弱性 は、攻撃者がプログラムを騙して、本来意図していたものとは別のファイルを 開かせてしまうことで発生します。この記事ではこの種の脆弱性について、そしてそれに対する既存の防御策を 説明し、Go 1.24で追加された新しい os.Root API が 意図しないパストラバーサルに対してどのようにシンプルかつ堅牢な防御を提供するかを解説します。

パストラバーサル攻撃

「パストラバーサル」は、共通のパターンに従ったいくつかの関連する攻撃を指す言葉です。 プログラムはある既知の場所にあるファイルを開こうとしますが、攻撃者はそのプログラムに 別の場所にあるファイルを開かせてしまいます。

攻撃者がファイル名の一部を制御できる場合、相対ディレクトリの構成要素(「..」)を使って 意図した場所から脱出できる可能性があります。

f, err := os.Open(filepath.Join(trustedLocation, "../../../../etc/passwd"))

Windowsシステムでは、特別な意味を持つ名前があります。

// fはコンソールに出力されます。
f, err := os.Create(filepath.Join(trustedLocation, "CONOUT$"))

攻撃者がローカルファイルシステムの一部を制御できる場合、シンボリックリンクを使って プログラムに間違ったファイルへアクセスさせられる可能性があります。

// 攻撃者が /home/user/.config を /home/otheruser/.config にリンクしている。
err := os.WriteFile("/home/user/.config/foo", config, 0o666)

プログラムが、意図したファイルにシンボリックリンクが含まれていないことを事前に検証することで シンボリックリンクによるトラバーサルを防いでいる場合でも、 TOCTOU(time-of-check/time-of-use)レース に対しては依然として脆弱である可能性があります。これは、プログラムがチェックを行った後に 攻撃者がシンボリックリンクを作成するというものです。

// 使用前にパスを検証する。
cleaned, err := filepath.EvalSymlinks(unsafePath)
if err != nil {
  return err
}
if !filepath.IsLocal(cleaned) {
  return errors.New("unsafe path")
}

// 攻撃者がパスの一部をシンボリックリンクに置き換える。
// Open呼び出しはそのシンボリックリンクをたどってしまう。
f, err := os.Open(cleaned)

もう一つの種類のTOCTOUレースは、トラバーサルの途中でパスの一部を構成するディレクトリを 移動させるというものです。たとえば、攻撃者が「a/b/c/../../etc/passwd」のようなパスを渡し、 open操作が進行中に「a/b/c」を「a/b」にリネームします。

パスのサニタイズ

一般的なパストラバーサル攻撃に取り組む前に、まずパスのサニタイズから始めましょう。 プログラムの脅威モデルにローカルファイルシステムへアクセスできる攻撃者が含まれない場合、 信頼できない入力パスを使用前に検証するだけで十分なことがあります。

残念ながら、パスのサニタイズは、特にUnixとWindowsの両方のパスを扱わなければならない 移植可能なプログラムにとっては、驚くほど厄介なものになりえます。たとえば、Windowsでは filepath.IsAbs(`\foo`) は false を返します。なぜなら、パス「\foo」は現在のドライブに対する 相対パスだからです。

Go 1.20では path/filepath.IsLocal 関数を追加しました。 これはパスが「ローカル」であるかどうかを報告します。「ローカル」なパスとは次のようなものです。

  • 評価されるディレクトリから脱出しない(「../etc/passwd」は許可されません)
  • 絶対パスではない(「/etc/passwd」は許可されません)
  • 空ではない(「」は許可されません)
  • Windowsでは、予約された名前ではない(「COM1」は許可されません)

Go 1.23では path/filepath.Localize 関数を追加しました。 これは「/」区切りのパスをローカルなオペレーティングシステムのパスに変換します。

攻撃者に制御されている可能性のあるパスを受け取って操作するプログラムは、ほぼ常に filepath.IsLocal または filepath.Localize を使ってそれらのパスを検証、あるいはサニタイズするべきです。

サニタイズだけでは不十分な場合

攻撃者がローカルファイルシステムの一部にアクセスできる可能性がある場合、パスのサニタイズだけでは 不十分です。

マルチユーザーシステムは近年では珍しくなりましたが、攻撃者がファイルシステムにアクセスできる状況は さまざまな形で依然として起こりえます。tarやzipファイルを展開する解凍ユーティリティが、 シンボリックリンクを展開させられ、その後そのリンクを経由するファイル名を展開させられることがあります。 コンテナランタイムが、信頼できないコードにローカルファイルシステムの一部へのアクセスを 与えてしまうこともあります。

プログラムは、検証前に信頼できない名前内のリンクを解決するために path/filepath.EvalSymlinks 関数を使うことで 意図しないシンボリックリンクのトラバーサルを防ごうとすることがありますが、 前述の通りこの2段階のプロセスはTOCTOUレースに対して脆弱です。

Go 1.24より前は、より安全な選択肢は github.com/google/safeopen のような、 特定のディレクトリ内で信頼できない可能性のあるファイル名を開くためのパストラバーサル耐性のある 関数を提供するパッケージを使うことでした。

os.Rootの紹介

Go 1.24では、ある場所にあるファイルをパストラバーサル耐性のある方法で安全に開くための新しいAPIを os パッケージに導入します。

新しい os.Root 型は、ローカルファイルシステム上のどこかにある ディレクトリを表します。rootは os.OpenRoot 関数で開きます。

root, err := os.OpenRoot("/some/root/directory")
if err != nil {
  return err
}
defer root.Close()

Root はroot内のファイルを操作するためのメソッドを提供します。 これらのメソッドはすべてrootからの相対パスとしてファイル名を受け取り、 相対パスの構成要素(「..」)やシンボリックリンクを使ってrootから脱出するような操作をすべて 禁止します。

f, err := root.Open("path/to/file")

Root は、rootから脱出しない相対パスの構成要素やシンボリックリンクは許可します。 たとえば root.Open("a/../b") は許可されます。ファイル名はローカルプラットフォームの セマンティクスに従って解決されます。Unixシステムでは、そのリンクがrootから脱出しない限り、 「a」内のシンボリックリンクをたどります。一方Windowsシステムでは、「a」が存在しない場合でも 「b」を開きます。

Root は現在、次の一連の操作を提供しています。

func (*Root) Create(string) (*File, error)
func (*Root) Lstat(string) (fs.FileInfo, error)
func (*Root) Mkdir(string, fs.FileMode) error
func (*Root) Open(string) (*File, error)
func (*Root) OpenFile(string, int, fs.FileMode) (*File, error)
func (*Root) OpenRoot(string) (*Root, error)
func (*Root) Remove(string) error
func (*Root) Stat(string) (fs.FileInfo, error)

Root 型に加えて、新しい os.OpenInRoot 関数は、 特定のディレクトリ内で信頼できない可能性のあるファイル名を開くためのシンプルな方法を提供します。

f, err := os.OpenInRoot("/some/root/directory", untrustedFilename)

Root 型は、信頼できないファイル名を扱うためのシンプルで安全、かつ移植可能なAPIを提供します。

注意点と考慮事項

Unix

Unixシステムでは、Root は openat 系のシステムコールを使って実装されています。 Root はそのrootディレクトリを参照するファイルディスクリプタを保持し、 リネームや削除をまたいでもそのディレクトリを追跡し続けます。

Root はシンボリックリンクによるトラバーサルは防ぎますが、マウントポイントの トラバーサルは制限しません。たとえば、Root はLinuxのbind mountのトラバーサルを 防ぎません。私たちの脅威モデルでは、Root はシンボリックリンクのように一般ユーザーが作成できる ファイルシステムの構造に対しては防御しますが、bind mountのように作成にroot権限が必要なものは 扱いません。

Windows

Windowsでは、Root はそのrootディレクトリを参照するハンドルを開きます。 開いているハンドルは、Root が閉じられるまでそのディレクトリがリネームまたは 削除されるのを防ぎます。

Root は NUL や COM1 のような予約されたWindowsデバイス名へのアクセスを防ぎます。

WASI

WASIでは、os パッケージはWASI preview 1のファイルシステムAPIを使用します。 これはパストラバーサル耐性のあるファイルシステムアクセスを提供することを意図したものです。 ただし、すべてのWASI実装がファイルシステムのサンドボックス化を完全にサポートしているわけではなく、 Root のトラバーサルに対する防御は、そのWASI実装が提供するものに限定されます。

GOOS=js

GOOS=jsの場合、os パッケージはNode.jsのファイルシステムAPIを使用します。 このAPIには openat 系の関数が含まれていないため、このプラットフォームでは os.Root はシンボリックリンクの検証においてTOCTOU(time-of-check-time-of-use)レースに 対して脆弱です。

GOOS=jsの場合、Root はファイルディスクリプタではなくディレクトリ名を参照し、 リネームをまたいでディレクトリを追跡することはありません。

Plan 9

Plan 9にはシンボリックリンクがありません。Plan 9では、Root はディレクトリ名を参照し、 ファイル名の字句的なサニタイズを行います。

パフォーマンス

多くのディレクトリの構成要素を含むファイル名に対する Root の操作は、それに相当する 非Root操作よりもずっとコストが高くなることがあります。「..」の構成要素の解決も コストが高くなる可能性があります。ファイルシステム操作のコストを制限したいプログラムは、 入力されたファイル名から「..」の構成要素を取り除くために filepath.Clean を使ったり、 ディレクトリの構成要素の数を制限したりするとよいでしょう。

os.Rootを使うべきなのは誰か

あるディレクトリ内のファイルを開こうとしており、なおかつその操作がそのディレクトリの外にある ファイルにアクセスするべきではない場合には、os.Root または os.OpenInRoot を使うべきです。

たとえば、出力ディレクトリにファイルを書き込むアーカイブ展開ツールは os.Root を 使うべきです。なぜなら、そのファイル名は信頼できない可能性があり、出力ディレクトリの外に ファイルを書き込んでしまうのは正しくないからです。

一方で、ユーザーが指定した場所に出力を書き込むコマンドラインプログラムは os.Root を 使うべきではありません。なぜなら、そのファイル名は信頼できないものではなく、 ファイルシステム上のどこでも指し示せてよいものだからです。

良い経験則として、固定のディレクトリと外部から与えられたファイル名を組み合わせるために filepath.Join を呼び出しているコードは、代わりに os.Root を使うべきでしょう。

// これは baseDirectory の外にあるファイルを開いてしまう可能性があります。
f, err := os.Open(filepath.Join(baseDirectory, filename))

// これは baseDirectory 配下にあるファイルのみを開きます。
f, err := os.OpenInRoot(baseDirectory, filename)

今後の作業

os.Root APIはGo 1.24で新しく追加されたものです。今後のリリースでこれに対する追加や 改善を行っていく予定です。

現在の実装は、パフォーマンスよりも正確性と安全性を優先しています。将来のバージョンでは、 可能な場合にはパフォーマンスを改善するために、Linuxの openat2 のようなプラットフォーム固有の APIを活用する予定です。

Root がまだサポートしていないファイルシステム操作がいくつかあります。シンボリックリンクの 作成やファイルのリネームなどです。可能な場合には、これらの操作へのサポートを追加していきます。 現在進行中の追加関数の一覧は go.dev/issue/67002 にあります。

By Damien Neil