プログラミング言語がセキュリティに関してやるべきこと#1

  • (by K, 2020.02.13)
  • (タイトルに#1ってついていますが、#2を作るかどうかは未定です。)

(0) はじめに

  • 私は、プログラミング言語を作りたい。・・・私は、プログラミング言語によって、セキュリティ上の問題の解決に寄与したい。
  • プログラミング言語が、セキュリティに対してできることっていったいなんだろうか。
  • これに対して、まず私は、「この言語を使っている限り、セキュリティ問題で悩まされることはもうないよ!」と言えるような言語を作ることが、ゴールなのではないかと考えた。
  • これは理想であって、もちろんすぐに実現できるわけではないけれど、まずはそこを目指すことにする。
  • さてこれを実現するためには、大きく分けて2つの設計思想があると思われる。
  • [A] 危ない結果につながるかもしれない命令を削除する。これなら何をどう間違えてもセキュリティホールを作ることはない。間違いなく安全である。
  • [B] 危ない結果につながるかもしれない命令は残すが、本当に「やらかした」場合にエラーを出して止まる。もし確実に検出できるのであれば、これも十分に安全である。
  • これはたとえば、ポインタという言語上の機能について考えた時に、[A]のアプローチなら、ポインタは事故の元なので、そんなものはなくしてしまう。
  • いっぽうで、[B]のアプローチなら、ヌルポインタアクセスとか、malloc範囲を超えたポインタアクセスをしてしまう場合にエラーを出すとか、そういう設計になる。
  • 私は、この[A]のアプローチには基本的に不満である。なぜなら、そんな風に機能を削除して行けば、まあ確かに安全にはなるかもしれないが、できることも少なくなってしまうからだ。
  • それでは結局、やりたいことができなくなってその言語を使う理由そのものが怪しくなる。だからセキュリティ対策のために機能を削るというアプローチは正しくないと思う。
  • しかし、できることが減らなければいいので、ポインタに変わる代替機能があり、しかもそれはポインタと同じくらいに便利でありさえすれば、ポインタが無くなることは何の問題もない。

(1) 言語で対処可能なセキュリティ問題の一覧

  • 以下に、言語側で支援が可能なセキュリティ問題を示す。これに対処できているとかいないとかを星取表の形で表現できたら、すごくわかりやすいのではないかと思う。
  • それぞれの項目の詳細な説明は、後述する。
  • 記号の意味
    • ◎: 実行前に確実に検出できる(いわゆるコンパイル時エラー)
    • ○: 実行時に確実に検出できる
    • △: 現在は対応していないが、将来的には対応予定
    • ×: 対応していないし、今のところは対応予定もない
    • ?: 検討中
    • -: 前提となる機能がない
番号ES-BASIC 2020年1月末時点ES-C 2020年1月末時点
01-01配列やポインタアクセスのレンジチェック
01-02不適切なポインタの減算
01-03ポインタアクセス時のアライメントチェック
01-04free後の不適切なアクセスをエラーにできる(use-after-free 脆弱性)
01-05二重freeをエラーにできる(double-free 脆弱性)
01-06メモリリークを発見できる(もしくはGCがあってリークがおきない)
01-07整数からポインタへの変換の禁止(特別な構文でのみ許可するなど)
02-01数値演算のオーバーフローがあった時にエラーにできる(かつ必要に応じてこれを部分的に抑制もできる)
02-02変数値に制約を持たせて、違反した時にエラーにできる○*1△*1
03-01エラー時に、どこでエラーが起きたのか、(最低でも)行単位で指摘できる
03-02どの行を何回実行したのかを報告できる
03-03エラー時にスタックトレースを表示できる
04-01通常実行モードと高速実行モードがあり、それは実行時にユーザが選べる
04-02実行を一時的に中断し、変数などを自由に確認したのちに、再開できる(そういう命令がある or 複数のブレークポイントが設定できる)
04-03キー入力などのアクションによっても、一時的に中断し、変数などを自由に確認したのちに、再開できる
04-04エラー時に、変数値などを自由に確認できる
04-05中断した状態をファイルに保存したり、そこから再開したりできる×
05-01メモリを確保した時に、前のプログラムの実行結果などを不用意に渡さない×
06-01ファジングやテストを支援する機能がある×
  • (*1) 変数宣言時に制約を書くことはできないが、デバッグトラップ関数を書くことで、同様のことは実現可能。

(2) 詳細説明

  • [01-01] 配列やポインタアクセスのレンジチェック
    • 配列の添え字の指定が間違っていたとか、ポインタでmalloc域を超えたアクセスをしているとか、そういうエラーを検出する。 int a[10], b[10];などとしている状況で、int *p = &a[9]; のとき、p[1]はb[0]と同じところを指しているかもしれないが、基本的にはそういうアクセスも許すべきではない。意図してpを使ってbにアクセスしたのか、間違ってはみ出しただけなのか、分からないからである。
    • 当然のことながら、「ライブラリ関数にやらせれば、レンジチェックを回避したアクセスができてしまう」みたいなのはアウトである。
  • [01-02] 不適切なポインタの減算
    • 2つのポインタの差を計算すると、それが同一の配列に属していれば、その間の要素の数を返すことになっているが、そうではない場合は、たとえ同じ型のポインタであってもエラーにすべきである。
  • [01-03] ポインタアクセス時のアライメントチェック
    • int *p;があって、pはmalloc域の中を指していたとしても、何かのバグによってpが奇数番地になっていて、意味不明な値を書いたり読んだりしてしまうかもしれない。 そういうケースはエラーにできたほうがよい。
  • [01-04] free後の不適切なアクセスをエラーにできる
    • 当然だが、freeした領域にアクセスすることは許されない。それを検出できなければいけない。
    • 即座に思い付く安易な方法としては free(p); したら、自動的に p = 0; にしてしまえばいいと思うかもしれない。しかしこんな方法ではほとんど解決しない。なぜなら別の変数にポインタが残っているかもしれないからだ。
    • だからこのアプローチでは解決とはみなさない。
    • ではどうするか。すべての変数、すべての構造体(オブジェクト)を検査して、該当域を指しているものを探しつくして0にしていくべきなのか?さすがにそれは重いだろう。だからこれも現実的ではない。
    • 私ならこれを以下のアルゴリズムで解決する。
      • まずポインタは従来のメモリアドレスのほかに、管理用ハンドルとリビジョン番号も持つ。この管理用ハンドルからは、アクセス可能範囲とリビジョン番号を持つ構造体を得ることができる。
      • 実際のメモリアクセス際しては、まず管理用ハンドルを使ってアクセス可能範囲を取得し、適合しているのかを確認する。
      • 次に、取得したリビジョンとポインタ内のリビジョンが一致していることも確認する。
      • どちらも合格できていれば、その段階でメモリアクセスをする。
      • メモリがfreeされたとき、管理用ハンドルの先の構造体のリビジョン番号を更新する。これで、古いポインタを使ってアクセスしようとしたときは、全部エラー検出できるようになる。
      • リビジョン番号さえ更新されれば、その管理用ハンドルを他の領域を指し示すために再利用しても全く問題はない。
    • 上記のアルゴリズムであれば、ポインタの減算は、管理用ハンドルとリビジョンが一致するかを比較してからポインタの引き算をすればいいだけになる。
  • [01-05] 二重freeをエラーにできる
    • 同じ領域に対して、freeを二度やってしまうのはもちろんエラーである(freeしたあとにmallocしてたまたま同じ領域を返されて、そこをfreeしたい場合を除く)。
  • [01-06] メモリリークを発見できる
    • freeのし忘れは、やはり重大なバグの原因になりえるので、検出手段を提供するべきである。
    • 循環参照があっても、見逃さないような機能を是非サポートしてほしい。
  • [01-07] 整数からポインタへの変換の禁止
    • この必要性がよくわからないかもしれない。つまりはこういうことである。
    • ある関数があったとして、そこにポインタ引数がなければ、メモリの任意の場所を勝手に読み書きするようなことはない、ということである。
    • ポインタ引数があったとしても、その影響範囲はそのポインタでアクセス可能な範囲に限定される、と。
    • これは実にあたりまえのことだけど、もし、整数からポインタへの変換が可能だと、int *p = (int *) 0x1234567; みたいにして、 p[123] = 4; などとすることで、関数は与えられもしない領域のメモリをいつでも破壊しうるということになってしまう。
    • こんな可能性まで気にしなければいけないとしたら、とてもじゃないけどデバッグできない。
  • [02-01] 数値演算のオーバーフローがあった時にエラーにできる
    • 何か適当な演算をしていて、オーバーフローに気付けないと、時には深刻なことになる。
    • この計算値は絶対に正になる。なぜなら正の値しか加算していないからだ。と思って負の可能性を無視してコードを書いていたのに、オーバーフローによって負になってしまったら、当然だけど予期せぬ結果を招くだろう。
    • もちろんここに示したそれ以外のセキュリティ機能によって、大きな被害が出る前にエラー停止できるかもしれないが、しかしそれでも「え?なんでこの変数が負になっているの?いつのまに???」となると原因究明は手間にになり、やはりオーバーフローした時点ですぐに指摘できればそのほうがずっとありがたい。
    • (いやでも、自分の預金残高が、ものすごいプラスからものすごいマイナスへ、オーバーフローによって急変してしまったら、ポインタとかがおかしくなかったとしても、重大な障害だよね?!)
  • [02-02] 変数値に制約を持たせて、違反した時にエラーにできる
    • この変数は7の倍数しかとらないとか、この変数は最大でも99にしからないなど、そういう制約を明らかにしておくことで、これに違反した時にすぐに気付けるようにする仕組みがあれば、きっと役立つことがあるだろう。
    • しかし一方で、そういう記述はやや煩雑になりすぎるきらいもあり、言語仕様としてきれいにまとめるのは、結構な難易度がある(とKは思っている)。
  • [03-01] エラー時に、どこでエラーが起きたのか、(最低でも)行単位で指摘できる
    • 単にエラーがあったと分かるだけでは、ほとんどデバッグの役には立たない。一番大事なのはどこでエラーが起きたのか、である。これさえわかればエラー種別が不十分であっても文脈から容易に想像できる(できないこともあるが)。
    • もちろんエラー種別も報告してもらえるのならそれに越したことはない。
  • [03-02] どの行を何回実行したのかを報告できる
    • 究極的には、どの行をどの順番で実行したかのログがあれば最強ではあるが、それは出力するのも集計するのもかなりのコストになりがちである。それなら、行ごとの実行回数が分かるだけでもかなり便利である。
    • これがあれば、今回の実行でどのくらいの部分が実行テストされたのか確認できる。コンパイル時にすべてのエラーチェックが完全にできるのであれば、カバレッジなどどうでもよいのだが、実行時にチェックする言語の場合は、通っていないところについては十分なチェックがされていないので、それをプログラマに確認させるためにも、この機能は役に立つ。
  • [03-03] エラー時にスタックトレースを表示できる
    • コールスタックがあれば、関数の奥深くでエラーが起きた際に、状況を把握しやすい。
  • [04-01] 通常実行モードと高速実行モードがあり、それは実行時にユーザが選べる
    • 高速実行モードというのは、要するにプログラムを信用してこれらのエラーチェックを省略するということである。そうすればセキュリティチェックによる速度低下を心配することなしに実行できるだろう。
    • むしろ高速モードがないと、「このチェックをやったら遅くなりそうだから」と心配するあまり、結局チェック機能を言語開発者が実装しないことになりかねない。
    • 言語利用者は、遅くなったら困るときと、遅くてもいいからちゃんとチェックしてほしいときの両方がありうるので、どちらのニーズも満たせるべきなのに。
    • また、コードを信用するかどうかは、基本的には実行者の意志であるべきである。プログラム開発者は、「よしもうこれで十分」だと思っているかもしれないが、プログラムを利用するユーザはまだ心配かもしれない。
    • 別のケースとしては、実行環境が貧弱で、高速モードにしないと満足に実行できない状況かもしれない。
    • コンパイラなどでは、リリースモードとデバッグモードがあるものがあるが、それはコンパイル速度が違うだけで、エラーチェックが厳密になったりするわけではないものもあり、そういうものはこの要件を満たしたとは言えない。
    • ユーザが選べることに意義があるので、言語利用者が自分の作品を公開するときは、ソースコードを公開するか(そうすれば、ユーザは好きなモードで実行できるから)、もしくは両方のモードでのコンパイル済みバイナリをセットで提供するべきだろう。
  • [04-02] 実行を一時的に中断し、変数などを自由に確認したのちに、再開できる(そういう命令がある or 複数のブレークポイントが設定できる)
    • これは言語側の機能で実現してもいいし、デバッガなどとの連携で達成してもよい。
    • (そのためには十分なデバッグ情報をデバッガ側に渡しておくこと。)
  • [04-03] キー入力などのアクションによっても、一時的に中断し、変数などを自由に確認したのちに、再開できる
    • これも言語側の機能で実現してもいいし、デバッガなどとの連携で達成してもよい。
  • [04-04] エラー時に、変数値などを自由に確認できる
    • これも言語側の機能で実現してもいいし、デバッガなどとの連携で達成してもよい。
  • [04-05] 中断した状態をファイルに保存したり、そこから再開したりできる
    • この機能があれば、怪しくなる直前で保存して、そこから何度も効率よく試すことができる。
    • これも言語側の機能で実現してもいいし、OSなどとの連携で達成してもよい。
  • [05-01] メモリを確保した時に、前のプログラムの実行結果などを不用意に渡さない
    • ただし、後続のプログラムが、「ファイル保存しない」や「通信(送信)しない」や「変数の永続的な値を残さない」タイプであれば、この限りではない。
    • safe-runみたいな命令があって、それで実行すると、メモリのfree時にクリアしてから返すようにするのがいいかもしれない。それなら簡単だ。しかしそれだけで十分なのかというと不安がある。ファイルや変数なども(安全な場所にセーブしたのちに)クリアできた方がいいのか??
    • 何でもかんでもクリアするだけでよければ簡単なのだが、そういう仕様だとデバッグがやりくくなるような気がして心配だ・・・。
  • [06-01] ファジングやテストを支援する機能がある
    • この機能は結局何をしたいのかというと、「この入力値の組み合わせだとプログラムはセキュリティエラーを起こすと思うんだけど、想定の範囲内ですか?」と(半自動的に)教えてくれるものである。これは便利だ!
    • ここではES-Cがどういう機能を予定しているのか紹介したいと思う。そうすれば、どういうレベルのものを期待しているのかわかるだろうから。
    • ES-Cでは、fuzz(0,100)みたいなコマンドがある。これはRUN命令に類するものではあるけど、内部ではファジングモードがONになって実行される。0,100というのはファジングの内部番号で、0~100の、合計101回がトライされるということになる。
    • つまりRUNが101回、自動で行われると思えばよい。
    • ES-Cはファジングモードで実行しているときは、入力命令や乱数生成命令に差し掛かったときに、入力値を「ファジングトラップ関数」に問い合わせるようになる。この時、ファジングの内部番号や、実行開始からの何回目の問い合わせか、何の命令による入力か、何行目の入力命令によるものか、などが通知されるので、それに応じて、適当な入力値を生成する。乱数でもいいし、ハッシュ値とかでもいい。この場面ではこのうちのどれかしか入力を想定しないでよい、みたいな仕様で作っている場合は、その制約を満たすような入力値を生成すればよい。
    • デフォルトのデバッグトラップ関数はランダム関数なのだが、もっと的を絞ってファジングしたいときは、ユーザがファジングトラップ関数を書くこともできる。
    • 重たい関数を含む場合、ファジングモードではこの関数をスキップして、結果をいくつにするかファジングトラップ関数に問い合わせることもできる。
    • ファジング実行中は、画面出力やファイル出力を完全に抑制することもできる(結局大事なのは、セキュリティエラーを起こすか、正常終了できるか、そのどちらなのかを突き止めることだけだと思うので・・・)。
    • セキュリティエラーを起こした場合、ファジングの内部番号を表示して、どこでどんなエラーがあったのかを報告し、fuzz命令は終了する(それ以上の試行はしない、したかったらユーザがまたfuzz命令を次の番号から実行すればいい)。
    • 一つの試行に延々と時間をかけてもしょうがないので、ある程度時間がたったら(=問い合わせ回数上限に達したら)、タイムアウトとして正常終了扱いにすることもできる。

トップ   編集 凍結 差分 バックアップ 添付 複製 名前変更 リロード   新規 一覧 単語検索 最終更新   ヘルプ   最終更新のRSS
Last-modified: 2020-03-11 (水) 19:07:19 (1500d)