未定義動作について
MISRA Cの主要な目標の1つは、コードベースに存在する「未定義動作」(UB)1の量を減らすことです。 第1章でUBについて少し触れましたが、これは不可欠な概念です。 UBを排除することは、高保証ソフトウェアにとって必要ですが、それだけでは十分ではありません。 そこで、Rustの構文に入る前に、このトピックに取り組みましょう。
ISO C標準2は、動作を次のように定義しています。
外部から見た様子または作用。
したがって、**未定義動作(UB)**は次のように定義されています2。
移植性のない、または誤ったプログラム構成要素、あるいは誤ったデータを使用した場合の動作であり、この国際標準がいかなる要件も課さないもの。
言い換えると、開発者がうっかりUBを引き起こした場合、プログラムは文字どおり何でもできます。 クラッシュしたり、不正な結果を生成したり、一見無関係な一連の操作を実行したりする可能性さえあります3。
ここで「未定義の操作は文字どおり何でもできます」とは言っておらず、「プログラムは」と言っていることに注意してください。 UBについて理解しておくべき重要な事実が1つあります。
- 未定義動作が引き起こされると、その悪影響は多くの場合、局所化できません。システム全体のセキュリティや信頼性を損なう可能性があります。
UBは、バグや脆弱性の望ましくない原因です。 しかし、複雑な歴史的理由がさまざまにあるため、UBはCとC++の両方の標準に深く組み込まれています。 どちらかの言語からUBのごく一部を取り除くだけでも、利用可能なコンパイラの大部分、または既存のコードをコンパイルする能力が壊れてしまいます。 そのため、実現する可能性は低いでしょう。
私たちにできる最善のことは、それを避けるために入念に努力することです。 つまり、C系のコードベースを徹底的に監査し、綿密にテストすること、そして可能な場合にはRustを導入することを意味します。
CでUBを導入するのはどれほど簡単か?
CおよびC++プログラムは、メモリ安全な言語と比べて、デバッグが難しく悪用されやすいです。なぜなら、UBの導入は些細であり、かつ一般的だからです。
研究者たちは、LinuxカーネルやPostgreSQLのような広く使われているプロジェクトでUBの事例を発見しています4。 第1章でその深刻度と蔓延状況を説明したメモリ破壊バグは、UBの結果の1つにすぎません。
UBがどのようなものか、具体的に感じてみましょう。 以下のCプログラムを考えてください。これは何を返すでしょうか?
#include <stdio.h>
int undef_func() {
int uninit_var; // 一度も代入されない!
if (uninit_var > 0) {
return 1;
} else {
return 0;
}
}
int main() {
printf("%d\n", undef_func());
}
これはひっかけ問題でした。
答えは1または0であり、その時点でたまたまメモリに入っていた内容によって決まります。
単にif文が未初期化の値を読み取り、その結果に基づいて分岐したからです。
C標準(6.7.8、段落102)は次のように述べています。
自動記憶域期間を持つオブジェクトが明示的に初期化されない場合、その値は不定である。
C標準の文脈では、「不定」とは、変数が次のいずれかであり得ることを意味します。
-
その型の任意の正当な値を取る(例:「未規定値」)。
-
その型のいかなるインスタンスも表さない値を取る(例:「トラップ表現」)。UBが発生します。
どちらの場合も、プログラムの信頼性にとって良い兆候ではありません。 この単純な関数は、標準のこの部分を明示的に対象とするMISRA Cのルールに違反しています。
[AR, Rule 9.1] オブジェクトの値は、書き込まれていない場合には読み取るべきではない5
デフォルトでは、人気のあるオープンソースのCコンパイラであるgccは、この重大なエラーについて警告しません。
以下の警告を得るには-Wallフラグを渡すことを覚えておく必要があり、それでもプログラムはビルドされ実行されます。
undef.c: In function ‘undef_func’:
undef.c:5:8: warning: ‘uninit_var’ is used uninitialized in this function [-Wuninitialized]
5 | if (uninit_var > 0) {
| ^
残念ながら、特別なコンパイラフラグを覚えておくことは一般的な解決策ではありません。 Cには起こり得る未定義動作が何百もあり、その大多数はコンパイラ警告では検出できません。 したがって、こうした「不正動作」は、コードベースが複雑になるにつれて急速に入り込んでいきます。
この問題の実例は何でしょうか?セキュリティの文脈では?
CVE-2022-0847、別名「Dirty Pipe」は、5.8以降のLinuxカーネルバージョンに影響する、非常に悪用しやすい脆弱性でした(安定版リリース5.16.11、5.15.25、5.10.102でパッチ適用済み6)。 コードのリファクタリングによって構造体のフィールドが未初期化になり、そのUBの事例はコンパイラ警告やテストでは検出されませんでした。
未初期化フィールドは、カーネル空間の
pipe_bufferデータ構造のflagsメンバーでした。 これはカーネルが「パイプ」を設定するために使用されます。パイプはプロセス間通信(IPC)機構です。通常の非特権操作を一連実行することで、攻撃者は、後でページキャッシュの書き込み権限のフラグとして読み取られる(正しくリセットまたは初期化されるのではなく)メモリ上の値を、確実に制御できました6。
この不正に得た権限を悪用してファイルにパイプすることで、攻撃者は読み取り専用であるべきシステムファイル内の小さな内容の塊を上書きできます。 これにより、とりわけ、rootパスワードを変更してローカル権限を昇格させ、その後リモートアクセスに使用されるSSH鍵データを上書きすることが可能になります。
実質的に、攻撃者はユーザーに非特権プログラムを実行させるだけで、脆弱なシステムの「完全な制御」を取得できます。すべては1つの未初期化フィールドが原因です! 攻撃者に少しでも隙を与えれば、大きな被害につながる可能性があります。
それをRustで試してみましょう
Rustでも、unsafeキーワード7を使用すれば未定義動作は依然として可能ですが、Rustの安全なサブセットではほぼ排除されています。
これは、Rust言語が正しく信頼性の高いソフトウェアを書くのに非常に適している大きな理由の1つです。
Rustはデフォルトで、UBをほぼ完全に取り除きます。
「ほぼ排除」と「ほぼ完全に」という但し書きはなぜ必要なのか?
本稿執筆時点では、Rust にはまだ公式な言語標準や仕様がありません。 C や C++ の ISO 文書に相当する Rust の文書はありません。 そのため、断定的な主張をするのは困難です。
The Rust Reference には、Rust において未定義と見なされる振る舞いの非網羅的な一覧が含まれており7、それらはいずれも導入するには
unsafeキーワードが必要です。 したがって、Rust における UB の潜在的な発生源は、おそらく次の 3 つだけです。
不変条件が実際には維持されていない
unsafe関数またはブロック(私たちの責任)。これには、サポートされていない ABI で外部例外が FFI 境界を越えるような、FFI のエッジケースも含まれます。健全性を脅かすまれなコンパイラバグ8(発見され次第パッチ適用)。
特定の CPU 拡張のサポートを有効にしてコンパイルされたプログラムを、それらをサポートしていない CPU バリアント上で実行するような、プラットフォーム固有の不変条件違反(デプロイの問題)。
2022 年 7 月、Ferrocene Language Specification(FLS)9 が公開されました。 この仕様は、特定の安全クリティカルな用途向けに認定された Rust コンパイラの商用ダウンストリームである Ferrocene をサポートします。 この仕様は Rust 言語と標準ライブラリ全体を文書化することを目的としていませんが、現在のところ Rust プログラムにおける UB の発生源になり得るものを 21 個列挙しています10。
第 12 章では、Rust プログラムの UB を検出するための実験的な動的ツールである
miri11 を使用します。
Rust の利点をより実感しやすくするために、バグのある C プログラムを Rust に移植してみましょう。
fn undef_func() -> isize {
let uninit_var: isize;
if uninit_var > 0 {
return 1;
} else {
return 0;
}
}
fn main() {
println!("{}\n", undef_func());
}
cargo build を実行すると、次のようになります。
error[E0381]: use of possibly-uninitialized variable: `uninit_var`
--> src/main.rs:3:8
|
3 | if uninit_var > 0 {
| ^^^^^^^^^^ use of possibly-uninitialized `uninit_var`
For more information about this error, try `rustc --explain E0381`.
gcc の警告も同様でしたが、それに従うかどうかは完全に任意でした。
Rust では、この同じ誤りはハードエラーです。この問題に対処しない限り、プログラムはコンパイルされません。
言い換えれば、すべての安全な Rust プログラムは前述の MISRA C Rule 9.1 に従います。
より一般的に言えば、安全な Rust プロジェクトのコンパイルに成功するということは、UB が排除されている可能性が高いことを意味します。 したがって、以下に従うことで、大きな保証が得られます。
[RR, Directive 2.1] プロジェクト全体がエラーなしでコンパイルされるべきである5
なぜ UB はそもそも存在するのか?
商用かオープンソースかを問わず、ある言語に複数のコンパイラが存在することが望ましいと仮定しましょう。 各コンパイラ実装は、異なるニッチに対応したり、独自の機能を提供したり、あるいは単に有望なアイデアを実験したりするかもしれません。 これは、同じ標準やプロトコル(HTML、HTTP2 など)をすべてサポートする複数の Web ブラウザーが存在するのと同じです。
したがって、単一の言語標準(すでに触れた ISO C 標準2 のようなもの)は、任意のプラットフォームアーキテクチャを対象とする任意のコンパイラ実装に適用可能である必要があります。 これはインターフェイス設計の問題に似ています。 失敗モードを設計することを伴い、そこで UB が登場します12。これは、標準が普遍的な規則を課さない、課すべきでない、または課せないエッジケースを「処理」する 1 つの方法です。
そのため、標準は境界を引きます。つまり、さまざまな基盤ハードウェアを表現するのに十分一般的な「抽象機械」を定義します。 これには、コンパイラ開発者にプラットフォーム固有の最適化を導入する余地を与えるという利点があります。 これはコンパイラの主な仕事の 1 つです。つまり、効率的な機械語コードを生成するために、書き換え規則を繰り返し適用することです。
最適化コンパイラは、入力ソースコードが言語仕様に従って UB を導入しないと仮定します。 この仮定が次の場合:
-
真(ソースが実際に UB を含まない) - 書き換え規則は既存のコードを、より高速で、かつ論理的に同等な新しいコードに置き換えます。
- これらの規則は、抽象的な仕様が提供する「自由度」を利用して、アーキテクチャ固有の命令やメモリモデルのセマンティクスを活用することがよくあります。あるいは、不要であることが証明されるチェックを削除します13。
-
偽(ソースに UB が含まれる) - 書き換え規則の適用によって論理的矛盾が生じる可能性があります。
- 存在する UB が「トリガー」された場合、結果として不正なコード置換や任意の実行時操作が発生します。
この二分法から、次の疑問が生じます。十分に「賢い」コンパイラであれば、UB を含まないソースであるという仮定を単純に検証できないのでしょうか? 構文や型付けをコンパイル時にチェックするのと同じように。
答えはイエスです!
ほのめかしたように、完全に安全な Rust コードをコンパイルするときに rustc が行っているのは、まさにそれです14。
高度な型システムと実行時チェック挿入の組み合わせを使用します。
しかし、すべての UB が存在しないことを自動的に保証するのは、C、C++、そして unsafe Rust にとって技術的に実現困難です。
さらに、最も安全な Rust プログラムであっても、C の libc や Rust の core の一部のように、内部で何らかの unsafe コードにリンクしている可能性があります15。
保証の観点からは、そのように広く使われ、十分に検証された依存関係は、私たち自身が書く unsafe コードよりも UB を含む可能性が低い、ということに賭けているのです。
最適化の例は何か?
John Regehrは2017年の講演12で説得力のあるスニペットを示しました。ここではそれを少し変更して使います。 次の関数があるとします。
int set(int* a, int* b) { *b = 3; *a = 7; return *b; }
aとbは同じ型intへのポインタであり、エイリアスする可能性があります(ポインタとエイリアシングについての第2章の議論を思い出してください)。 つまり、この関数は、ポインタがエイリアスしない場合は3を返し、エイリアスする場合は7を返すべきです。したがって、コンパイラは整数を返す前にメモリからロードする機械語コードを生成せざるを得ません。 エイリアスする場合としない場合の両方に対応するには、最新のデータを読み取る必要があります。 次のようなx86-64アセンブリのスニペットが出力される可能性があります(ATT構文)。
set: movl $3, (%rsi) movl $7, (%rdi) movl (%rsi), %eaxx86アセンブリに詳しくない場合、ここでの重要な考え方は、最後の行がメモリアドレスからのロードであるということです。
%rsiはポインタを保持するレジスタです。
(%rsi)はポインタの参照外しであり、そのポインタが指すデータを読み取ります。
movl (%rsi), %eaxは読み取ったデータを、戻り値に使われるレジスタである%eaxへコピーします16。では、2つのパラメータが異なる整数型へのポインタであるとします。
int set(long* a, int* b) { *b = 3; *a = 7; return *b; }この場合、コンパイラはC標準における「strict aliasing」の定義に基づき、ポインタがエイリアスしないと仮定できます。 もはやメモリから現在の値を読み取る必要はなく、定数
3を返せます。 その方が高速です。 この最適化により、次のようなアセンブリになる可能性があります。set: movl $3, (%rsi) movq $7, (%rdi) movl $3, %eaxすばらしいことに、より効率的なコードが得られました。 最後の命令には読み取りがなく、定数の移動だけです。
では何が問題なのでしょうか? Cプログラマは、関数を呼び出す前に
int*をlong*へキャストすることで、その仮定を破ることができます。 以下のプログラムの動作は未定義です。include <stdio.h> int set(long* a, int* b) { *b = 3; *a = 7; return *b; } int main() { int x = 0; printf("%d\n", set((long*)&x, &x)); }さまざまな理由から、キャストはCおよびC++プログラムで一般的な操作です。 Linuxカーネルのようなプロジェクトでは、安全性のためにこの特定の最適化を明示的に無効化しています4。
安全なRustでは、参照をキャストできません。また、可変エイリアシングが常に存在しないことを保証できます。 そのため、この特定のケースでは、RustはUBの危険なしに最適化を実行できます。
UBの実際の影響は何か?
起こり得る結果は4つあります12。 おおむね最善のケースから最悪のケースの順に列挙できます。
-
プログラムが即座に壊れる: 実行時にクラッシュ(例: セグメンテーションフォルト)または例外(例: ゼロ除算の試行)が発生し、プログラムは停止します。
- 製品を出荷する前に検出するのが最も簡単なケースです。動的テストで、欠陥のあるコードパスを一度実行するだけで済みます。
-
プログラムが破損した状態で実行を継続する: 内部状態が論理的に無効になりますが、プログラムは実行を続けます。何らかの任意の条件が満たされた場合に後でクラッシュすることもあれば、単に終了するものの誤った結果を生成することもあります。
- このケースは検出がより困難であり、発見するにはより徹底したテストケースが必要になる場合があります。
-
UBに依存しているにもかかわらず、プログラムが期待どおりに動作する: テストの観点からはプログラムが正しく見えますが、そのUBは起動を待つ「時限爆弾」です。別のアーキテクチャ向けにコンパイルしたり、新しいコンパイラを使ったり、単に異なる設定を使ったりすると、プログラムは動作しなくなる可能性があります。
- 検出にはビルドツールチェーンの変更または更新が必要です。また、UBが上記のケース2として現れる場合、検出は即時ではない可能性があります。
-
プログラムが攻撃に対して脆弱になる: 想定された入力ではプログラムはUBを引き起こしませんが、攻撃者が特別に細工した入力を与えると引き起こします。メモリ破壊バグの悪用にはUBを引き起こすことが伴います(次章で見ます)。
- これは最悪のシナリオです。攻撃者が、私たちのテストでは見つけられなかったUBを検出し、それを利用して本番環境の資産を侵害します。
UBの最初の3つの潜在的な影響は、機能性と信頼性への脅威です。 4つ目はセキュリティへの脅威です。 そのため、MISRA C標準には次の広範なルールが含まれています。
[AR, Rule 1.3] 未定義動作の発生をすべて排除する5
Rustの設計により、一般にこのルールへ準拠しやすくなります。 開発者は、何百もの難解なUBのエッジケースを同時に覚え、それらを100万行規模のコードベース全体で失敗なく適用する責任を負いません。 代わりに、Rustコンパイラが潜在的な問題をチェックします。 自動的に、かつ正確に。
要点
私たちが持つ最良のツールでも、中規模のCまたはC++コードベースに存在するすべての未定義動作を正確に突き止めることはできません。
-
商用の静的解析ツールは偽陽性に悩まされます。実用的な結果は多くの場合、ノイズの中に埋もれます。さらに、多くのUBについては検出アルゴリズムを設計すること自体が困難です。第2章で思い出したかもしれませんが、エイリアシングのような静的解析における重要な問題は、数学的に決定不能です。
-
動的ツール(LLVMのオープンソースである
UBSan17、ASan18、TSan19など)は近年大きく改善されましたが、それでも動的テストの根本的な制限(プログラムの状態空間のごく小さなサンプル)によりバグを見逃します。カバレッジガイド付きファジング(第12章で紹介)と組み合わせた場合でさえもです。
これこそが、MISRA Cのような標準が存在する理由の一部であり、また数え切れないほどのエンジニアリング時間が、これらの標準が守られることを保証するために費やされている理由でもあります。
欠陥率の削減は困難な戦いです。 CとC++がそれぞれの標準で許している未定義動作の量が非常に多いことを考えると、それは消耗戦だと主張することもできます。 勝者は信じられないほどのエンジニアリングコストを支払います。ツールのライセンス、出荷を遅らせるプロセス、そしてデバッグに費やされる人時という形でです。 あるいは、UBが悪用可能な脆弱性につながる場合には、サービス停止という形で支払うことになります。
それでは、Rustの安全なサブセットを学び始めましょう! Rustは完璧ではありませんが、UBを排除することは確かにRustの強みです。
gccはまだ戦いを諦めていない!弱い型システムと、UB の多い明文化された仕様を前提にすると、C コンパイラのメモリ安全性に関する保証の上限は低いと私たちは考えています20。 しかし、重要な進歩は今も続いています。 そして、C が広く使われていることを考えると、どんな小さな進歩にも大きな影響があります。
gcc12 は、改善された実験的な静的テイント解析21(信頼できないデータのフロー追跡)を提供します。 ソースアノテーションと組み合わせることで、潜在的な攻撃の入口を体系的にレビューする方法になります。 そして、これは現時点でrustcには提供されていない高度な機能です。この同じバージョンでは、新しい
-Wanalyzer-use-of-uninitialized-valueフラグ21 も追加されています。 上で-Wallを使ったことで含まれていた-Wuninitialized警告とは異なり、この新しいフラグは、関数間のフローに対して分岐を考慮した静的解析を使用します。 これは、誤検知を減らし、かつ、より対処可能な警告を増やすことを意味するかもしれません。私たちは、前述の「Dirty Pipe」6 カーネル脆弱性を検出する
gcc12 の能力はテストしていません。 しかし、関心のある読者にとっては、試してみる価値のある演習かもしれません。
-
Misra C コーディング標準と開発におけるその役割(SAS Talk). Roberto Bagnara (2018). ↩
-
ISO/IEC 9899:TC3. International Organization for Standardization (2007). C 言語のより新しい標準は有料であり、オンラインで自由に入手できるものではないことに注意してください。本書で述べる点は、より新しい C 標準にも依然として適用できます。 ↩ ↩2 ↩3 ↩4
-
nasal demons. 悪名高い Usenet の投稿によれば、UB の任意の結果には「鼻から悪魔が飛び出す」ことも含まれ得ます。そのため、UB は時々冗談めかして「nasal demons」と呼ばれます。 ↩
-
未定義動作:私のコードに何が起きたのか?. Xi Wang, Haogang Chen, Alvin Cheung, Zhihao Jia, Nickolai Zeldovich, M. Frans Kaashoek (2012). ↩ ↩2
-
MISRA C: 2012 クリティカルシステムにおける C 言語の使用に関するガイドライン(第 3 版). MISRA (2019). ↩ ↩2 ↩3
-
Dirty Pipe 脆弱性. Max Kellerman (2022). ↩ ↩2 ↩3
-
未定義と見なされる動作. The Rust Reference (Accessed 2022). ↩ ↩2
-
Pinにおける健全性の欠如. comex (2019). ↩ -
Ferrocene 言語仕様. Ferrous Systems (2022). ↩
-
CppCon 2017: 「2017 年の未定義動作」. John Regehr (2017). ↩ ↩2 ↩3
-
未定義動作はもっとよい評判に値する. Ralf Jung (2021). ↩
-
この主張には、議論の余地がある境界事例があるかもしれません。たとえば、
Cargo.tomlでoverflow-checks = falseが指定されている場合(最適化されたreleaseプロファイルのデフォルト設定)、整数オーバーフローは実行時に発生し得ます。これは、C/C++ におけるものとは異なり、Rust では技術的には UB ではありません。2 の補数によるラップを確実に期待できるからです。しかし、それでも、より大きなアプリケーションの文脈では予期しないバグを引き起こす可能性があります。 ↩ -
Rust Core ライブラリ. The Rust Team (Accessed 2022). ↩
-
技術的には、
%eaxは x86-64 システムにおける 8 バイトの%raxレジスタの下位 4 バイトです。%raxは戻り値に使用されます。この例では、8 バイトポインタを逆参照していますが、4 バイト整数を返しています。 ↩ -
UndefinedBehaviorSanitizer. LLVM Project (Accessed 2022). ↩
-
AddressSanitizer. LLVM Project (Accessed 2022). ↩
-
ThreadSanitizer. LLVM Project (Accessed 2022). ↩
-
一方で、C コンパイラは安全認証の観点から成熟しており、よく理解されています。また、形式検証の面でもさらに先を進んでいます。一例として、CompCert22 C コンパイラは、ソースコードのセマンティクスが機械語コードのセマンティクスと一致することを証明します。現行の Rust コンパイラで、そのレベルまたは種類の保証を主張できるものはありません。 ↩
-
GCC 12 コンパイラにおける静的解析の現状. David Malcom (2022). ↩ ↩2