1 ポイント 投稿者 GN⁺ 3 시간 전 | 1件のコメント | WhatsAppで共有
  • Rust と C/C++ の CVE 件数をそのまま比較すると、メモリ安全性の脆弱性を「ライブラリの問題」とみなす基準の違いを見落としやすい
  • C/C++ では、誤った API 呼び出しによって UB やセグメンテーションフォルトが起きても、たいていは ユーザーコードの誤用として扱われ、可能性そのものをすべて CVE として登録するわけではない
  • libcurlcurl_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 ベースのネットワーキングライブラリ
  • libcurlcurl_getenv は、複数の OS で環境変数の値を取得するための移植性のある抽象化関数
  • 次の C プログラムは curl_getenvNULL ポインタを渡す
#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 が作られることがある

safeunsafe が責任を可視化する

  • Rust では「この関数をメモリ安全性の観点から正しく使っているか」という問いへの答えが、C/C++ よりも明確
    • 呼び出す関数が unsafe としてマークされていないなら、安全に使えるはず
    • 呼び出す関数が unsafe なら、呼び出し箇所に unsafe ブロックが必要になり、コードレビューやコードベース内で危険箇所が明確になる
  • この区別は、Rust のメモリ安全性を実務上スケーラブルにしている要素
  • ユーザーコードが unsafe を使わず、コンパイラバグもないなら、潜在的なメモリ安全性の原因をユーザーコードの責任とみなすのは難しい
  • ライブラリが unsafe なインターフェースを公開していないなら、ユーザーはそのライブラリをメモリバグを起こす形で使えないはず
  • ライブラリが内部で unsafe を使ってバグを出したとしても、修正はライブラリ内で行われ、ユーザーは再びメモリバグから守られる

生の CVE 件数だけではメモリ安全性を比較しにくい

  • 同じ論理を C に適用するなら、curl_getenvcurl の CVE として数えるべきだが、C には Rust の safeunsafe のような区別がない
  • 実質的にほぼすべての C コードは暗黙に unsafe に近いため、Rust 流の基準をそのまま当てはめるのは難しい
  • C/C++ ライブラリ開発者が安全で堅牢なライブラリを作ったとしても、それを使う無数の C プログラムは API を誤って扱うことで簡単にメモリ安全性の問題を作り出せる
  • この違いは curl に限らず、ほぼすべての C/C++ ライブラリや両言語の標準ライブラリにも当てはまる
  • Rust と C/C++ のコード行あたりの CVE 件数といった 生の数値比較は、メモリ安全性を評価する際に誤解を招きうる

1件のコメント

 
GN⁺ 3 시간 전
Lobste.rsの意見
  • 素朴な質問かもしれないが、C/C++の多くの問題が未定義動作に由来するなら、なぜ単に定義してしまわないのかと思う

    • 標準である動作が未定義になっている理由は、少なくとも3つあると思う。
      第一に、今では誰も気にしない歴史的な残滓なので、「そのまま定義」できるものがあり、@fanf が言うように作業も進んでいる。たとえば終端されていない文字列リテラルを含むソースファイルは、Cでは実際に未定義動作である。
      第二に、定義はできるが性能コストがかかるものがある。代表例は符号付き整数オーバーフローで、単にラップアラウンドすると定義すれば未定義動作ではなくなるが、コンパイラは「絶対に発生しない」という仮定に基づく最適化ができなくなる。委員会にはコンパイラ側の人が多く、彼らはベンチマークにこだわる傾向があるので、簡単には直らないだろう。それでもまったく変化がないわけではなく、たとえば P2723 は、C++で初期化されていない可能性のあるすべてのローカル変数を暗黙に 0 初期化しようと提案している。
      第三に、合理的に動作を定義しにくいものがある。よい例が use-after-free だ。Fil-C のような重いランタイム capability システムを全員に強制するか、Rust 風のライフタイム注釈を言語全体に追加しない限り、use-after-free で起こりうる動作の範囲をどう制限できるのか曖昧だ。「use-after-free の場合、その時その場所にあるメモリに触れるか、セグフォルト/中断する」と明記することはできるが、誰の助けにもならない。依然として危険で、CVE も同じように発生し、その後プログラムが何をできて何をできないのかを意味のある形で語れないので、名前が違うだけの未定義動作である。
      残念ながら第三のカテゴリの影響が圧倒的に大きいため、一部を「今は単に定義する」ようにしても、全体状況は大きく変わらない
    • 今回の改訂ラウンドで、C 委員会は言語の未定義動作を減らしている。https://open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm の「slaying earthly demons」文書を見ればよい。
      私の知る限り、まだライブラリはほとんど扱い始めていないが、サイズ引数を受け取る関数はヌルポインタに対して合理的に動作するよう変更された。これは、ヌルポインタに 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 を使うほうがはるかによく、おそらくコードもより速く書ける
  • ヌルポインタ逆参照はよく定義された動作だと思っていた

    • https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf の4ページ、PDF上では18ページにこう書かれている。
      1. 用語、定義、記号

      3.5.3 未定義動作

      例: 未定義動作の例として、ヌルポインタ逆参照時の動作がある

    • CPU 命令セットの観点ではそうかもしれないが、プログラミング対象はそれではなく C 抽象機械であり、C 抽象機械はこれを未定義動作としている
  • この記事で1つ引っかかる部分がある。
    SEGFAULT は panic と同じようなサービス拒否攻撃だ。
    どちらも同じエラーのカテゴリに属し、通常メモリ安全性と結びつけて思い浮かべるのは、スタック破壊、データ改ざん、コード改ざんのようなものだ。こうしたことは Rust ではずっと、ずっと難しく、ある程度は C でも難しくできる。
    全体としてこの記事は、だいたい C の型システムがひどいという話に見えた。C++ ではこうしたミスを防げるし、C でも GCC の nonnull 属性を使えば関数に NULL を渡すことをコンパイルエラーにまで引き上げられる。
    個人的には、境界外アクセスのほうがよりよく、代表的な例だったと思う

    • 「SEGFAULT は panic と同じようなサービス拒否攻撃」というのは正しくない。
      panic はプログラムに組み込まれた安全性チェックであり、安定して発生し、動作も明確に定義されている。
      セグフォルトは不正なメモリ操作をオペレーティングシステムが捕捉したもので、プログラムの仮想メモリマップ内のページ外アドレスに対してのみ発生する。したがって、多くのセグフォルトバグは何らかの任意コード実行へと悪用できる。
      両者は正常時には結果が同じように見えるだけで、本質的にはまったく別物だ