
2026/04/24 11:01
ファイルを開くのはどれくらい難しいでしょうか?
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
最も重要なセキュリティ上の変化は、ファイルパス文字列を渡し続けるのではなく、ファイルディスクリプタ(fd)を用いることで TOCTOU など危険なランダム競争状態攻撃を防ぐことにあります。「fd を常に持つ」という規律により、セキュリティチェックと実際のファイル使用の間で下の файловシステムが変化してもアクセスは安全に保たれる一方、パスは攻撃者が置いたシンボリックリンクや不安全な bind マウントを経由して悪用される可能性があります。
libglnx などのライブラリでは、より安全な代替案を提供しており、特に新たな glnx_chaseat ユーティリティによって安全なパスの移動を可能にしていますが、多くの標準 POSIX API とツールの依然として従来の不安全なパスベースのアブストラクションを採用しており、セキュリティ境界をまたいで安全に使用するには広範な監査が必要です。Flatpak における最近の脆弱性が、ポータルから信頼できる環境へ不安全なパスを渡すことで深刻なサンドボックス突破(CVE-2026-34078)を引き起こしたことを示しました。この問題の解決には、パス文字列を特定のファイルディスクリプタオプション(--app-fd、--bind-fd など)に置き換え、コールチェーン全体を監査する必要がありました。今後の進捗としては、O_PATH デスクリプターに対して不透明なハンドルを採用することが含まれますが、15 年前のサブサンドボクシング設計から現代的でカーネルネイティブの実装への移行により、Steam、WebKit、Chromium などのソフトウェアが動作しなくなるという初期の回退が発生しました。業界は今後、これらの現代的手法へ移行することによって、システム全体の不安定性をさらに引き起こす前に重大な欠陥を排除する必要があるのです。本文
過去数ヶ月間、私はこの問いを自身で何回も自問する必要がありました。文脈によって答えは以下のどちらかになります:
- とてもシンプルに、標準ライブラリの関数を呼び出すだけである場合
- 極めて難しく、何も信用してはならない場合です。
アプリケーション開発者であれば幸運なほうに当たります。ほぼすべてのケースで最初の答えが適用可能です。しかし、ファイルを何らかの形で扱いつつセキュリティ境界線(separation boundary)を設けるようなものを開発する場合は、正解はおそらく第二段の場合である可能性が高いのです。
ファイルを開く「困難な方法」
多くの場合と同様に、詳細はその具体条件に依存しますが、最悪のケースでは、セキュリティ境界線の両側にプロセスが存在し、それらが共有するファイルシステムツリー上で動作しているという状況が考えられます。
より権限の多いプロセスが、より権限が少ないプロセスの代わりとしてファイル上の操作を代行한다고 しましょう。この制限を特定のディレクトリ内のファイルに限ることが望ましい場合があり、例えば SSH キーを盗むことなどを防ぐためです。その際に、あるディレクトリに対して相対的なサブパスを使用することになります。
まずの問題
まず真っ先に顕著な問題は、サブパスが
.. を含むことで、指定されたディレクトリ外のファイルを参照してしまうことです。もし権限の多いプロセスが ../.ssh/id_ed25519 というサブパスを受け取ると、大変なことになります。簡単な対策は、パスを正規化(normalization)し、ターゲットディレクトリ外に出ようとすればエラーにすることです。
次の課題
次の問題は、パスのすべてのコンポーネントがシンボリックリンク(symlink)である可能性があることです。権限の多いプロセスが
link というサブパスを受け取り、かつ link が ../.ssh/id_ed25519 へのシンボリックリンクである場合も危険です。より権限が少ないプロセスがそのツリーの一部にファイルを作成できない場合、マルウェア用のシンボリックリンクを作成できず、問題はありません。しかし、それ以外のすべてのシナリオでは、すべてが「正常」とは言えません。簡単な対策としては、シンボリックリンクを解決し、パスを展開してから正規化することです。
ここまでは多くの人が「これで完了だ」と考えてしまいます。「ファイルを開くなどそんなに難しいことではない、あとはもっと楽しいことを考えよう」という感覚が生まれます。しかし、本当に問題はこれから始まります。
上記の対策は、より権限が少ないプロセスがファイルのパス上のどこかでファイルシステムツリーを変更できない限り有効です。より権限の高いプロセスがそのファイルをアクセスしようとする間に、より権限の低いプロセスが何らかの変更を加えない場合が一般的です。例えば、攻撃者から提供されたアーカイブを、攻撃者がアクセスできないディレクトリに展開する場合などは該当します。しかし、変更を加えてしまえる場合は、古典的な TOCTOU(Time-of-Check to Time-of-Use)競合条件問題が発生します。
パス
foo/id_ed25519 とし、シンボリックリンクを解決し、パスを展開してから正規化したとしましょう。その間、別のプロセスがまさにチェックした直後に確認していたディレクトリ foo(通常ディレクトリ)を、../.ssh へのシンボリックリンクに置き換えてしまった場合が典型的です。私たちはそのパスが目標ディレクトリ内に解決されると確認しただけであり、幸福に foo/id_ed25519 を開いてしまったことになります。これはさぞ簡単そうでない修正が必要になります。
では、ここで根本的な問題は何でしょうか?
/home/user/.local/share/flatpak/app/org.example.App/deploy のようなパス文字列は、ファイルシステム名前空間内の場所を記述しているに過ぎません。ファイルへの参照そのものではありません。「そのパスを口に出す」行為が終わる頃には、その名前の指し示す対象が変更されている可能性があります。
安全な基本単位(primitive)はファイルディスクリプタです。あるインノード(inode)を指し示す fd が用意された後、カーネルはそのインノードをピン留め(pin)します。ディレクトリはアンリンクされても、名前変更されても、シンボリックリンクで置き換えられても、fd は気にしません。一般的な誤解の 하나는「ファイルディスクリプタとはオープンされているファイルを意味する」という考えです。確かにそうなることはありますが、
O_PATH で開かれた fd には実際のファイルをオープンする必要はなく、それでも安定したインノードへの参照を提供します。
ここで学ぶべき教訓は、パス文字列を権限を持つプロセスに渡してはならないということです。それは「絶対的」です。ファイルディスクリプタを渡すことにも、呼び出し側プロセスが実際にはそのリソースにアクセス権を持っているという証明としての利点があります。
また、重要な教訓として、ファイルディスクリプタからパスに降下させることは、再び競合条件の問題を生じさせます。例えば、ファイルを指定する fd からバインドマウント(bind mount)を実行したいが、従来のマウント API しか利用できないため、fd をパスに変換してマウント関数に渡した場合を考えましょう。残念ながら、攻撃者が配置しえた可能性のあるパス内のシンボリックリンクをカーネルは解決します。場合によっては、インストール後の事象を検出することも可能ですが、例えばマウントされたファイルと fd のインノードおよびデバイスの一致を確認するなどです。
その話を一旦脇に置きながら、パスを使用せざるを得ない場合もあるため、それについても見ていきましょう。
上記のシナリオでは、すべてのパスが解決するディレクトリがあり、かつ攻撃者が制御できないディレクトリがあります。そのため、攻撃者による再方向化を許さない
O_PATH フラグ付きで開いてから fd を取得できます。
さらに、
openat システムコールを用いれば、直前に開いた fd に対する相対パスとしてファイルを開くことができます。上記と同じ課題を抱えますが、同時に O_NOFOLLOW フラグも渡せるという点で異なります。このフラグをセットした場合、パスの最右端のコンポーネントがシンボリックリンクである場合でも、それを追跡せず、実際のシンボリックリンクそのもののインノードに対応した fd を返します。他のコンポーネントは依然としてシンボリックリンクであり、それらは引き続き追跡されます。しかし、パスを分割して、次のパスセグメント用の次(fd)を開き、すべてのコンポーネントでシンボリックリンクの解決を手動で行うことができます。
libglnx の chase
libglnx は GNOME C プロジェクト向けのユーティリティライブラリで、主に fd ベースのファイルシステム操作を提供します。glnx_openat_rdonly、glnx_file_replace_contents_at、glnx_tmpfile_link_at などの関数はすべてディレクトリ fd を受け取り、それに対する相対パスでの操作を行います。このライブラリは、「常に fd を保有し、可能な限り絶対パスを避ける」という規律を中心に構築されています。
最新に追加された
glnx_chaseat は、安全なパストラバーサルを提供する関数で、systemd の chase() に着想を得ており、まさに上記の通り動作します。
int glnx_chaseat (int dirfd, const char *path, GlnxChaseFlags flags, GError **error);
これは解決済みのパスに対応した
O_PATH | O_CLOEXEC フラグ付きの fd を返すか、エラーの場合は -1 を返します。真の魔法はフラグにあります:
typedef enum _GlnxChaseFlags { /* デフォルト */ GLNX_CHASE_DEFAULT = 0, /* Automount のトリガーを無効にする */ GLNX_CHASE_NO_AUTOMOUNT = 1 << 1, /* パスの右端成分を追跡しない。パスの右端成分がシンボリックリンクである場合、 * そのシンボリックリンク自体の O_PATH fd を返す。*/ GLNX_CHASE_NOFOLLOW = 1 << 2, /* 解決プロセスのいずれかのコンポーネントが dirfd で示されたディレクトリの descendant ではない場合、 * パスの解決を成功させない。*/ GLNX_CHASE_RESOLVE_BENEATH = 1 << 3, /* シンボリックリンクは与えられた dirfd を基準として根付き (root) ではなく解決される。*/ GLNX_CHASE_RESOLVE_IN_ROOT = 1 << 4, /* どのシンボリックリンクも遭遇した場合、エラーにする。*/ GLNX_CHASE_RESOLVE_NO_SYMLINKS = 1 << 5, /* パスの右端成分が普通ファイル (regular file) ではない場合、エラーにする。*/ GLNX_CHASE_MUST_BE_REGULAR = 1 << 6, /* パスの右端成分がディレクトリでない場合、エラーにする。*/ GLNX_CHASE_MUST_BE_DIRECTORY = 1 << 7, /* パスの右端成分がソケットでない場合、エラーにする。*/ GLNX_CHASE_MUST_BE_SOCKET = 1 << 8, } GlnxChaseFlags;
実装自体はあまり複雑そうに見えませんが、多くの詳細が非常にこまかいです。実装では利用可能なシステムコールと要求される振る舞いに応じて
openat2、open_tree、openat を使い分けており、自動マウントの動作を処理し、既に見たパスが変更されていないことを保証するなど、いくつかの追加機能も実装しています。
標準ライブラりに対する一言
POSIX API はこの問題に対処する方面ではあまり優れているとはいえません。GLib/Gio API(GFile など)はさらに悪く、パスを受け取るだけです。確かに、それらは fd が普遍的な概念ではないクロスプラットフォーム抽象化の役割も果たします。不幸なことに、Rust でも完全にパスに基づくクロスプラットフォーム抽象化が存在します。
これらの API のいずれかを使用すれば、ほぼ確実に脆弱性を生み出していることになります。より根深い問題は、これらのパスベースの API がファイルとの相互作用における標準方法となりがちであることです。これにより、合成されたコードのセキュリティについて論理的に考えられなくなります。自らのコードを綿密に監査し、すべてのファイルを開く際に
O_PATH | O_NOFOLLOW を使用し、*at() 呼び出しを注意深くチェーンしても、第三者ライブラリが内部で open(path) を呼び出すことを許容すれば、コード内で確立したセキュリティ特性は、そのライブラリ呼び出しを通じて保持されなくなります。
つまり、ファイルシステムのセキュリティに関心のあるシステムレベルのコードは、すべての推移的依存関係(transitive dependencies)を監査するか、そもそもそれらを避ける必要があります。
では、より良い GLib クロスプラットフォーム API はどのようなものになるでしょうか?
chaseat() とあまり変わらないと考えるのが妥当でしょうが、不透明なハンドル(opaque handles)を返すだけで、Unix 上では O_PATH fd を持ち、表示・デバッグなどに使えるパスも含むものとなります。そこからファイルを開くと、読み書きなどのための別の種類の不透明なハンドルが得られます。
現在の
GFile も GVfs の実装のために設計されました:g_file_new_for_uri("smb://server/share/file") はローカルファイルのように g_file_read() が可能な GFile を返します。これは目指すべき方向性は正しいですが、抽象化の層が間違っています。むしろこの種のアクセスは FUSE によって提供され、URI を特定の FUSE マウント上のパスに変換するのが望ましいです。これによる利点は以下の通りです:
- fd chasing アプローチは、カーネル管理の実在のファイルシステムだからどこでも機能する
- ファイルシステムは GLib に依存しなくなり、Rust などから使用可能になる
- Flatpak で使われる XDG デスクトップドキュメントポータルなどの他の FUSE ファイルシステムとスタックできる
それなら、なぜこれを話すのか?
現在私は小型プロジェクトである Flatpak の維持を任されています。最近 Codean Labs が Flatpak のセキュリティ解析を行いましたが、いくつかの課題が発見されました。Flatpak 開発者がファイルシステムの危険性を認識していたこともあり、これのために libglnx を作成しましたが、発見された問題の大部分はまさにそれに関するものでした。その一つ(CVE-2026-34078)は完全にサンドボックスエスケープという重大なものだったのです。
flatpak run は信頼されるユーザー向けのコマンドラインツールとして設計されています。flatpak run org.example.App と入力した際、あなたは引数を制御します。引数を処理するコードは呼び出し方が正当であると想定して書かれています。それはパス文字列を受け取るだけで、コマンドラインツールの標準的な振る舞いです。
その後 Flatpak ポータルは、サンドボックス化されたアプリケーションがサブサンドボックスを開始するために呼び出す D-Bus サービスとして構築されました。これは実質的に
flatpak run の呼び出しを構築して実行することで実現されています。つまり、信頼できる入力用に設計されたコンポーネントを、非信頼な呼び出し元(サンドボックス内アプリ)に直接接続してしまったのです。
その接続が確立されると、「flatpak run」における「呼び出し元の正当性に関するすべての前提条件」が潜在的な脆弱性に変換されます。修正策は「一つの関数を変更するだけ」ではなく、「ポータルリクエストから bubblewrap の実行に至るまでの一連の呼び出しを監査し、すべてのパス文字列を fd に置き換える」ことに他なりません。それはポータルに変わるコミット、
flatpak-run、flatpak_run_app、flatpak_run_setup_base_argv、bwrap の引数構築、そして新しいオプション(--app-fd、--usr-fd、--bind-fd、--ro-bind-fd)をこれらすべてにわたって通す作業でした。
GLib 標準のファイル・パス API が安全であれば、この問題は発生しませんでした。
もう一つの問題は、Flatpak のサブサンドボックス化アプローチが現在では必要以上に古く(15 年前)、未権限付与のユーザーネームスペースが一般的ではなかった時代からのものということです。現代ではアプリケーションに対してカーネルネイティブの未権限付与ユーザーネームスペースを利用させて独自のサブサンドボックスを構築させることも可能(かつ必要)です。
残念ながら、大きな変更に伴うリスクとして何か不具合が発生する可能性が高いです。数日間にわたって、Steam や WebKit、Chromium ベースのアプリケーションの起動を防いでいたいくつかの回帰(regression)を修正するために必死で動きました。Simon McVittie 氏に心より感謝申し上げます!
結局、私たちはすべての問題を解決し、Flatpak をより安全に改修し、エコシステム全体がこの種の課題に対処する能力が高まりました。きっと何かしら学んでもらえたhopefully もあるはずです。