In this article
安全なパス処理:安全なファイルシステム操作が想像以上に難しい理由
パストラバーサルの脆弱性は後を絶ちません。多くの言語が提供する標準ライブラリのファイルI/O関数は、敵対的な環境ではなく、利便性を重視して設計されています。コードがサンドボックスや共有システム内で実行されたり、信頼できない入力を扱ったりする場合、この隔たりがセキュリティ境界の侵害につながります。
Snykのセキュリティリサーチチームは、さまざまなエコシステムでこのパターンを繰り返し確認しています。
Incusで見つかった深刻度の高い脆弱性では、改行インジェクションとシンボリックリンク攻撃を組み合わせ、任意のファイル書き込みとホストレベルでのroot権限昇格を実現
NixOSでの権限昇格チェーンでは、Nixストアの競合状態を悪用し、不変であるべきパスへの書き込みアクセスを維持
Ubuntu 24.04での権限昇格では、ファイルシステムの操作と特権コンポーネントのバグを連鎖させ、デフォルトユーザーからrootへ昇格
これらは珍しい攻撃手法ではありません。ある時点でパスを確認し、別の時点でそのパスを使うコードが原因です。その間に攻撃者が安全な対象を悪意のある対象へ差し替えられる隙が生まれます。
3つの脆弱性クラス
パストラバーサル
古典的な脆弱性です。../../etc/passwdのようなユーザー入力によって、意図したディレクトリの外へ抜け出します。多くの開発者が注意すべきことを知っていても、パスの検証は見た目以上に難しく、今も頻繁に発生しています。単純な文字列チェックでは、URLエンコード、ヌルバイト、Unicode正規化、プラットフォーム固有のパス区切り文字に対応できません。
# Looks safe, isn't safe
def read_user_file(filename):
path = os.path.join("/app/uploads", filename)
if not path.startswith("/app/uploads"):
raise ValueError("Access denied")
return open(path).read()
# filename = "../../etc/passwd"
# path = "/app/uploads/../../etc/passwd"
# os.path.join resolves this, but startswith still passes on some inputsシンボリックリンク攻撃
攻撃者は、コードがアクセスする想定の場所から、本来アクセスすべきでない場所を指すシンボリックリンクを作成します。ほとんどのシステムではシンボリックリンクをたどる動作がデフォルトのため、コードは攻撃者が選んだ対象から読み込んだり、そこへ書き込んだりしてしまいます。
共有環境、一時ディレクトリ、コンテナやサンドボックスの境界など、攻撃者がファイルシステムへの限定的な書き込み権限を持つ可能性のある場所では、特に危険です。
TOCTOU競合状態
Time-of-Check to Time-of-Use(確認から使用までの時間)。コードがパスを検証してから操作するまでの間に、攻撃者が対象を差し替えます。検証には通っても、操作の対象は別のファイルになっています。
# Classic TOCTOU vulnerability
if os.path.isfile(path) and not os.path.islink(path):
# Attacker swaps path for a symlink right here
with open(path, 'w') as f:
f.write(data) # Now writing to attacker's target根本的な問題は、マルチユーザーまたはマルチプロセスのシステムでは、パスを使った操作に本質的な競合リスクがあることです。同じパスを参照する2つのシステムコールの間で、ファイルシステムの状態が変わる可能性があります。
解決策:ファイルディスクリプタを使った操作
適切な方法では、openat()、O_NOFOLLOW、およびf*系のシステムコール(fstat、fchmod、fchown)を組み合わせます。重要なのは、ファイルディスクリプタを一度開けば、パスに何が起きても特定のファイルシステムオブジェクトを参照し続けるという点です。
手順は次のとおりです。
安全性が確認されたディレクトリ(
/やサンドボックスのルートなど)から始める前のディレクトリのファイルディスクリプタを使い、
openat()で各パス要素を個別に開く各段階で
O_NOFOLLOWを使い、シンボリックリンクを拒否する各要素を検証する(
..がないこと、シンボリックリンクでないこと、適切な権限があること)以降のすべての操作に、最終的なファイルディスクリプタを使う
ファイルを開いた後にパスを再び使わないため、TOCTOUを防止できます。すべての操作はファイルディスクリプタを介して行われ、パスが変わっても同じinodeを参照することがカーネルによって保証されます。
各言語での対応状況
C/C++:完全な制御が可能
CとC++ではopenat()とO_NOFOLLOWを直接利用できるため、冗長ではあるものの、正しい実装を簡単に行えます。GoogleによるChromeOS向けの実装(libbrillo/brillo/files/safe_fd.cc)は、参考になる実装例です。
int safe_open(int dirfd, const char *component) {
return openat(dirfd, component,
O_RDONLY | O_NOFOLLOW | O_CLOEXEC);
}Go:標準ライブラリでサポート(Go 1.24以降)
Goでは標準ライブラリにos.Rootが追加され、ディレクトリツリー内のファイルへ安全かつ競合なしにアクセスできます。言語レベルのサポートとして、理想的な実装です。
// Go 1.24+ — safe by default
root, err := os.OpenRoot("/sandbox")
if err != nil {
log.Fatal(err)
}
defer root.Close()
f, err := root.Open("user/data.txt")
// Guaranteed to stay within /sandbox, TOCTOU-proof設計の背景や検討されたエッジケースについては、os.Rootの設計に関するGoのブログ記事が参考になります。
Rust:優れたプリミティブ、組み立ては手動
Rustではopenatクレートを介してnixを利用でき、O_NOFOLLOWもサポートされています。cap-stdクレートは、設計上パストラバーサルを防ぐ、ケイパビリティベースのファイルシステムアクセスを提供します。
use cap_std::fs::Dir;
let root = Dir::open_ambient_dir("/sandbox", cap_std::ambient_authority())?;
let file = root.open("user/data.txt")?;
// Constrained to /sandboxPython:部分的なサポート
Python 3.11以降では、O_NOFOLLOWとdir_fdパラメーターを指定できるos.open()が使えます。ただし、安全なトラバーサルを完全に実装するには手作業が必要です。Goのos.Rootに相当する標準ライブラリ機能はありません。
import os
def safe_open_under(root_path, relative_path):
root_fd = os.open(root_path, os.O_RDONLY | os.O_DIRECTORY)
try:
components = relative_path.split('/')
current_fd = root_fd
for component in components[:-1]:
if component in ('', '.', '..'):
raise ValueError(f"Invalid path component: {component}")
next_fd = os.open(
component,
os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW,
dir_fd=current_fd
)
if current_fd != root_fd:
os.close(current_fd)
current_fd = next_fd
return os.open(
components[-1],
os.O_RDONLY | os.O_NOFOLLOW,
dir_fd=current_fd
)
finally:
os.close(root_fd)Node.js:実質的に不可能?
ここから難しくなります。Node.jsの標準ライブラリではopenat()もO_NOFOLLOWも公開されていません。fsモジュールはファイルディスクリプタではなく、パスだけを使って操作します。ネイティブアドオンを使わずに、純粋なNode.jsでTOCTOUを防止したディレクトリトラバーサルを実現する方法はありません。
できることはfs.realpath()の後にプレフィックスを確認する程度ですが、これは本質的に競合の危険があります。N-APIを使ったネイティブアドオンでopenat()を公開できますが、コンパイルが必要になり、プラットフォーム固有の複雑さも加わります。
CLIツール、ビルドシステム、コンテナ環境で広く使われるランタイムであることを考えると、パスの安全性が重要な場面でこれは大きな不足です。
どのような場合に重要か?
すべてのアプリケーションでTOCTOUを防ぐファイル操作が必要なわけではありません。ハードコードされたパスからのみファイルを開くコードや、信頼できない入力がなく、単一ユーザー環境でのみ実行されるコードであれば、標準ライブラリの関数で問題ありません。
次のような場合は、安全なパス処理が必要です。
コードがサンドボックスまたはコンテナ内で動作し、ファイルシステム操作が信頼境界を越える
コードがユーザー提供のファイルパスを処理する(アップロード、設定ファイル、プラグインディレクトリなど)
コードが昇格した権限で動作し、権限のないユーザーの影響を受けるパスにアクセスする
コードが共有環境(マルチテナントシステム、CI/CDパイプライン、ビルドシステムなど)で動作する
コードが一時ファイルを扱い、
/tmpなど、すべてのユーザーが書き込めるディレクトリを使う
推奨する対策
ユーザー入力を受け取る、パスベースのファイル操作がないかコードベースを監査する
可能な限り、
realpath()の後にstartswith()で確認するようなパスベースのチェックを、ファイルディスクリプタを使ったトラバーサルに置き換えるGo 1.24以降では、サンドボックス内のファイルアクセスに
os.Rootを使うRustでは、ケイパビリティベースのファイルシステムアクセスに
cap-stdクレートを検討するNode.jsでは制約を認識したうえで、多層防御を実装する(入力検証、chroot、AppArmor/SELinuxプロファイルなど)
開発時にコードベース内のパストラバーサルのパターンを検出するには、Snyk Codeを活用しましょう。
パストラバーサルのような見えにくいファイル処理の欠陥が、気づかないうちにアプリケーションをより深刻な侵害の危険にさらしていませんか?Python環境におけるAIセキュリティ危機のホワイトペーパーをダウンロードして、AIが生成したコードと開発者が書いたコードの両方で、現代のチームがこうしたリスクをどう防いでいるかをご覧ください。