- Rust と C/C++ の CVE 件数をそのまま比較すると、メモリ安全性の脆弱性を「ライブラリの問題」とみなす基準の違いを見落としやすい
- C/C++ では、誤った API 呼び出しによって UB やセグメンテーションフォルトが起きても、たいていは ユーザーコードの誤用として扱われ、可能性そのものをすべて CVE として登録するわけではない
libcurl の curl_getenv(NULL) 呼び出しは警告なしでビルドされ、実行時にセグメンテーションフォルトを起こしうるが、通常は curl の脆弱性とはみなされない
- Rust では、ユーザーコードに
unsafe がないのに安全な API 呼び出しだけでメモリバグが発生するなら、ライブラリの soundness bug とみなされる
- そのため Rust の一部の CVE は C/C++ よりも厳しい基準で記録されており、生の CVE 件数比較だけではメモリ安全性を判断しにくい
CVE 件数比較がぶれやすい理由
- CVE はソフトウェアのセキュリティ脆弱性を分類・報告するデータベース
- 脆弱性は単純なプログラムロジックのバグから生じることもあれば、エクスプロイトにつながりやすい メモリ安全性の問題に由来することもある
- Rust と C/C++ の CVE 件数を比較して、Rust は「実際にはメモリ安全ではない」あるいは「導入する価値がない」と主張する声もある
- しかしメモリ安全性に関わる潜在的脆弱性を、両エコシステムは大きく異なるやり方で扱っている
Rust でも脆弱性は起こりうる
- Rust のプログラムでも UB やメモリ安全性バグは起こりうる
- ほとんどの場合、こうした問題には
unsafe キーワードが必要になる
- Rust のプログラムが UB を一切起こしえないという主張は誤り
- メモリ安全性と無関係な一般的な脆弱性も Rust では起こりうる
- 管理者ダッシュボードへのアクセス権チェックを入れ忘れる問題は、どの言語でも起こりうる
C ライブラリの例: curl_getenv(NULL)
curl は広く使われ、よくメンテナンスされている C ベースのネットワーキングライブラリ
libcurl の curl_getenv は、複数の OS で環境変数の値を取得するための移植性のある抽象化関数
- 次の C プログラムは
curl_getenv に NULL ポインタを渡す
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- このプログラムは
gcc test.c -otest -lcurl -Wall -Wextra で警告なしにコンパイルできる
- 実行するとセグメンテーションフォルトが発生しうるため、これは メモリ安全性バグであり潜在的脆弱性とみなせる
- しかしこの種の例は、通常
curl の脆弱性として報告される対象ではない
C/C++ では誤用可能性だけで CVE にはしない
curl_getenv(NULL) のように問題が起きるケースは、一般に API の 誤った使い方とみなされる
- 欠陥の所在も、ライブラリや API ではなくアプリケーションコード側にあると考えられる
- この慣行には 2 つの理由がある
- C の限定的な型システムでは、API の契約、不変条件、事前条件、事後条件を精密に表現しにくい
- 考えうるすべての誤用を文書化するのも実用的ではない
- 実際、
curl_getenv のドキュメントには NULL 呼び出しが禁止でありセグメンテーションフォルトにつながりうるとは書かれていない
- C/C++ では UB を偶発的に引き起こすのが非常に容易なので、あらゆる潜在的脆弱性の可能性を CVE として報告すると、ほとんどのライブラリが膨大な数の CVE に埋もれてしまう可能性がある
- そのため C/C++ では通常、「誤用可能な API の存在」ではなく、特定の 誤用事例を中心に CVE が作られる
Rust では安全な API の責任境界が異なる
- Rust で
hyper::foo(None) のような安全な呼び出しだけでプログラムがセグメンテーションフォルトを起こすと仮定すると、それは hyper の CVE になりうる
- ユーザープログラムに
unsafe ブロックがないのにメモリバグが発生したなら、そのライブラリに soundness bug があるはずだから
- Rust では、安全なライブラリ API をどのように使ってもメモリバグが起きうるなら、それはユーザーコードではなくライブラリのバグとみなされる
- このような API は unsound である、あるいは soundness hole があると言われる
- 実際のプログラムでまだ問題が見つかっていなくても、安全な API の使用だけでメモリバグを起こしうるなら CVE が作られることがある
safe と unsafe が責任を可視化する
- Rust では「この関数をメモリ安全性の観点から正しく使っているか」という問いへの答えが、C/C++ よりも明確
- 呼び出す関数が
unsafe としてマークされていないなら、安全に使えるはず
- 呼び出す関数が
unsafe なら、呼び出し箇所に unsafe ブロックが必要になり、コードレビューやコードベース内で危険箇所が明確になる
- この区別は、Rust のメモリ安全性を実務上スケーラブルにしている要素
- ユーザーコードが
unsafe を使わず、コンパイラバグもないなら、潜在的なメモリ安全性の原因をユーザーコードの責任とみなすのは難しい
- ライブラリが
unsafe なインターフェースを公開していないなら、ユーザーはそのライブラリをメモリバグを起こす形で使えないはず
- ライブラリが内部で
unsafe を使ってバグを出したとしても、修正はライブラリ内で行われ、ユーザーは再びメモリバグから守られる
生の CVE 件数だけではメモリ安全性を比較しにくい
- 同じ論理を C に適用するなら、
curl_getenv も curl の CVE として数えるべきだが、C には Rust の safe と unsafe のような区別がない
- 実質的にほぼすべての C コードは暗黙に
unsafe に近いため、Rust 流の基準をそのまま当てはめるのは難しい
- C/C++ ライブラリ開発者が安全で堅牢なライブラリを作ったとしても、それを使う無数の C プログラムは API を誤って扱うことで簡単にメモリ安全性の問題を作り出せる
- この違いは
curl に限らず、ほぼすべての C/C++ ライブラリや両言語の標準ライブラリにも当てはまる
- Rust と C/C++ のコード行あたりの CVE 件数といった 生の数値比較は、メモリ安全性を評価する際に誤解を招きうる
1件のコメント
Lobste.rsの意見
素朴な質問かもしれないが、C/C++の多くの問題が未定義動作に由来するなら、なぜ単に定義してしまわないのかと思う
第一に、今では誰も気にしない歴史的な残滓なので、「そのまま定義」できるものがあり、@fanf が言うように作業も進んでいる。たとえば終端されていない文字列リテラルを含むソースファイルは、Cでは実際に未定義動作である。
第二に、定義はできるが性能コストがかかるものがある。代表例は符号付き整数オーバーフローで、単にラップアラウンドすると定義すれば未定義動作ではなくなるが、コンパイラは「絶対に発生しない」という仮定に基づく最適化ができなくなる。委員会にはコンパイラ側の人が多く、彼らはベンチマークにこだわる傾向があるので、簡単には直らないだろう。それでもまったく変化がないわけではなく、たとえば P2723 は、C++で初期化されていない可能性のあるすべてのローカル変数を暗黙に 0 初期化しようと提案している。
第三に、合理的に動作を定義しにくいものがある。よい例が use-after-free だ。Fil-C のような重いランタイム capability システムを全員に強制するか、Rust 風のライフタイム注釈を言語全体に追加しない限り、use-after-free で起こりうる動作の範囲をどう制限できるのか曖昧だ。「use-after-free の場合、その時その場所にあるメモリに触れるか、セグフォルト/中断する」と明記することはできるが、誰の助けにもならない。依然として危険で、CVE も同じように発生し、その後プログラムが何をできて何をできないのかを意味のある形で語れないので、名前が違うだけの未定義動作である。
残念ながら第三のカテゴリの影響が圧倒的に大きいため、一部を「今は単に定義する」ようにしても、全体状況は大きく変わらない
私の知る限り、まだライブラリはほとんど扱い始めていないが、サイズ引数を受け取る関数はヌルポインタに対して合理的に動作するよう変更された。これは、ヌルポインタに 0 を足すことを許可する言語変更と関係していたためである。同様に直せる関数は多いが、
getenv()の変更は POSIX と調整したほうがよさそうだそうした性能上の利得はほぼすべて局所的で、せいぜい微々たるものだ。
rm -rf /を呼び出すが実際には決して呼ばれない関数があり、未定義動作を含む関数ポインタ呼び出しを作ると、コンパイラは技術的にはディスクを消去するその関数を無条件に呼ぶコードを生成しても許される。結局は、仕様設計のまずさと遺産でしかないfor (int ii = 0; ii < something; ii++)は符号付き整数オーバーフローが未定義であることに依存してsomething == INT_MAXの可能性を無視でき、その結果さまざまなループ変換が可能になる。Rust では、同等の機能が安全関数と
unsafe関数に分かれている。安全関数は少し遅いかもしれず、unsafe関数は誤用すると未定義動作を許す。i32::wrapping_add()とi32::unchecked_add()を見るとよい。C でも、ある関数を
unsafeと表示し、特定の領域でunsafe関数の使用を許可する記法を追加するなら、安全な変種を定義し始めることはできるかもしれない。だが、ある時点で C を変える努力、さらに重要なのは C を支配している人たちの考えを変える努力が、目標に対して釣り合わなくなり、むしろ目標により合った言語を探すほうが簡単になるC では、ヒープオブジェクトを指すポインタを
freeに渡したあとでそのオブジェクトにアクセスすると未定義動作になる。CHERIoT ではこの場合にトラップが発生するよう定義しているが、それは私たちがそれを可能にするハードウェアを作ったからこそ可能なことだ。標準は多様なハードウェアをサポートしなければならないので、何として定義すべきかが問題になる。大きく2つのアプローチがある。1つは解放を遅らせ、オブジェクトを指すすべてのポインタが消えるまでオブジェクトは消えないとする方法だ。これはガベージコレクタに似た何かを必要とし、C の多くの用途では耐えがたいオーバーヘッドになる。もう1つは、オブジェクトを指すすべてのポインタの位置を把握でき、それらを無効化できる型システムを定義することだ。Rust が後者のアプローチを採ったため、Rust で木構造ではないデータ構造を実装するには
unsafeか、unsafeを使う標準ライブラリ機能が必要になる。こうしたものは言語設計段階で入れることはできても、後から付け足すのはほぼ不可能だ。境界外アクセスも似ている。CHERI システムでは、オブジェクトまたはサブオブジェクトの境界がポインタの本質的な一部なので、境界外アクセスはトラップになる。ほかのプラットフォームでは、ポインタはアドレスを入れたワードにすぎない。算術演算をしたあとでは元のオブジェクトに再マッピングする方法がないため、境界をどこから得るのかが問題になる。AddressSanitizer のようなツールは境界を別構造に保存し、ポインタ演算に検査を要求するが、メモリと性能のオーバーヘッドが大きいため、本番環境では ASan を有効にした C を使うより Java を使うほうがはるかによく、おそらくコードもより速く書ける
ヌルポインタ逆参照はよく定義された動作だと思っていた
この記事で1つ引っかかる部分がある。
SEGFAULT は panic と同じようなサービス拒否攻撃だ。
どちらも同じエラーのカテゴリに属し、通常メモリ安全性と結びつけて思い浮かべるのは、スタック破壊、データ改ざん、コード改ざんのようなものだ。こうしたことは Rust ではずっと、ずっと難しく、ある程度は C でも難しくできる。
全体としてこの記事は、だいたい C の型システムがひどいという話に見えた。C++ ではこうしたミスを防げるし、C でも GCC の
nonnull属性を使えば関数にNULLを渡すことをコンパイルエラーにまで引き上げられる。個人的には、境界外アクセスのほうがよりよく、代表的な例だったと思う
panic はプログラムに組み込まれた安全性チェックであり、安定して発生し、動作も明確に定義されている。
セグフォルトは不正なメモリ操作をオペレーティングシステムが捕捉したもので、プログラムの仮想メモリマップ内のページ外アドレスに対してのみ発生する。したがって、多くのセグフォルトバグは何らかの任意コード実行へと悪用できる。
両者は正常時には結果が同じように見えるだけで、本質的にはまったく別物だ