tmp/下訳/Writing_Insecure_C/Part3

Writing Insecure C, Part 3

本連載の Part1 と Part2 では、C コード中にセキュリティホールを書いてしまう基本的な流れをいくつか指摘し、その回避法を検討しました。 今回は、C コード中でもっともよくセキュリティの問題の発生源となるもの――バッファ処理――とセキュリティ問題を理解し、回避するためのやや突っ込んだテクニックを検討することで記事全体を総括します。

バッファと文字列

C の文字列は問題の恒常的な発生源です。 C 言語が生み出されると、文字列の実装法としてどちらがベストかということをかけて、ふたつの方法が争うようになりました――それらの方法を普及させたふたつの言語にちなんで、いまでは C 文字列と Pascal 文字列といわれています。 Lisp のような言語が用いているのは3番目の実装で、文字列は文字のリンクドリストになっています(Erlang もこのモデルを使っています)。

Lisp 風の文字列には明らかなに不利な点があります。 1文字1文字に対して、文字を格納するために 1 バイト、そして次の文字アドレスを格納するために 4 もしくは 8 バイト――つまり 1 バイトのデータを格納するに最大 9 バイトが必要なのです。 この構造は理想的ではありませんが、文字列の分割や連結が非常に簡単になります。

複数文字を格納できる配列のリンクドリストを扱えるように文字列を改良しても、(改良以前のものと)容易に混ぜ合わせることができます。

これらのモデルはすべて C で実装可能です(そして、実装されてきました)が、標準の文字列関数はバイト配列向けのものとなっているのです。

大部分の「伝統的」な文字列関数は、基本的に、安全な利用は不可能です。 (そういうわけで、OpenBSD のリンカは、これらの関数を使ったとき、便利なことに警告を出してくれます) まずい関数の代表例は strcat() で、これは C 文字列のポインタをふたつ引数にとります。 この関数は最初の文字列内をスキャンしてヌル終端文字 (null terminator) を探します。その位置から、2番目の文字列のバイト列を、2番目の文字列が終端に達するまで書き込みます。 最初の文字列中に、2番目の文字列を格納(結合)できるだけの十分な空き容量があることを、呼び出し元が確認しておかなければなりません。

新しい関数 strncat() は、これをより安全化するために導入されました。 この関数は最初の文字列の許容量を3番目の引数とします。 この関数は最初の文字列をはみ出さないことを保証していますが、新たな問題を生んでいます。 この関数は、戻り値として(結合後の)新しい文字列を返すため、結果が途中で切り詰められたかどうか簡単に調べられないのです。 たとえばパスワード的な語句を連結する場合は大問題です。

OpenBSD は strlcat を導入しており、これは strncat と似ていますが、両方の文字列(の長さ)の合計を戻り値とします。 関数の戻り値が3番目の引数よりも大きいなら、結果が切り詰められています。 この関数は BSD 系(Darwin/OS X を含む)の libc に入っていますが glibc には入っていません。glibc のメンテナによると「役に立たない BSD のクソ (inefficient BSD crap)」だからだそうです。 さいわい BSD のライセンスでは、BSD の libc から自分のコード内にこの関数をコピーしても問題ありません。

C での文字列にまつわる問題はかなりの割合で、文字列がたんに配列であり、そして配列が境界チェックされないということが原因となっています。 文字列に起こる大部分の問題は、どのようなバッファにも起こります。

C99 で最悪なもののひとつに、可変長配列 (variable-length array) のデザインがあります。 これはスタック上に、動的サイズの小規模配列を確保します。 alloca() を使ってこれを行ってもいいのですが、alloca() 実装の出来はプラットフォームによって差があります。 以下のものは、おおまかに言って、同じです。

int *a = alloca(sizeof(int) * n);
int a[n];

違うのは、スタックを伸ばして n 個の整数を入れるだけの空間が存在しなかった場合にどうなるか、ということです。 最初のほうは NULL を設定します。 頭にきますが、条件は調べられますし、配列の先頭にアクセスするだけでクラッシュしてくれるのですからデバッグは楽なものです。 2番目のほうは、もしスタック領域が十分でなかったら、指すことになるのは…どこか、です。 正確に言えば、それがどこなのかは完全に実装依存です。 したがって、C99 の可変長配列を使うと、スタックオーバーフローのチェックは不可能になります。 ほとんどの場合これは問題になりません。 小規模の確保ならほぼ間違いなく動作しますが、攻撃者が n のサイズを変えられるなら、配列がありもしないところを指して一巻の終わり、ということもありえます。

スタックが伝統的な方法で実装されていると、この種のことが深刻な問題となります。 一般的に、スタックの「底」はプロセスの最高位メモリで、スタックは低位方向に伸びていきます。 スタック上に配列を作り、その終端を突き抜けてしまった場合、もう呼び出し元のスタックフレームを上書きしてしまっています。 なお悪いことにリターンアドレスも書き換えてしまっています。 スタック上の文字列に strcat() などを使った場合、リターンアドレスの書き換えはきわめてたやすく、攻撃者に対して関数リターン後の実行制御を許すことになるのです。

この問題は近年のオペレーティングシステムでは改善傾向にあります(リターンアドレスが怪しいものでないか調べ、不当な場合はプロセスを kill する)が、回避すべきものであることに変わりはありません。 クラッシュは外部からの悪用(remote exploit)よりもましですが、確かなコードには遠く及びません。

どうにもうまくいかないとき

どれだけ必死に取り組んだとしても、コードにはバグが入ってしまうものです。 OpenBSD の人たちが言うには、バグと悪用 (exploit) の違いはただ攻撃者の知性だけだそうですが、これはかなりの程度まで真実です。

安全なプログラムを組むことの鍵は、自分のプログラムのすべての個所で、(いま書いている)以外の部分はすべてタコな奴によって書かれたものだと考えることです。 渡されたポインタが NULL でないことをチェックしなければなりません。(そうはいっても、悲しいことに、そのポインタがしかるべきメモリを指しているのか調べる仕組みを、C は何も提供していません) ポインタ以外の値はすべて、想定範囲内に収まっているか調べなければなりません。 その挙句、そこまでやっても、プロセスの一箇所にあるひとつのバグが、プロセス中にあるそれ以外すべてのデータを――そして多くのオペレーティングシステムでは、コードさえも――蹂躙してしまうことがあるのです。

しかしながらここで注目すべきは、影響を受けるのは現行プロセスだけである、ということです。 プログラムが複数のプロセスを使ってはいけない理由は何もありません。 この場合、安全性と速度が一挙両得になります。複数のプロセスを使うことでマルチプロセッサの利点をたやすく享受できるようになるのです。

プログラムを別個のプロセスに分離することで、単体のバグが起こしうる被害の量を抑えることができます。 非常によくある例は、多くのサーバプログラム中にある権限分離 (privilege-separation) コードでのものです。 サーバの多くは root での実行、もしくは昇格権限 (elevated privilege) 状態での実行が必要です。 このような機能が必要なのは、権限が必要なポートへの bind、そしてそれ以外の、複数のユーザが所持するデータへのアクセスや別ユーザでのプログラム実行といった仕事を行うためです。

この種のプロセスを使う上でのよい方針は、権限を要する操作を行うためのコードを別プロセス中にくくり出してしまうことです。 たとえばほかのユーザのメールボックスへの書き込みが必要なら、メールボックスを開いてメッセージを書き込む、ということだけを root で行うプロセスがあればいいのです。 このようにしたプロセスは引数をチェックして書き込みを行うだけで、他はなにもしません。 コードはシンプルになりますから、バグを取り除くのは非常に簡単です。

もうひとつの使い道は、見せたくない情報を含んだコードを隔離することです。 大部分のプログラムは、たとえば暗号鍵やパスワードを知る必要がありません。 これらの情報を別プロセスに分離してメインプロセスからのアクセスに制限をかければ、こういったデータを攻撃者がさらに手に入れにくくなります。

権限の降格

権限の分離が理想ですが、単に権限を落とすだけでも多くのことができます。 UNIX は setuid() 系のシステムコールを提供しており、root 権限のプロセスを root 以外のユーザとして動かすことができます。

Web サーバは root で実行される必要があります。 これはポート 80 への bind、そして全ユーザの public_html ディレクトリにアクセスする必要があるためです。 しかしいったんポート 80 に bind すれば、もう root で動作する必要はなくなり、root 権限は捨ててもかまいません。 とはいえ各ユーザの public_html ディレクトリにアクセスするための仕組みはやはり必要です。 ひとつの解決法は、各ユーザが、各自のファイルを Web サーバグループでアクセスできるように(各ユーザ自身で設定)することです。 もうひとつは各ユーザ向けに子プロセスを fork() して、子プロセスをそのユーザで動かし、そのユーザのディレクトリ内にあるファイルをアクセスさせるというものです。

chroot() システムコールを使い、(そのプロセスから見た)ルートディレクトリを指定したディレクトリに設定することで、セキュリティを若干向上させることができます。 このディレクトリ以外の場所にあるファイルはすべて見えなくなりますが、すでにオープンされているファイルにはアクセスできてしまいます。 この事実は重要です。 chroot の檻 (jail) の外にある共有ライブラリはおろか実行ファイルや設定ファイルなども掴んでおけるからです。

root ユーザは、ハードディスク用のデバイスノードを作って chroot 内部に mount してしまえば(あるいは直接アクセスしてしまっても)簡単に chroot の檻から抜け出せます。 したがって、chroot に入ったらすぐに root 権限を捨てることが重要です。

アプリケーションの形にするなら、(システムコールの)chroot() を使うほうが簡単です。 chroot コマンドを使ってプロセスを chroot 状態で動かすこともできますが、このやり方にはふたつの問題があります。 まずはじめに、プロセス実行以前に chroot を呼び出すので、プログラムそのものと必要なライブラリすべてが檻の中になければならないことです。 次に、root で実行しなくてはならないため、権限を下げることのできる何かしらの道具が chroot(の檻の)内部に必要だということです。 よくある解決法は chroot の内部に su コマンドを置いておくことです。 chroot 内にあまりたくさんのコードを置いてしまうと、内部も外部もあったもんじゃないという感じになりそうです。

カーネルの空回り

共有メモリの同時使用が、プログラマにとってマルチコアプロセッサの強みを生かすためのよいモデルだと考える人がまだいるようです。 これはコードの合理的な説明を非常に難しくし、バグ(とセキュリティホール)を呼び込みます。 最も顕著な例が、少し前に、大部分のシステムコール監視フレームワーク (system-call interception framework) 中で見つかりました。 それらはすべて、傾向としておおむね以下のような動作になっています。

  1. ユーザ空間のプロセスがシステムコールを発行
  2. フレームワークが引数を確認し、システムコールが持つべき権限のレベルを(あるいはシステムコールを続けるかを)決定
  3. カーネルがシステムコールを受ける

残念ながらここには、見えにくい不備があります。 多くのシステムコールが引数としてポインタを受けます。 カーネルは一般的に、各プロセスのアドレス空間にマッピングされており(ただし、特権モードでのみアクセス可能になっています)、このためカーネル内のシステムコールハンドラはポインタの参照先に簡単に(コピーせずに)アクセスできます。 カーネルが完全に分離されたアドレス空間にあり、参照先に直接アクセスできないプラットフォームであっても、一般的には対象プロセスの該当するアドレス空間を簡単にマッピングできます。 ポインタつきのシステムコールを発行した場合、以下のステップ 2a をとることがあります。

2a. ポインタ引数が指した先のデータを、別のスレッドが書き換える。

この場合、システムコールハンドラはポインタが検証済みだとみなして作業を続けます――しかし、もう検証済みではないのですが。

ローカルアドレスの情報をポインタ引数にとる bind() システムコールがよくある例でしょう。 フレームワークは、権限不要のポートに bind を要求しているかまず調べ、そうであるならばこれを許可します。 別のスレッドでポート番号を権限の必要なポートに変更し、システムコールはそのまま続行されます。 この問題は、権限昇格に関する多くの脆弱性をもたらします。

共有メモリを使っているなら、権限分離を行っているユーザ空間コード内でも同じ問題がありうるのです。 もっともシンプルな解決法は、処理する前にメモリ範囲全体を特権プロセス内に必ずコピーすることです。 データ量が少なければこのテクニックは良好ですが、大きなデータに対しては理想的ではありません。 残念ながら、この問題についての名案は、共有メモリを使うなというほかには存在せず、この方法は一般的により遅くなります。 パイプのようなものでさえデータが共有バッファへコピーされること、そして共有バッファからコピーされることを必要とするのです。 パイプと共有メモリバッファの中間的なもの、つまり、バッファは受け手のアドレス空間にあるがカーネル経由でしか書き込めず、さらに受け手がその空間が使えることを明示したときだけ(アクセス)許可を与えるようなものが、将来のオペレーティングシステムにつけばいいのかもしれません。 この機能が今後短期間のうちに現れるようなことはなさそうですが。

結論

C で安全なコードを書くことは難しいですが、不可能ではありません。 これが可能であることを、OpenBSD のようなシステムのセキュリティ履歴が示しています。 この言語は、安全なコードを簡単に書けるような方向からは外れていますが、見方によってはこの事実が助けになります。 問題を避けるためにプログラマが頼るべきは、言語仕様よりも、よいコードなのです。

あらゆる言語において、安全なコードを書くための最もよい方法は、とにかく小さなコードを書くことです。 よく使われるパターンを関数やマクロに切り出す (factoring out) ことで、バグを見つけた場合、パターンの実体を見つけるのにコード全体を検索する必要がなくなります。

もっとも肝心なのは、信用できるソースからのものであっても(すでに陥落していて、権限昇格攻撃を行っていないとは言い切れないのです)とにかくすべての入力を検査すること、そして中断 (abort) や未定義状態の継続よりもむしろクラッシュを選べ、ということです。