Rustは「メモリ安全性を保つために型システムがある」と強く打ち出してきた言語だ。その土台を揺らす unsoundness の報告が、GitHub の rust-lang/rust に上がった。しかも今回は、単なる理論上の穴ではなく、実際に昔から通るコードで再現でき、ASan では stack-use-after-scope まで出る。Rust の borrow checker や lifetime の話は入り組んで見えるが、今回の件は「安全だと思っていた前提が、Drop のタイミングを通じて破られる」という、かなり本質的な問題だと思う。
issue の題名は「struct's lifetime requirements can be bypassed via covariant field assignment」で、struct の lifetime 要件が共変な field assignment を使って回避できる、という意味だ。投稿者はまず、このバグは Rust の型システムが守るべき memory unsafety に関わるため重い問題だと説明している。ただし、11年前から存在したコードで、野外で長く見逃されていたことから、実際に偶発的な被害を起こすほど簡単な穴ではないとも書いている。攻撃に使うには、意図的に悪さをする Drop 実装が必要になるからだ、という整理だ。
最初の例では、Mutex<Vec<Box<dyn Any + Send>>> をグローバルに用意し、Q<T: 'static + Send>(Option<T>) という struct に Drop を実装している。drop の中では、self.0 に入っていた値を取り出し、Box::new(t) にして KEPT に push する。main では、ローカル変数 l を参照として q.0 = Some(&l) に入れたあと、スコープを抜ける。すると q の Drop が走り、&l を 'static だとみなしたまま保存してしまう。続いて別の String を作ると、保存していた参照を downcast_ref::<&'static String>() で読み出し、実際にはもう寿命が切れているはずのメモリに触れてしまう。結果として、プログラムはコンパイルが通り、XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX を表示する。ASan を付けると AddressSanitizer: stack-use-after-scope で落ちる。
投稿者は、この挙動について「program is not accepted であるべきだった」と明言している。つまりコンパイラが弾くべきコードが通ってしまったわけだ。さらに別のコメントでは、核心は struct Q<T: 'static>(Option<T>) のような型にあるとまとめ直している。Drop の中は「&String を扱っているのに &'static String だと思い込んでいる」状態になっており、そこが unsoundness の本体だ、という説明だ。
その後、別の再現例も出てくる。MyType<'a, 'b: 'a, 'c>( &'c mut &'a str, &'b str ) のような型で、Drop の中で *self.0 = self.1; と書く。main では一時的な String の参照を q.1 に入れ、Drop 時にそれが外側の me_out に書き戻される。これも、寿命制約を満たしていない値が drop 時に紛れ込む例だ。さらに別版として、FnMut(&T) を受け取る Q<T, F> の Drop で self.1(self.0.as_ref().unwrap()) を呼ぶ例も示されている。こちらは、解放済みメモリを読んだり、free 済みの値を出力したりする方向の危険につながる。
コメント欄では、この問題が少なくとも Rust 1.18.0 以降でコンパイルされてきた、とも書かれている。投稿者は「どうして今まで見つからなかったのか」と驚いており、さらに関連しそうな issue 番号も挙げている。最終的に rustbot が P-critical に格上げし、A-borrow-checker、A-lifetimes、A-variance、A-destructors などのラベルが付いた。つまり、borrow checker だけでなく variance と destructor の交差点にある、かなり深い問題として扱われたわけだ。
まず気になったのは、「11年見つからなかったから大したことはない」とは言えない、という issue 側の姿勢だ。これはもっともで、型システムの穴は再現の容易さだけで危険度を測れない。今回は、普通のコードを書いているだけではなく、Drop の中で型の前提を壊すような挙動が必要だった。だから被害は限定的に見える。だが、一度でも unsafe な境界を越えると、Rust が約束していた安全性の意味が薄れる。ここは「悪用が難しいから安心」ではなく、「悪用が難しいから長く見逃された」と見るべきだと思う。
もう一つ引っかかるのは、問題の舞台が Drop だという点だ。Rust では、値がスコープを抜けるときの後始末にあたる Drop が非常に重要で、メモリ解放や後処理の最後の砦になる。ところがその最後の砦の中で、まだ生きている参照と、もう死んでいる参照の区別が崩れると、静的な型検査だけでは追い切れない。今回の例は、Option<T> に一旦入れた値を Drop で取り出すだけに見えるのに、そこから 'static の仮定が混ざる。Rust の安全モデルが「値の寿命」と「型パラメータの見え方」をかなり複雑に組み合わせて守っていることが、逆に見えてくる。
この issue が面白いのは、コンパイルが通ること自体が問題だと、かなりはっきり示しているところだと思う。Rust を使う人はつい、エラーが出なければ安全だと考えたくなる。でも今回のように、共変性や Drop のふるまいをまたいだ隙間があると、その信頼は崩れる。しかも実行結果はすぐクラッシュしないこともある。今回は文字列がそのまま表示されていて、見た目には「動いている」ように見える。だから余計に怖い。
ここから得られる教訓は、Rust でも「コンパイラが通したから型安全の議論は終わり」ではない、ということだ。もちろん通常利用者が毎日この種の穴を心配する必要はない。だが言語実装やライブラリ設計に関わる人にとっては、Drop、lifetime、variance の組み合わせがどれだけ繊細かを示す実例になる。Rust チームが types team で優先度高く扱ったのも納得できる。安全を売りにする言語では、こうした穴が見つかった時点で迅速に潰すしかない。
個人的には、この issue は Rust の強みと弱みを同時に見せていると思う。強みは、問題が見つかると、コミュニティがその場で最小例をいくつも作り、どこで前提が壊れるかを切り分けていく点だ。コメントの流れを見ると、単なる報告で終わらず、別パターンの再現や関連 issue の参照まで進んでいる。安全性を大事にする文化が、バグの発見後にも機能している。
一方で弱みは、型システムが高度になるほど、実際の危険が「理屈では分かるが直感しにくい」形になることだ。T: 'static と書いてあるのに、実際にはそうでない参照が内部から入ってしまう。このズレは、Rust に詳しい人ほどぞっとするはずだ。言語の約束は強いが、約束が守られる条件もまた複雑だということだろう。今回の件は、その複雑さの境目を突いた報告としてかなり重い。