ハクソク

世界を動かす技術を、日本語で。

Rustが捕まえられないバグ

概要

  • 2026年4月、Canonicalがuutils(Rust製GNU coreutils再実装)の44件のCVEを公開
  • 多くは26.04 LTSリリース前の外部監査で発見
  • Rustの安全機構(borrow checker、clippy、cargo audit)をすり抜けたバグが多数
  • 監査結果はRustの安全性の限界を示す貴重な教材
  • 本内容はuutilsチームへの批判ではなく、学びの共有

Rustで発生した44件のCVEから学ぶシステムプログラミングの教訓

  • uutilsRustで書かれたGNU coreutilsの再実装であり、Ubuntu 25.10以降デフォルトで採用
  • 2026年4月、Canonicalが44件のCVEを公開
  • 多くは外部監査による発見、26.04 LTSリリースの事前準備として実施
  • Rustの型安全性静的解析ツールでは検出できなかった実バグ
  • uutilsチームは詳細な監査結果を公開し、コミュニティ全体の学びに貢献

システムコール間のパスの信頼性(TOCTOU問題)

  • TOCTOU(Time Of Check To Time Of Use)バグが最大のクラスター

  • 例:fs::remove_fileでファイル削除後、File::createで再作成する間にシンボリックリンクへすり替え可能

  • 攻撃者が親ディレクトリへの書き込み権限を持つと、特権操作が任意ファイルに作用

  • Rust標準ライブラリのfs::metadata, File::create, fs::remove_file, fs::set_permissionsなどのAPIはパスベースで再解決

  • **OpenOptions::create_new(true)**を使い、ファイル新規作成時のみ安全性を担保

  • それ以外の場合は親ディレクトリのファイルディスクリプタを基準に相対パスで操作

    • ルール:同じパスに2回作用するならTOCTOUを疑い、ファイルディスクリプタ基準で処理

パーミッションは作成時に設定

  • ディレクトリやファイルの作成後にset_permissionsでパーミッションを修正すると、短時間デフォルト権限で存在

  • 他ユーザーがその間にopen()可能、chmodしても既存のfdは権限維持

  • **DirBuilderExt::mode()OpenOptions::mode()**で作成時にパーミッション指定

  • umaskの明示的設定も重要

    • ルール:パーミッションは必ず作成時に指定、後から修正しない

パスの文字列比較はファイルシステム上の同一性を保証しない

  • 例:--preserve-rootの判定が"/"と文字列比較のみだと"/../"やシンボリックリンクで回避可能

  • fs::canonicalizeで正規化し絶対パス比較

  • より厳密には**(dev, inode)**ペアで比較

    • ルール:パス比較は文字列ではなく、正規化またはファイルシステムIDで行う

Unix境界ではバイト列で扱う

  • RustのStringや**&str**は常にUTF-8、しかしUnixのパスや環境変数、標準入出力はバイト列

  • from_utf8_lossyは不正バイトをU+FFFDに変換しデータ破壊、unwrapや?はクラッシュ

  • OsStr/OsStringや**&[u8]**、**Vec<u8>**でバイト列として扱うべき

  • print!はUTF-8経由だが、write_allはバイト列をそのまま出力

    • ルール:Unix系の生データはバイト列型で処理し、String経由の変換は避ける

panic!はDoS攻撃の温床

  • unwrap、expect、インデックスアクセス、unchecked演算、from_utf8などはpanic!でプロセス全体が異常終了

  • CLIやバッチ処理、CI環境ではDoS(サービス不能)につながる

  • 例:sort --files0-fromで非UTF-8ファイル名にexpectを使い即panic

  • Clippyのunwrap_used、expect_used、panic、indexing_slicing、arithmetic_side_effectsをwarn指定

  • テストコードではpanic許容、CIでは本番コードのみ警告

    • ルール:不正入力はpanic!ではなくエラーとして返却、unwrap/expect禁止

エラーは捨てずに伝播

  • chmod -Rやchown -Rが最後のファイルのエラーコードのみ返却、途中失敗が無視される

  • ddがset_lenのResultを.ok()で黙殺し、ディスクフルでもエラー無視

  • let _ =や.ok()でResultを捨てる際は、なぜ安全かコメント必須

    • ルール:意味あるエラーは必ず伝播し、最悪ケースを記録

オリジナルツールとの完全互換性

  • 多くのCVEは「GNU coreutilsと挙動が違う」ことが原因

  • 例:kill -1の解釈違いで全プロセスkill

  • オプション、エラーコード、エラーメッセージ、端的な挙動までバグ互換が安全性

    • ルール:バトルテスト済みツールの再実装では、バグ含めて挙動を完全再現

まとめ

  • Rustは強力な安全機構を持つが、TOCTOU問題パーミッションのタイミングバイト列処理panic!によるDoSなど、設計・実装レベルでの注意が不可欠
  • 監査レポートはRustによるシステムプログラミングの落とし穴とベストプラクティスの宝庫
  • uutilsチームの透明性とコミュニティ貢献に感謝

Hackerたちの意見

> 注目すべきは、これらのバグがすべて、Rustのコードベースに存在していることだね。書いた人たちはちゃんとした知識があったけど、UnixのAPIやセマンティクス、落とし穴にはあまり経験がなかったみたい。長年GNU coreutils(またはBSDやSolarisベース)の開発者から見ると、ほとんどがアマチュア的なミスで、数十年前に特定されて解決された問題なんだよね。それでも、古いコードベースにはまだ修正が続いてるけど、最近はほとんどが微々たるものだね。
それ以上に、Rustの標準ライブラリは、開発者を不適切な抽象レベルでのきれいなAPIを使うように促してる気がする。例えば、ハンドルベースではなくパスベースのファイル操作とか。間違ってるといいな。
誰かが「逆アセンブラの怒り」という関連用語を作ったことがあるんだ。これは、すべてのミスが近くで見るとアマチュアに見えるという考え方。逆アセンブラに座って、例えば関数呼び出しの中で条件文を使った高レベルプログラマーに対して怒る人たちから来てる。彼らは、間違った部分だけを見て、周りの正しい行の数千を無視してるんだよね。
新しい言語でcoreutilsを書き換えて、Unixの経験がほとんどないのに、バグや脆弱性がほとんどないってすごいと思う。少なくとももっと多くの問題が出ると思ってたよ。Rustがどれだけ優れているかを示してるね。経験のないUnix開発者でも、こんなものを書いてほとんどミスをしないんだから。
メモリ安全性はバッファオーバーフローをキャッチする。CIは論理バグをキャッチする。でも、Unix APIの落とし穴は誰もドキュメント化してないから、どちらも捕まえられないんだよね。
こんにちは、私はGNU Coreutilsのメンテナです。この記事ありがとう、面白いトピックがいくつか取り上げられてるね。私が使った少しのRustでは、std::fsを使うとTOCTOUレースが書きやすいと感じたよ。最終的にはopenatに似たAPIが標準ライブラリに追加されることを願ってる。あと、「ルール:パスを比較する前に解決する」というセクションには同意できないな。一般的にはfstatを呼び出してst_devとst_inoを比較する方がいいと思う。でも、この記事でも触れられてたね。あまり考慮されない副作用としては、パフォーマンスへの影響があるよ。実際の例を挙げると、$ mkdir -p $(yes a/ | head -n $((32 * 1024)) | tr -d '\n') $ while cd $(yes a/ | head -n 1024 | tr -d '\n'); do :; done 2>/dev/null $ echo a > file $ time cp file copy 実行時間 0m0.010s ユーザー 0m0.002s システム 0m0.003s $ time uu_cp file copy 実行時間 0m12.857s ユーザー 0m0.064s システム 0m12.702s こんなことを現実でやる人はほとんどいないと思うけど、GNUソフトウェアは恣意的な制限を避けるためにすごく頑張ってるんだよね[1]。それに、全体的なポイントはまだ有効だけど、記事には「Rustの書き換えでは、同等の活動期間中にこれらの[メモリ安全性のバグ]はゼロだった」と書いてある。でも、それは真実じゃないよ[2]。 :)
ごめん、完全な初心者なんだけど。どうして$(yes a/ | head -n $((32 * 1024)) | tr -d '\n')にcdしなかったの?whileループを使ってcdする必要があるの? 編集:わかった。-bash: cd: a/a/a/....../a/a/: ファイル名が長すぎます
まずは、反対側の視点からの簡潔な意見をありがとう。これからどうやって学べるかな?(特にインターネットの文章において、対比をはっきりさせるためにかなり攻撃的に聞いてるよ。)(君は僕に時間や精神的な余裕を貸す必要は全くないからね。)じゃあ、いくつか質問するね。質問1: どうして「スピード」、「パフォーマンス」、レースコンディション、st_inoが何度も出てくるの?スピード(レイテンシ)、物理的にストレージに書き込むこと(順次、原子的に(ACID)、HDD、NVME、SSD、ODD、FDD、テープ、「Haskellモナド」、イベントホライズン、光と情報の有限速度、何でも)やレースコンディションは、結局同じことに行き着くように思える。会計のような信頼性の高いシステムでは、ACIDかハイウェイが道筋のようだね。「信頼性のない」システムは、コンピュータがあまり違いを生まないほど早く忘れる。質問2: 日常のアプリケーションでは、スループットはレイテンシよりも重要なの?質問3(今回は説明から始めるね): inode番号に焦点を当てるのは、CやUnix系OS、GNU coreutilsの歴史を考えると理解できる。でも、この基本的な例はどう?USBメモリをファイル保存用に「動かす」だけでいいんだけど(nandフラッシュの劣化やUSBは無視して)。libcのIOバッファリング、fflush、カーネルバッファリング(LinuxやFreeBSDよりHurdが好きならそれで)、マルチコアやタイムスライスシステム上で複数のアプリケーションが動いている状況(ブロッキングIOの単一ユーザーレベルのバイナリしか動かしていない単一コアCPUを排除するために)に引っかからないように。
公平に言うと、RustのVec::set_lenバグは2021年のことだったんだ。それでも、`unsafe`として注釈を付けなきゃいけなかった。で、その後非推奨になって、リンターのチェックが追加されたんだよね: https://github.com/rust-lang/rust-clippy/issues/7681
多分バカな質問だけど、GNU Core utilsは自分たちのRust書き直しに興味があるのかな?
要するに、ファイルシステム操作に必要な原子性が欠けてるのと、面倒なパスや文字列のエンコーディング、歴史的な振る舞いの慣性が問題なんだね。
リストありがとう。こういうリスト好きだから、.mdファイルに入れて、コードベースで「ファイルごとに1エージェント」を実行して、挙げられたCVEに似たものが見つかるか確認したいんだ。Rustでは捕まえられないけど、今度はエージェントが捕まえてくれるだろうね。 編集: https://gist.github.com/fschutt/cc585703d52a9e1da8a06f9ef93c... これをコピーしたい人のために。
コードを書き直すのが難しいのは、元のコードが実際の問題に応じて徐々に変わってきたからなんだよね。そうやって蓄積された教訓が、黙ってコードに影響を与えてる。ドキュメント化されてないと、実際に同じレベルに達するまでに隠れた作業がたくさん必要になる。TFAはまさにそういうことの良いリストだよ。人をアマチュア呼ばわりする前に、ソフトウェアを書くことの中で一番ソフトウェアっぽい部分でもあるってことも考えてみて。coreutilsが本当に良い技術文書を持っていて、無視されたケースのテストも含んでいなかったら、こうなるのは避けられなかっただろうね。
さらに難しいのは、GPLを避けながらそれをやることで、元のソースコードを読まずにやることだね。個人的には、uutilsはGPLだったらもっと良くなると思うし、coreutilsのソースコードから直接インスパイアを受けてたら最高だよね。
> パターンはいつも同じ。何かパスについてチェックするために1回システムコールをして、その後同じパスに対して別のシステムコールをする。その2つの呼び出しの間に、親ディレクトリに書き込み権限を持つ攻撃者がパスのコンポーネントをシンボリックリンクに置き換えられる。カーネルは2回目の呼び出しでパスを最初から解決し直し、特権のあるアクションが攻撃者が選んだターゲットに行くことになる。実際には、親ディレクトリに書き込み権限を持つ攻撃者がハードリンクにも手を出せるから、もっと悪化するんだよね…確かに、通常のファイル自体には影響があるだけだけど、基本的に対策はない。例えば[0]や他の投稿を見てみて。 [0] https://michael.orlitzky.com/articles/posix_hardlink_heartac...
うーん…ディレクトリに「書き込みロック」をかけるのはどう?でも、タイムアウトなしだともっと厄介になるかも…
> 落とし穴は、get_user_by_nameが新しいルートファイルシステムから共有ライブラリを読み込んでユーザー名を解決することになることだ。これはちょっと恐ろしいね。そういうことをする関数の信頼できるリストはどこかにあるの?そのリストは安定していると見なされてるの?
いや、基本的にユーザー名やホスト名を解決するものは、NSSによってユーザースペースで行われると思っておいて。サンのエンジニア、トーマス・マスレンとサンジェイ・ダニが最初にネームサービススイッチを設計・実装したんだ。彼らはnsswitch.confファイルの仕様でSolarisの要件を満たし、データベースアクセスモジュールを動的にロードされるライブラリとして読み込む実装を選んだ。これもサンが最初に導入したんだよ。サンのエンジニアたちが設計した設定ファイルとネームサービスバックエンドライブラリのランタイム読み込みは、オペレーティングシステムが進化し、新しいネームサービスが導入されても時代に耐えてきた。年月が経つにつれて、プログラマーたちはNSS設定ファイルをほぼ同じ実装でFreeBSD、NetBSD、Linux、HP-UX、IRIX、AIXなどの多くの他のオペレーティングシステムに移植してきたんだ。[citation needed] NSSが発明されてから20年以上経った今、GNU libcはほぼ同じように実装している。これは設計によるものなんだよ。
いくつかのバグの根本的な原因は、Unix APIの不透明な性質にあるみたい。例えば、 > "get_user_by_nameが新しいルートファイルシステムから共有ライブラリを読み込んでユーザー名を解決することになるのが罠なんだ。chrootにファイルを置ける攻撃者は、uid 0としてコードを実行できる。" 私にとって、そんなget_user_by_name関数は罠みたいなもので、いつか事故が起きるのを待っている感じ。ユーザーデータが必要で、このget_user_by_name関数があって、それが共有ライブラリを読み込むって、関心の混同があると思う。ユーザーデータを取得するのと共有ライブラリを読み込むのを、別々の関数に分けるか、関数名で何をしているのかを明確にするべきだと思う。
> いくつかのバグの根本的な原因は、Unix APIの不透明な性質にあるみたい。 「見える」とか「匂う」は言い訳だね。根本的な原因は考えないことだと思う:なぜrootが自分が管理していないディレクトリにchrootしているのか? chrootする先は、そのchrootを作った人の管理下にあるから、これが理解できないならchroot()を使う資格はないよ。 > 私にとって、そんなget_user_by_name関数は罠みたいなもので。 > ユーザーデータを取得するのと共有ライブラリを読み込むのを、別々の関数に分けるか、関数名で何をしているのかを明確にするべきだと思う。 でも、たぶんまだ罠にはまってると思う。newroot/etc/passwdに書き込むのとnewroot/usr/lib/x86_64-linux-gnu/libnss_compat.soに書き込むのは、実際にはほとんど違いがないからね。/usr/sbin/chrootが最初にユーザーIDを調べる理由はないから、バグは何かをすること自体だと思う。
> 注目すべきは、これらのバグが全て、やるべきことを知っている人たちによって書かれた本番のRustコードベースに現れたことだ。 それって、元のutilsにはテストハーネスがなかったのか、書き直すプロセスがそれを作ることから始まらなかったってこと? 確かにエッジケースはたくさんあるけど、OSやFSは抽象化できるし、「rm .//」が実際に期待通りに動くか確認できるはず(例えば、現在のディレクトリを削除しないとか)。これは雑なコーディングとは思えないし、言語への批判でもない。ただの「これはシステムプログラミングだから、テストはやらない」って感じ? 逆に、もし元のutilsにテストがあったとして、テストにこんなに穴があったら、元のutilsのテストスイートには大きな欠陥があるかもね。
Rustだと、借用チェッカーと戦うのに時間を取られすぎて、全てのバグのクラスを見逃しちゃうんだよね。