tmp/下訳/Writing_Insecure_C/Part1

Writing Insecure C, Part 1

C 言語を使うことが安全でないコードの原因だとしばしば非難されています。 これはいついかなる場合でも正当な非難というわけではありません。 現に OpenBSD のようなプロジェクトが、C で安全なコードを書けることを示しています。 C にまつわる問題は、この点では、アセンブリ言語のプログラミングでの問題と同じです。 これらの言語はアーキテキクチャのすべての機能を開放しますが、ほぼそれだけ。安全なコーディングのための道具を書くのに必要な機能はすべて用意されていますが、道具そのものは用意されていないのです。

この連載では、C コード中でエラーの要因となりやすいもの、そしてその回避法を検討します。

エラー判定

多くの言語が今日、何かしらの例外処理機構を含んでいます。 例外は一般的に、よい発想ではありません。 例外はプログラムの制御の流れをわかりにくくし、しかも構造化プログラミング隆盛以前の GOTO まみれのプログラムとほとんど同じ不利益を蒙ることになります。 とはいえ例外には、明らかな利点がひとつあります。 それを気づかずに済ませることができないことです。

Java のコードでは特に、エラーを捨てる以外なにもしない try ... catch 節がしばしば見受けられますが、このような場合でさえ例外機構は、プログラマーに対してエラー条件をきちんと処理していないことを意識させる、という役目を果たしてきたのです。

C においては、何かしらのエラー状況があるとき、ほとんどの関数が無効な値を返します。 これは通常、以下の2通りのうち一方のやり方をとります。 多くの関数はエラーコード、または成功値のゼロを返します。 ポインタを返す関数は成功時に有効なポインタ、失敗時にヌルポインタを返します。 ゼロで成功を表す関数があり、さらに失敗を表す関数が別にあるわけですから、これはいささか混乱した状況と言えるでしょう。

ヌルポインタを返すことはおおむねOKです。これはそう簡単に見逃せるものではありません。 参照先を使おうとすれば即セグメント違反となるからです。 この方法が本当に危険なのはほとんど失敗しない関数でだけです。 そうでなければテスト中にクラッシュして修正できます。

ほとんど失敗しない関数の代表例は malloc() 、そしてそれに類する calloc() などの関数です。 C の仕様によれば、要求を満たすだけのメモリがない場合、malloc は NULL を返すことになっています。 Linux はこのルールに厳密には従っていません。 Linux が NULL を返すのは、要求されたぶんの仮想アドレス空間が確保できない場合ですが、実際のメモリが不足している場合も Linux はやはりアドレス領域を確保してしまい――そして、実際にそのメモリを使おうとすると失敗するのです。 仮にあなたの C コンパイラが規格に厳密に従っているものだとしても、それでも、malloc の戻り値をチェックする価値はあるのです。

ほとんどの状況では、malloc が失敗した場合、まともな手は何も打てません。エラーリカバリコードといえどもふつうはメモリ確保が必要です。 このためのメモリをプログラム開始時に確保してみるという手があります。 (実際のメモリ確保がずっと後に持ち越されないように、確保したメモリにアクセスしておくことを忘れずに)

あるいは、以下のようなマクロを使うという手もあります。

#define MALLOC(x,y) do { y = malloc(x); if (!y) abort(1); } while(0)

このマクロはそのつどメモリ確保を調べ、失敗した場合は abort します。 abort の呼び出しを自前のエラー処理コードに変えることはできますが、気をつけてください。 OpenSSH の脆弱性のうちでごく最近のものひとつは、プログラムが未定義状態にあるもとでのエラーリカバリコードの実行によるものでした。 たいていの場合、プログラムを終了することが、もっとも安全な対処なのです。

malloc 以外の関数の戻り値をチェックすることも、おなじく重要です。

初期値

C でグローバルな値を宣言した場合、暗黙のうちにゼロに初期化されます。このテクニックはとても好都合です――コンパイラの作者が C 言語仕様のそれに関する部分を読んでいるはずだと言い切れるならさらに好都合でしょう。 もし(C 言語以外の)別のソースからメモリを取得したとすると、しかしながら、このルールは維持されません。

ローカルな値を宣言するとき、コンパイラはスタックの先頭を表すレジスタを単に進めることでこれに当てます。 変数の初期値は、スタックメモリのその場所を使っていた最後の関数が設定しうる、いかなる値でもありえます。

ここで起こりうる問題がふたつあります。 ひとつ目は、初期化されていない値を使った際の挙動が予想できないことです。 ふたつ目は、ひきおこされる挙動の中で予想しうるものがあることです。 一般的に、初期化されていない値を使うとプログラムは未定義状態 (undefined state) におちいります。 最悪の場合は情報漏れを引き起こします。 暗号化ルーチンの呼出し後に初期化しないまま値を使ってしまうようなコードをたまたま書いてしまった場合、たとえば、その未初期化変数から取り出した「任意の」値は暗号鍵の一部かもしれないのです。 あなたのコードがスタックから情報を読み出すようなスクリプトを走らせたら、それは深刻なセキュリティホールになりうるのです。

未初期化変数で若干の問題が発生することがあります。 私が見たなかでいちばんひどかったもののひとつは、コードがまずこんな具合で始まりました。

int a = 42;

それから、何か別の条件に応じて初期化を行うことにしたので、これをコピーして if 文の中に貼り付けます。

if (何かの条件) {
   int a = 42;
}

おっと、これ以降でも使うんなら if 文の外で定義しないとね。 それからデフォルト値もちゃんとつけないと。

int a = 0;
if (何かの条件)
{
   int a = 42;
}

さてコードができたのでコンパイルし、実行します。 ほとんどの場合(条件が満たされない場合)、これは正しく動きます。 しかしブレース内部( { ... } )のコードは基本的に何もしないコードです。 a という新しい変数を定義し、42 を代入します。 制御がブロックから抜けると、ただちにこの変数はスコープ外となり、値が 0 のままの、以前の a が戻ってきます。

この種のものでもっとありがちなのは、以下のような初期化部分の書き損じが原因となって発生するものです。

int value = value + 12;

これはこういうつもりだったわけです。

int value = othervalue + 12;

コンパイラが int value を解析(parse)した時点で、この変数は有効、かつスコープ内となっています。 変数の値を読み出し、12 を足し、代入しなおすことができるのです。 残念ながら読み出し時の値は不定です。 つまり値を不定値に設定してしまっているのです。 じっくり読んでいなかったら初期化済みだと思うでしょうが、初期化はしていないのです。 最適化を行うコンパイラなら + 12 を取り除いてしまいそうですが、そもそも不定値プラス 12 イコール不定値なのですから、これは以下と同じことになります。

int value;

もしもコンパイラが、初期化されないまま使われているものがある、と警告を出すのであれば、この問題を把握できるはずです。 残念ながらこの警告を切っておくことがほぼ標準となっていますが、これは出力パラメータを指定する方法が C にはないためです。 したがって、以下のようなことが比較的ありふれたこととなっています。

int a;
call_some_function(&a);

a を使って関数からの出力を受け取るならば、これをエラーとするわけにはいきません。 そうでない場合、関数は初期化されていない a の値を読み出すことになります。 これが a の正しい使い方かどうか、C コンパイラは知る手段を持っていません。そんなわけで、初期化しないまま使われていることについてコンパイラに文句を言われるのもやむを得ないところです。

次回予告

ここまで私たちは、新米 C プログラマが C 言語で抱える問題の原因となるハマりどころをいくつか見てきました。 パート 2 では、C が整数とメモリをどのように扱うか、そしてそれがどのような問題を引き起こすのかを見ていくことにします。