The Go Blog
トラバーサル耐性ファイルAPI
パス・トラバーサル脆弱性は、攻撃者がプログラムをだまして意図したものとは異なるファイルを開かせようとするときに発生します。この投稿では、この種の脆弱性、それに対する既存の防御策、およびGo 1.24で追加された新しいos.Root APIが意図しないパス・トラバーサルに対するシンプルで堅牢な防御策をどのように提供するかについて説明します。
パス・トラバーサル攻撃
「パス・トラバーサル」は、共通のパターンに従う多くの関連する攻撃を網羅します。プログラムは既知の場所にあるファイルを開こうとしますが、攻撃者が別の場所にあるファイルを開かせます。
攻撃者がファイル名の一部を制御している場合、相対ディレクトリ・コンポーネント(「..」)を使用して意図した場所から抜け出すことができます。
f, err := os.Open(filepath.Join(trustedLocation, "../../../../etc/passwd"))
Windowsシステムでは、一部の名前には特別な意味があります。
// f will print to the console.
f, err := os.Create(filepath.Join(trustedLocation, "CONOUT$"))
攻撃者がローカル・ファイルシステムの一部を制御している場合、シンボリック・リンクを使用してプログラムに誤ったファイルにアクセスさせることができます。
// Attacker links /home/user/.config to /home/otheruser/.config:
err := os.WriteFile("/home/user/.config/foo", config, 0o666)
プログラムが意図したファイルにシンボリック・リンクが含まれていないことを最初に検証することでシンボリック・リンク・トラバーサルから防御する場合でも、Time-of-Check/Time-of-Use (TOCTOU) レースに対して脆弱である可能性があります。この場合、攻撃者はプログラムのチェック後にシンボリック・リンクを作成します。
// Validate the path before use.
cleaned, err := filepath.EvalSymlinks(unsafePath)
if err != nil {
return err
}
if !filepath.IsLocal(cleaned) {
return errors.New("unsafe path")
}
// Attacker replaces part of the path with a symlink.
// The Open call follows the symlink:
f, err := os.Open(cleaned)
別の種類のTOCTOUレースでは、トラバーサル中にパスの一部を形成するディレクトリを移動させます。たとえば、攻撃者が「a/b/c/../../etc/passwd」のようなパスを提供し、オープン操作の進行中に「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では、osパッケージに新しいAPIを導入し、トラバーサル耐性のある方法で安全にファイルを開くことができるようにしました。
新しいos.Rootタイプは、ローカル・ファイルシステム内のどこかのディレクトリを表します。os.OpenRoot関数でルートを開きます。
root, err := os.OpenRoot("/some/root/directory")
if err != nil {
return err
}
defer root.Close()
`Root` は、ルート内のファイルを操作するためのメソッドを提供します。これらのメソッドはすべてルートに対する相対ファイル名を受け入れ、相対パス・コンポーネント(「..」)またはシンボリック・リンクを使用してルートから脱出する操作を許可しません。
f, err := root.Open("path/to/file")
`Root` は、ルートから抜け出さない相対パス・コンポーネントとシンボリック・リンクを許可します。たとえば、`root.Open("a/../b")` は許可されます。ファイル名はローカル・プラットフォームのセマンティクスを使用して解決されます。Unixシステムでは、これは「a」内のシンボリック・リンクをたどります(そのリンクがルートから抜け出さない限り)。Windowsシステムでは、これは「b」を開きます(「a」が存在しなくても)。
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` はLinuxのバインド・マウントのトラバーサルを防ぎません。私たちの脅威モデルは、`Root` が通常のユーザーが作成できるファイルシステム構造(シンボリック・リンクなど)から防御しますが、作成にルート権限を必要とするもの(バインド・マウントなど)は処理しないというものです。
Windows
Windowsでは、`Root` はルート・ディレクトリを参照するハンドルを開きます。オープンされたハンドルは、`Root` が閉じられるまで、そのディレクトリの名前変更や削除を防止します。
Root は、NUL や COM1 などの予約済みWindowsデバイス名へのアクセスを防ぎます。
WASI
WASIでは、`os` パッケージはWASIプレビュー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` を使用すべきです。
// This might open a file not located in baseDirectory.
f, err := os.Open(filepath.Join(baseDirectory, filename))
// This will only open files under baseDirectory.
f, err := os.OpenInRoot(baseDirectory, filename)
今後の作業
os.Root APIはGo 1.24で新しく追加されました。今後のリリースで、これに追加や改良が加えられる予定です。
現在の実装は、パフォーマンスよりも正確性と安全性を優先しています。将来のバージョンでは、Linuxのopenat2などのプラットフォーム固有のAPIを活用して、可能な限りパフォーマンスを向上させる予定です。
Root がまだサポートしていないファイルシステム操作がいくつかあります。例えば、シンボリック・リンクの作成やファイル名の変更などです。可能な限り、これらの操作のサポートを追加する予定です。進行中の追加関数のリストは、go.dev/issue/67002にあります。