tmp/下訳/Writing_Insecure_C/Part2

Writing Insecure C, Part 2

本連載の Part 1 では、新米 C プログラマが C 言語でひきおこす問題のなかで、エラーチェックと変数初期化にまつわるものを検討しました。 今週はもうすこし掘り下げて、C がどのように整数値――予期せぬセキュリティホールの原因となる――を扱うのかを確かめるとともに、よりエラーの元になりにくいメモリ管理ルーチンを C でどう作ればいいかを検討します。

整数の問題

高級言語を使い慣れている人ならば、数値に対する C のサポートが悲しいほど粗末に思えることでしょう。 アセンブリ言語から移ってきたプログラマにとってもこれは真実なのですが、それはいまの CPU が備えている条件コードを C がプログラマにまったく解放していないためです。

高級言語では、数はふつう任意の精度をとるものであり、その精度は必要に応じて決められます。C はかなり限定された整数型しか持っていません。 もっとも一般的なものは int で、これはマシンワードと一致しているとほぼみなせます。 私が C を学んだときの機種では 16 ビットでした。今ならおおむね 32 ビットです。マシンワードが 64 ビットのアーキテクチャでもそうなっていますが、これは int が 32 ビットであると決め打ちにしたコードが今でも多く書かれているためです。

動作がおかしくなるもっともありがちな原因は、int にポインタ値を入れようとすることです。 よくある 32 ビットプラットフォーム上なら、このやり方でうまくいきます。 64 ビットプラットフォームでは、動くものもあるでしょうが、ものによっては動きません。 C99 標準は intptr_t という新しい整数型を定義しており、これはポインタを格納するのに十分な大きさであることが保証されています。 ポインタを整数型に格納するのなら、かならず intptr_t を使いましょう。

ポインタについてですが、これがまた厄介なところのひとつです。 C は2種類のポインタを定義しています。ひとつはコードを指すもの、もうひとつはデータを指すものです。 関数へのポインタが、データを指すポインタと同じサイズであることは保証されていません。 多くの組み込みプラットフォームでは、コード用に 16 ビットポインタ、データ用に 32 ビットポインタをふつうに使っています。 この場合、void * を関数へのポインタにキャストするとデータの一部が失われることになります。

べつの厄介なところですが、C の基本的な整数は char、short、long です。 C99 では long long も導入されました。 これらはどれも、最小サイズ以外のサイズが決まっていません。 (専門的なことをいえば、格納できる値の範囲のうちでもっとも小さい範囲が決まっており、その内部配置については何の保証もありません) short は最低 16 ビット、long は最低 32 ビット、long long は最低 64 ビットでなければなりません。 これらのうちどれかを使うと、これらの最小精度だけが必要な場合でも、要求したよりもすこし場所を多めに取ることになるかもしれません。その量はアーキテクチャ依存になります。

char 系の型について私は今まで触れませんでしたが、それは他の型といささか事情が異なるためです。 char 系以外の基本整数型は、明示的に符号なし(unsigned)と定義しないかぎり、符号つき(signed)になります。 これは必ずしも char には当てはまりません。 困ったことに、絶対当てはまらないわけでもないのです。char が符号つきか否かは完全に(C コンパイラの)実装に依存します。 もし char を使っているなら、つねに signed か unsigned かを明示的に定義することです。 もしそうしていなければ、後で不意を突かれることになるでしょう。

演算時の(異なる)型同士の暗黙の型変換に関して、C 言語にはかなり意外な規則があります。 常識的な前提ですが、演算の精度は、(型変換が)どう使われるかによって決まります。 たとえば、以下のようにしたとします。

a = b + c;

a に結果を格納するのですから、ともかく a の型精度で計算が行われるだろうと思うかもしれません。 実際は b もしくは c の型精度で計算が行われます。 こうしないと (b + c) の値が b と c 以外のものの影響をうけてしまうから、と考えればこれは納得できます。 この式の型は b の型だろうと思うかもしれません。 C99 標準は型決定について数多くのルールを定義していますが、一般的には、置かれた場所にかかわりなく大きいほうの型になります。つまり b か c――たとえば a が char で b が int だとすると、式の型は int になります。 よくあるバグのひとつは、以下のようなものから発生します。

long long a;
long b;// = ほにゃらら
long c;// = ほにゃらら

a = b * c;

a は少なくとも 64 ビット、b と c は少なくとも 32 ビットです。 b と c は 32 ビットしか保証されていませんから、仮にコンパイラやプラットフォームが 64 ビット長であっても、中に 32 ビットを超える値を入れてはいけません。 32 ビット同士の乗算は 64 ビット整数になら収まるわけで、その結果は 64 ビット整数相当のものとして保持されます。 気が利いてるでしょ?  確かにそうです。 代入の直前に結果が 32 ビットに切り詰められてしまう、ということを別にすればですが。 正しいやり方はこうです。

a = (long long)b * c;

これは b を 64 ビットに符号拡張します(あるいは 64 ビット以上。実装依存)。 型伸張の規定により、c は b と同じ大きさの型になることが保証され、つまり c も符号拡張されます。 すると乗算は、その上位 32 ビット以上の部分がゼロである long long 型ふたつの乗算ということになり、(long long の)結果は long long 型の変数に格納されます。

一般的には、演算に十分な精度のものに明示的な型変換を行うことで、不測の事態を防ぐことができます。 (演算項の)両方の型符号が一致しているか確認してください。 unsigned 型の大きな値が同じ大きさの signed 型に変換されるとき、最上位ビットが失われることがあるため、これは非常によくエラーの原因となります。

整数のオーバーフローから発生するバグはさらにありがちです。 これは malloc と一緒のとき特に顕著で、そのよくあるパターンは malloc(i * sizeof(x)) と書くことです。 もし 攻撃者が i に何らかの影響を与えられるなら、i をオーバーフローさせようとするでしょう。 そうすると、大きな値の i が必要なところでかなり小さな値が返されることになり、これが問題となります。 malloc への呼び出しは成功しますが、受け取った配列を使おうとすると、最初のごくわずかな個所しか有効ではないわけです。 攻撃者はこれを利用して、あなたに他のデータを上書きさせるのです。

この種の穴を避ける簡単な方法は、malloc() のかわりに calloc() を使うことです。 (もちろんここでは、calloc() の実装が自前の境界チェックを行っており、単なる malloc() と count*size の memset() になっていないということを期待しています)

realloc() はさらに問題です。 境界チェックを行う標準的な手段がないため、自分で行う必要があります。 さいわい OpenSSH には、realloc の境界チェック版である xrealloc() が含まれています。 xrealloc() にはほかにも多くの検査が含まれていますが、その全部が必要でないなら、簡易版を実装するのがよいでしょう。

void * xrealloc(void *ptr, size_t nmemb, size_t size)
{
    void *new_ptr;
    size_t new_size = nmemb * size;
    if (SIZE_T_MAX / nmemb < size)
            return NULL;
        return realloc(ptr, new_size);
}

このチェックはかなり単純なものです。 SIZE_T_MAX は size_t 型のとりうる最大値です。 これを要求されるメモリ要素の数で割ると、オーバーフローせずにすむ1要素あたりの最大サイズがわかります。 このサイズが要求された size よりも小さい場合は、オーバーフローになるので NULL を返します。

realloc はエラーの場合 NULL を返すので、realloc() からの戻り値は常にチェックしてください。 残念なことに、これがメモリリークの原因として非常によくあるものなのです(そしてこれが DoS 攻撃につながるわけです)。 realloc() が NULL を返した場合、元のポインタは有効なままになっています。 開発者はこの原則を忘れ、あっさり以下のように書いてしまいがちです。

ptr = realloc(ptr, newsize);

これでは realloc() が NULL を返したとき、以前のメモリがどこにあるか見つけられなくなります。 FreeBSD は reallocf() という便利な関数を提供しており、以下のものと等価です。

void *reallocf(void* ptr, size_t size)
{
    void *newptr = realloc(ptr, size);
    if (NULL == newptr)
    {
        free(ptr);
    }
    return newptr;
}

realloc 失敗時のリカバリ用コードを別に使っているのでなければ、このような処置をしておくのがよいと思います。

嘆きのメモリ

C のメモリモデルは、メモリをふたつの場所――ヒープとスタックに分けています。 ヒープ上のメモリは確保と解放を手動で行います。 スタック上のメモリはレキシカルスコープです。すなわちブロックに入ると自動的に確保され、ブロックを出ると解放されます。 この方法で問題になるのは、呼び出し元の関数にデータを渡すときです。 構造体ならば、言うまでもなくその構造体をそのまま返します。 (構造体を返す関数が)コンパイルされる場合、呼び出し元は構造体を確保し、そのポインタを呼び出す関数に渡します。そして呼び出された関数はデータをこの場所にコピーするのです。 構造体を戻り値にした関数を何度も呼び出した場合、ともかくそれは、多量のコピー作業が行われるということになります。

なお悪いことに、これはサイズが固定していないデータには使えません。 sprintf() などのことを考えてみます。 これは printf() の類似品で、標準出力のかわりにバッファに書き込みます。 sprintf の問題は、呼び出し元がバッファの長さを知っておく必要があり、それがいつでも簡単なわけでないことです。実際、sprintf はそれを呼び出す側に多くの実装を要求します。

実際、sprintf を安全に使うことはほぼ不可能です。 (安全に使いたいなら)ひとつひとつの要素すべてに対して書式指定文字列中で長さを指定しなければならないのです。(snprintf が作られたのはこのせいです) これは結果を切り詰めることがあります(これはセキュリティの問題の原因にもなりますので、また後でとり上げます)。このため libc の実装によっては asprintf() を用意しているものもあります。

asprintf() 関数は sprintf と似ていますが、malloc() を使って自前のバッファを確保する点が違います。 これは切り詰めやバッファ溢れの被害を受けません。 残念ながらここで新しい問題が出てきます。 呼び出した関数から返されたポインタを、呼び出し元はいつ解放すべきなのか?

C コードの多くはこの問題を抱えています。 代表的な解決法は、関数のドキュメントに「返されたポインタは必ず呼び出し元が解放すること」と書いておくことです。 残念ながらこのやり方では、プログラムの一部を見てそれが妥当なものか見分けることが非常に難しくなります。 ひとつの解決法は、返されるポインタを以下のようなものの中に入れてしまうことです。

typedef struct _RefCountedBuffer
{
    void *buffer;
    int refcount;
    void (free*)(struct _RefCountedBuffer*);
} *RefCountedBuffer;

ポインタを返す際にはこの構造体を作成し、refcount に 1 を設定します。 この構造体を受け取ったら、refcount をデクリメントして 0 になったら内蔵の free() を呼び出す関数を常に使うようにします。

このテクニックは Objective-C プログラマにはおなじみのものでしょう。OpenStep が同様のメカニズムを実装しているためです。 GNUstep はさらに一歩進めて、ASSIGN() と DESTROY() マクロを提供しています。 これらのマクロでメモリのバグがかなり減らせますが、ただの C でも同様のことが可能です。

まずはじめに、保持 retain() と解放 release() の関数を定義する必要があります。

RefCountedBuffer retain(RefCountedBuffer *buf)
{
    buffer->refcount++;
    return buffer;
}
void release(RefCountedBuffer *buf)
{
    buf->refcount--;
    if (buf->refcount == 0)
        buf->free(buf);
}

これらが実際に必要な関数の簡略版であることに注意してください。 もっとも明白な問題はスレッドセーフでないことです。 ++ や -- 演算子はコンパイルされると、ロード、加算(減算)、ストアの3段階の動作に分けられます。 これをふたつのスレッドが同時に実行した場合、片方のストアが終わらないうちに両方ともロードを行ってしまい、一方の保持状態が失われるかもしれません。 CPU 固有のアセンブリ言語か GCC のアトミック操作用 intrinsics を使うことで、この問題に対処できます。

いったんこれらの関数を定義しておけば、SET と FREE のマクロを以下のように定義できます。

#define SET(var, val) do {    RefCountedBuffer __tmp = retain(val);    if (NULL != var)        release(var)    var = __tmp;    } while(0)

以前の値を release するよりも前に新しい値を retain していることに注意してください。 これは以前の値と新しい値とが同じだった場合の不具合を回避するためです。 対応する FREE() マクロは実にシンプル。

#define FREE(var) do {    release(var)    var = NULL;    } while(0)

このマクロはポインタを解放したあと、もれなく NULL を設定します。 参照カウンタを使わないとしても、この方法にはやはり意味があります。

このふたつのマクロを使うことで、コード中の等号をごくわずかにすることができます。 メモリ関連のバグがありそうな個所の詳しい調査が、これによって容易になります。

参照カウンタは、読み出し専用のデータに対してはよい解決法です。 巨大なデータ構造体の内部要素を参照で返し、その参照は元の構造体の側からは、両者ともに使い終わっていない間は消さずにすませることができます――どちらに対しても構造体 RefCountedBuffer に包んでいれば、ですが。

この設計はしかし、asprintf などにそもそも存在する問題を解決しません。 それは呼び出し元でだけ使われる文字列を返しがちなものです。 これに対して、ヒープ上に確保して参照カウンタ構造体で包む、というのはやり過ぎです。 呼び出し元のスタックフレーム内に確保する手段があればよかったのですが。

Dovecot IMAP server の設計者はエレガントな解決を行っています。 通常のスタックのほかに、独立したデータスタックとそちらを使う asprintf 相当品を用意しているのです。 呼び出し元は、まず新しいデータスタックフレームを確保する関数を呼び出します。 asprintf() などを呼び出すと、それらの関数はデータスタック上に結果を出力し、戻ります。 この結果は、最後に確保したデータスタックフレームが pop されるまでずっと有効です。

制御スタックとデータスタックが別働なので、データスタックから簡単にデータを取り出せます。 データスタックフレームを作った関数がこれを破壊しない限り、何も問題はありません。 asprintf() などでの流れは、まずデータスタックフレームを作成し、それから asprintf() を呼び出すと、asprintf() が現在のデータスタックフレーム内に領域を確保してそこに結果を出力します。 出力結果を使ったらそのスタックフレームを pop します。

メモリ領域が複数に分かれてしまうことは避けられません。 その隙間のいずれかの部分を /dev/zero に mmap して、好きなように使うことはできます。 実行可能なアイデアとしては、データスタックの確保を制御スタックの動きにあわせる、というものがあります。 このトリックを使うのは、それ以外の方法でスタック上に確保したすべての配列に対してです。 制御スタックと違い、これはメモリ内で高位方向に伸びていきます。 最初からずっと、以下のようなマクロを使ったグローバルポインタでアドレス指定しておくことで、この配列を再配置可能(relocatable)にできます。

#define NEW_ARRAY(type, name, elements) __attribute__((cleanup(popDataStack)))    type *name = dataStackCalloc(sizeof(type), elements)

__attribute__(cleanup) の部分は GCC の拡張です。 これにより、変数がスコープ外になると、その変数を指すポインタを引数にした popDataStack 関数が呼び出されるようになります。 これでデータスタックの内容物を指すポインタが手に入ります。 このアドレスをポインタに設定するには、じかに行うよりもマクロがあったほうがよいでしょう。 この下準備をしておけば、近接するメモリを解放しないとデータが保存できなくなるまでは、データスタックのサイズを伸ばすだけですみます。

とはいえ、配列のオーバーランはまだ可能です。 戻りアドレスを壊すことはないのですが、データの上書きあるいはその他の問題の可能性はあるのです。 データスタックの終端超えは、mprotect() を使ってスタック最終ページのアクセス権をなくすことで回避できます。 ほとんどの malloc() 実装には、ページ確保のたびにこの種のページ保護が挿入されるデバッグモードがあります。 すべての配列をその直後にアクセス不可ページが続くように確保して、データスタックをアクセス可能ページとアクセス不可ページの層にすれば、これ(と同じこと)ができますが、この仕組みはかなり高価につきます。 気の利いたオペレーティングシステムなら保護ページに実メモリを確保することはありませんが、ページの起点から配列の起点までにできる多くの隙間が無駄になり、多量のアドレス空間を使うことになるのです。

次回予告

C はコンピュータへの非常に低レベルなインターフェースを提供していますが、だからといって C 言語で低レベルのコードを書かなければならないわけではありません。 C の素朴な機能は、より高レベルの抽象化で包めばもっと便利であり、複雑なプロジェクトではこれを行う価値があります。 Part3 は C の荒削りな部分をいくつか検討して本連載を総括し、そのカドを少しでも丸くする方法を見ていきます。