コンパイラ セキュリティの徹底調査

Brandon Bray
Visual Studio Team
Microsoft Corporation

February 2002
日本語版最終更新日 2002 年 9 月 26 日

概要 : この資料では、 バッファ オーバーラン、 および /GS コンパイル時フラグが提供する Microsoft® Visual C++® .NET のセキュリティ チェック機能の全体像について説明します。

目次

はじめに
バッファ オーバーランとは?
x86 スタックの構造
ランタイム チェック
/GS の機能
エラー ハンドラ
Cookie の値
パフォーマンスへの影響
具体例
まとめ

はじめに

ソフトウェア セキュリティはハイテク業界にとって大きな関心事ですが、 中でも最も恐ろしく、誤解されているソフトウェアの脆弱性が、 バッファ オーバーランです。 近頃では、バッファ オーバーランの話をすれば、人々が足を止めて耳を傾けるほどです。 多くの場合、一般の人々はメモを取っているうちに細かい技術内容がわからなくなり、 根本的な問題に不安を抱いたまま立ち去ることになります。 Visual C++ .NET はこの問題に対処するために、 開発者がバッファ オーバーランを識別するのに役に立つセキュリティ チェックを導入しました。

バッファ オーバーランとは?

バッファとは、通常配列の形式をとるメモリ ブロックのことです。 配列のサイズを確認しないと、 確保したバッファの外部に書き込む可能性があります。 書き込みがバッファよりも上位のメモリ アドレスに行われる場合、 これをバッファ オーバーランと呼びます。 書き込みが確保したバッファよりも下位のメモリ アドレスに行われる場合もあります。 これをバッファ アンダーフローと呼びます。 アンダーフローが発生することは、 オーバーランが発生するよりも極めてまれですが、 発生する可能性はあります。 このことは、この資料の後半で触れます。 実行中のプロセスにコードを書き込むバッファ オーバーランは、 悪用されかねないバッファ オーバーラン (exploitable buffer overrun) と呼ばれています。

strcpygetsscanfsprintfstrcat など特定のクラスの関数は、 バッファ オーバーランに対して本質的に脆弱であり、 なるべく使用しないようにマニュアルに明記されています。 以下の簡単な例でこのような関数の危険性を示します。


int vulnerable1(char * pStr) {
    int nCount = 0;
    char pBuff[_MAX_PATH];

    strcpy(pBuff, pStr);

    for(; pBuff; pBuff++)
       if (*pBuff == '\\') nCount++;

    return nCount;
}

このコードには明らかに脆弱性があります。 pStr が指すバッファが _MAX_PATH より長い場合、 pBuff パラメータがオーバーランを起こすでしょう。 単純に assert(strlen(pStr) < _MAX_PATH) を含めると、 デバック ビルドの実行時にはこのエラーを十分捕捉できますが、 リリース ビルドではこれだけでは不十分です。 このような脆弱性を持つ関数を使用することはよい習慣ではありません。 同様の機能を持ち、技術的には脆弱性が低い関数として strncpystrncatmemcpy などがあります。 ただし、これらの関数にも問題点があります。 それは、バッファのサイズを宣言するのがコンパイラではなく開発者であることです。 以下の関数で、一般的な間違いを示します。


#define BUFLEN 16

void vulnerable2(void) {
    wchar_t buf[BUFLEN];
    int ret;

    ret = MultiByteToWideChar(CP_ACP, 0, "1234567890123456789", -1,
                              buf, sizeof(buf));
    printf("%d\n", ret);
}

上記の例では、 文字数ではなくバイト数を使用してバッファのサイズを宣言したために、 オーバーフローが発生します。 この脆弱性を修正するには、 MultiByteToWideChar の最後の引数を sizeof(buf)/sizeof(buf[0]) にする必要があります。 vulnerable1vulnerable2 の 2 つの例は容易に防ぐことができる一般的な間違いです。 しかし、コードのレビューで見落とした場合、 潜在的に危険なセキュリティの脆弱性を持ったアプリケーションが出荷されることになります。 これが Visual C++ .NET がセキュリティ チェックを導入した理由であり、 脆弱なアプリケーションへの悪意あるコード挿入という vulnerable1vulnerable2 のようなバッファ オーバーランを防ぐことでしょう。

x86 スタックの構造

どのような環境でバッファ オーバーランが利用されるか、 また、どのようにセキュリティ チェックを実行するのかを完全に理解するには、 スタックのレイアウトを完全に理解しなければなりません。 x86 アーキテクチャでは、スタックは下方向に大きくなります。 これは、新たなデータが、先にスタックへプッシュされた要素よりも小さいアドレスへ位置付けられることを意味しています。 それぞれの関数呼び出しはレイアウトに従った新しいスタック フレームを生成します。 上位メモリがリストの頭にあることを注意してください。

  • 関数パラメータ
  • 関数のリターン アドレス
  • フレーム ポインタ
  • 例外ハンドラ フレーム
  • ローカルに宣言した変数やバッファ
  • 呼び出し側のレジスタの保存

このレイアウトを見ると、 バッファ オーバーフローが、 バッファ、例外フレーム、フレーム ポインタ、リターン アドレス、 および関数パラメータよりも前に割り当てた他の変数に上書きできることは明らかです。 その後 EIP レジスタに読み込まれる予定のデータに値を書き込むと、 プログラムの実行を支配できます。 EIP レジスタに読み込まれる予定のデータの 1 つが関数のリターン アドレスです。 古典的なバッファ オーバーラン攻撃は、 リターン アドレスを上書きし、 それから、 関数のリターン命令に EIP 内にそのリターン アドレスをロードさせます。

データ要素は次の方法でスタック上に格納されます。 関数パラメータは、 関数が呼び出される前に、 スタック上にプッシュされます。 スタンダード コールは、 パラメータを右から左へプッシュします。 x86 CALL 命令により、関数のリターン アドレスがスタック上に格納されます。 つまり、EIP レジスタの現在値がスタック上に格納されます。 フレーム ポインタは直前の EBP レジスタの値で、 フレーム ポインタを省略する (FPO : Frame Pointer Omission) 最適化が行われないときにスタック上に格納されます。 そのため、フレーム ポインタは常にスタック フレームに格納されるわけではありません。 関数が try/catch またはその他の例外処理構造を含む場合、 コンパイラがスタック上に例外処理情報を格納します。 その後、ローカルに宣言した変数とバッファがスタック上に割り当てられます。 これらの割り当て順序は、どのような最適化が行われるかによって変化することがあります。 最後に、関数の実行中のどこかの場所で ESI、EDI、EBX などのレジスタを使用する場合は、 呼び出し側のこれらのレジスタを保存するためにスタック上に格納します。

ランタイム チェック

バッファ オーバーランは、C や C++ のプログラマが共通して犯しやすい誤りであり、 潜在的に最も危険です。 Visual C++ .NET は、 開発者が開発サイクルの中でこれらのエラーを容易に発見し、 そのエラーを修正できるようにするツールを提供します。 Visual Studio C++ 6.0 の /GZ スイッチは Visual C++ .NET の /RTC1 スイッチに生まれ変わりました。 /RTC1 スイッチは /RTCsu の別名です。 /RTCsus はスタック チェック (この資料のテーマです) を意味し、 u は初期化されていない変数のチェックを意味します。 スタック上に割り当てられるすべてのバッファは両端にタグが付けられるので、 オーバーランとアンダーフローを捕捉できるようになります。 小さなオーバーランはプログラムの実行の流れを変えない場合もありますが、 バッファ付近のデータを破壊する可能性があり、 これに気付かないで実行する場合があります。

安全なコードを記述するだけでなく、 正しいコードを記述するという基本的な問題に注意を払う開発者にとって、 このランタイム チェックが役に立ちます。 ただし、ランタイム チェックはデバッグ ビルドでのみ機能します。 この機能は実稼動コードで操作するようにはデザインされていません。 それでも、実稼動コードでバッファ オーバーフローのチェックを行うことには明らかな価値があります。 実稼働コードでランタイム チェックを行う場合は、 ランタイム チェックを実装することによるパフォーマンスへの影響を最小限にとどめるデザインが必要になるでしょう。 このような理由から、Visual C++ .NET コンパイラは /GS スイッチを導入しました。

/GS の機能

/GS スイッチはバッファとリターン アドレス間に "徐行帯 (Speed Bump)"、つまり Cookie を提供します。 オーバーフローによりリターン アドレスに上書きすると、 リターン アドレスとバッファとの間に配置された Cookie にも上書きすることになります。 この Cookie により、スタックのレイアウトが新しくなります。

  • 関数パラメータ
  • 関数のリターン アドレス
  • フレーム ポインタ
  • Cookie
  • 例外ハンドラ フレーム
  • ローカルに宣言した変数やバッファ
  • 呼び出し側のレジスタの保存

Cookie については、後で詳しく検討しましょう。 このようなセキュリティ チェックで、関数の実行が変化します。 まず、関数が呼び出されるとき、 関数のプロローグ内の一連の命令が最初に実行されます。 プロローグは、少なくとも、次の命令のようにローカル変数用の領域をスタック上に確保します。


sub   esp,20h

この命令は、関数のローカル変数が使用する領域を 32 バイト確保します。 /GS スイッチを指定してこの関数をコンパイルすると、 関数のプロローグはさらに 4 バイトを確保し、以下のような 3 つの命令を追加します。


sub   esp,24h
mov   eax,dword ptr [___security_cookie (408040h)]
xor   eax,dword ptr [esp+24h]
mov   dword ptr [esp+20h],eax

プロローグには、Cookie のコピーをフェッチする命令、 それに続けて Cookie とリターン アドレスの論理 xor 演算を行う命令、 そして最後にスタック上のリターン アドレスの直下に Cookie を格納する命令が含まれます。 ここから先は、関数は通常どおりに実行されます。 関数から戻るとき、最後に関数のエピローグが実行されます。 これはプロローグの反対のことを行います。 セキュリティ チェックを行わない場合、 関数は次の命令のようにスタック領域を解放して戻ります。


add   esp,20h
ret

/GS スイッチを指定してコンパイルすると、 エピローグにもセキュリティ チェックが配置されます。


mov   ecx,dword ptr [esp+20h]
xor   ecx,dword ptr [esp+24h]
add   esp,24h
jmp   __security_check_cookie (4010B2h)

スタック上の Cookie のコピーを取得し、 次にリターン アドレスとの XOR 命令を行います。 ECX レジスタは、 __security_cookie 変数に格納されている元の Cookie に一致する値を保持している必要があります。 スタック領域が解放され、 RET 命令を実行する代わりに __security_check_cookie ルーチンへの JMP 命令を実行します。

__security_check_cookie ルーチンは単純なものです。 Cookie の値が変更されていなければ RET 命令を実行し、 関数呼び出しを終了します。 Cookie の値が一致しない場合、 このルーチンは report_failure を呼び出します。 その後、report_failure 関数が __security_error_handler(_SECERR_BUFFER_OVERRUN, NULL) を呼び出します。 両方の関数は C ランタイム (CRT) ソース ファイルの seccook.c ファイルで定義されています。

エラー ハンドラ

このようなセキュリティ チェックを機能させるには、 CRT のサポートが必要になります。 セキュリティ チェックで障害が発生すると、 プログラムの制御が以下に概要を示す __security_error_handler に移ります。


void __cdecl __security_error_handler(int code, void *data)
{
    if (user_handler != NULL) {
      __try {
        user_handler(code, data);
      } __except (EXCEPTION_EXECUTE_HANDLER) {}
    } else {
      //...出力メッセージの準備...
      __crtMessageBoxA(
          outmsg,
          "Microsoft Visual C++ ランタイム ライブラリ",
          MB_OK|MB_ICONHAND|MB_SETFOREGROUND|MB_TASKMODAL);
    }
    _exit(3);
}

既定では、セキュリティ チェックに失敗したアプリケーションは、 "バッファ オーバーランが検出されました!" というダイアログ ボックスを表示します。 ダイアログ ボックスを閉じると、アプリケーションが終了します。 CRT ライブラリは、 アプリケーションにとってより実用的な方法でバッファ オーバーランに反応する別のハンドラを使用するオプションを、 開発者に提供します。 関数 __set_security_error_handler を以下の例に示すように使用して、 user_handler 変数にユーザー定義ハンドラを格納することにより、 ユーザー ハンドラをインストールします。


void __cdecl report_failure(int code, void * unused)
{
    if (code == _SECERR_BUFFER_OVERRUN)
      printf("バッファ オーバーランが検出されました!\n");
}

void main()
{
    _set_security_error_handler(report_failure);
    // 以下にコードが続きます。
}

このアプリケーションではバッファ オーバーランを検出すると、 ダイアログ ボックスを表示するのではなく、 コンソール ウィンドウにメッセージを表示します。 ユーザー ハンドラが明示的にプログラムを終了することはありませんが、 ユーザー ハンドラから戻ったときに、 __security_error_handler_exit(3) を呼び出してプログラムを終了します。 __security_error_handler 関数と _set_security_error_handler 関数は、 いずれも CRT ソース ファイルの secfail.c ファイルにあります。

ユーザー ハンドラで行う必要のあることを説明すると役立つでしょう。 例外をスローするのが一般的な対応です。 しかし、例外情報はスタック上に格納されるので、 例外をスローすると壊れた例外フレームに制御が移る可能性があります。 __security_error_handler 関数は、 これを防ぐために、 すべての例外を捕捉する __try/__except ブロック内にユーザー関数の呼び出しをラップして、 その後プログラムを終了します。 開発者は、例外が発生するので、 DebugBreak の呼び出しや、 longjmp の使用を敬遠します。 ユーザー ハンドラが行う動作は、 エラーを報告し、場合によってはログを作成して、 バッファ オーバーランを解決できるようすることだけです。

開発者によっては、 _set_security_error_handler を使用するのではなく、 __security_error_handler を書き直して同じ目標を実現することもあります。 書き直しはエラーが発生しやすくなるので、 メインのハンドラを正しく実装することが重要で、 正しく実装しないと危険な結果を招く可能性があります。

Cookie はポインタと同じサイズ、つまり x86 アーキテクチャでは 4 バイトの長さを持つ乱数値です。 Cookie の値は、 他の CRT グローバル データと共に __security_cookie 変数に格納されます。 この値は、CRT ソース ファイルの seccinit.c ファイルにある __security_init_cookie を呼び出すことにより、 乱数値に初期化されます。 Cookie の乱数値はプロセッサ カウンタを元に生成されます。 各イメージ (つまり、/GS スイッチを指定してコンパイルした各 DLL または EXE) は、 メモリに読み込まれるたびに異なる Cookie 値を持ちます。

/GS コンパイラ スイッチを指定してアプリケーションのビルドを開始するとき、 2 つの問題点が発生する可能性があります。 まず、__security_init_cookie への呼び出しは CRT の初期化中に行われるので、 CRT サポートを含まないアプリケーションは Cookie が乱数値を持ちません。 アプリケーションの読み込み時に Cookie に乱数値が設定されないと、 アプリケーションはバッファ オーバーフローが検出された場合の攻撃に対して脆弱なままです。 この問題を解決するには、 アプリケーションがスタートアップ時に明示的に __security_init_cookie を呼び出す必要があります。 2 番目に、 以下の例のようにマニュアルに記載されている _CRT_INIT 関数を呼び出して初期化する従来のアプリケーションは、 セキュリティ チェックが予期せず失敗する場合があります。


DllEntryPoint(...) {
    char buf[_MAX_PATH];   // セキュリティ チェックを起動するバッファ
    ...
    _CRT_INIT();
    ...
}

これは、既にセキュリティ チェックが行われるようにセットアップされた関数の実行中に、 _CRT_INIT への呼び出しで Cookie の値が変更される点が問題になります。 Cookie の値が関数の終了時に変わっているので、 セキュリティ チェックはバッファ オーバーランが存在したと解釈します。 解決策は、_CRT_INIT の呼び出しを行う前に、 実行中の関数でバッファを宣言しないようにすることです。 現時点では、 _alloca 関数を使って割り当てを行うとコンパイラがセキュリティ チェックを生成しないので、 _alloca を使用してスタック上にバッファを割り当てるという回避策があります。 ただし、この回避策は Visual C++ の今後のバージョンで機能することは保証されません。

パフォーマンスへの影響

アプリケーションでセキュリティ チェックを使用する場合は、 パフォーマンスへの影響との比較検討を行う必要があります。 Visual C++ コンパイラ チームは、 パフォーマンスの低下を抑えることに尽力しました。 ほとんどの場合、2% 以上のパフォーマンスの低下はありません。 実際には、 パフォーマンスの高いサーバー アプリケーションを含めた大部分のアプリケーションで、 パフォーマンスへの影響には気が付かないことが経験によってわかっています。

パフォーマンスへの影響が問題にならないことを支える最も重要な要因は、 攻撃に対して脆弱な関数のみを対象にしていることです。 現在、脆弱性のある関数は、 文字列型のバッファをスタック上に割り当てる関数であると定義されています。 脆弱であると考えられる文字列バッファは、 4 バイト以上の領域を割り当てるバッファで、 バッファの各要素が 1 バイトまたは 2 バイトのバッファです。 小さなバッファが攻撃対象になることはあまりありません。 セキュリティ チェックを行う関数の数を制限することで、 コードが大きくなるのを防ぎます。 大部分の実行形式ファイルでは、 /GS スイッチを指定してビルドしているときでも、 サイズの増加に気が付くことはありません。

具体例

つまり、/GS スイッチはバッファ オーバーランを解決しませんが、 特定の状況でバッファ オーバーランが悪用されるのを防ぐことができます。 vulnerable1vulnerable2 は、 /GS スイッチを指定してコンパイルすると悪用されなくなります。 バッファ オーバーランが関数から戻る直前に発生する最後の動作であるすべての関数は、 悪用されることはないでしょう。 関数の実行の前半でバッファ オーバーランが発生する可能性があるので、 以下の例に示すように、 セキュリティ チェックがバッファ オーバーランを検出する機会がないか、 またはセキュリティ チェック自体がオーバーランの攻撃を受ける状況が生じます。

例 1


class Vulnerable3 {
public:
    int value;

    Vulnerable3() { value = 0; }
    virtual ~Vulnerable3() { value = -1; }
};

void vulnerable3(char * pStr) {
    Vulnerable3 * vuln = new Vulnerable3;
    char buf[20];

    strcpy(buf, pStr);
    delete vuln;
}

上記の状況では、 仮想関数を持つオブジェクトへのポインタはスタック上に割り当てられます。 オブジェクトが仮想関数を持つので、 そのオブジェクトは vtable ポインタを含みます。 攻撃者はこの機会を利用して悪意のある pStr 値を提供し、 buf をオーバーランできます。 関数から戻る前に、 delete 演算子が vuln に対して仮想デストラクタを呼び出します。 これを行うには、vtable 内のデストラクタ関数を照合する必要がありますが、 この時点では vtable 内のデストラクタ関数は上書きされています。 関数から戻る前にプログラムの実行が支配されるので、 セキュリティ チェックはバッファ オーバーランを検出できません。

例 2


void vulnerable4(char *bBuff, in cbBuff) {
    char bName[128];
    void (*func)() = MyFunction;

    memcpy(bName, bBuff, cbBuff);
    (func)();
}

上記の状況では、 関数はポインタをごまかす攻撃に対して脆弱です。 コンパイラが 2 つのローカル変数の領域を割り当てるときに、 bName 変数の前に func 変数を配置します。 これは、オプティマイザがこのレイアウトによりコードの効率を向上できるためです。 残念ながらこのレイアウトにより、 攻撃者が bBuff に悪意のある値を提供できます。 また、攻撃者は bBuff のサイズを示す cbBuff の値を提供できます。 この関数は cbBuff のサイズが 128 以下であることの確認を間違って省略しています。 その結果 memcpy への呼び出しがバッファをオーバーランし、 func の値を壊します。 値が変わった func ポインタを使用して、 vulnerable4 関数から戻る前に、 ポインタが指す間違った関数を呼び出すので、 セキュリティ チェックを実行する前に実行が支配されます。

例 3


int vulnerable5(char * pStr) {
    char buf[32];
    char * volatile pch = pStr;

    strcpy(buf, pStr);
    return *pch == '\0';
}

int main(int argc, char* argv[]) {
    __try { vulnerable5(argv[1]); }
    __except(2) { return 1; }
    return 0;
}

このプログラムは構造化例外処理を使用するので、 特に難しい問題を示しています。 以前に説明したように、 例外処理を使用する関数は、 適切な例外処理関数などの情報をスタック上に格納します。 この場合、 vulnerable5 に欠陥がある場合にも、 main 関数の例外処理フレームを攻撃できます。 攻撃者は buf をオーバーランする機会を利用して、 pch と、 main の例外処理フレームの両方を壊します。 vulnerable5 関数は後で pch を逆参照するので、 攻撃者が 0 などの値を提供した場合、 アクセス違反になり、結果的に例外が発生します。 スタックを順次解放していくとき、 オペレーティング システムが例外ハンドラの例外フレームを調べて、 例外ハンドラに制御を渡します。 例外処理フレームが壊れているので、 オペレーティング システムは攻撃者が提供する任意のコードにプログラムの制御を渡すことになります。 関数から正しく戻らなかったので、 セキュリティ チェックはこのバッファ オーバーランを検出できません。

最近の最も有名な悪用に、 例外処理を悪用したものがありました。 最も報道価値があったものの 1 つは、2001 年夏に出現した Code Red ウイルスでした。 Windows XP では、 例外ハンドラのアドレスがスタック上に存在しないようにし、 すべてのレジスタを 0 に設定してから例外ハンドラを呼び出すことによって、 例外処理の攻撃が困難な環境を既に作り上げました。

例 4


void vulnerable6(char * pStr) {
    char buf[_MAX_PATH];
    int * pNum;

    strcpy(buf, pStr);
    sscanf(buf, "%d", pNum);
}

/GS スイッチを指定してこの関数をコンパイルすると、 上記の 3 つの例とは異なり、 単純にバッファをオーバーランするだけでは実行を支配できません。 この関数は、プログラムの実行を支配するために 2 段階の攻撃が必要です。 pNumbuf の前に割り当てられていることを知っていると、 pStr 文字列が提供する任意の値で容易に上書きできます。 攻撃者は上書きするために 4 バイトのメモリを選択する必要があるでしょう。 バッファが Cookie を上書きした場合、 user_handler 変数に格納されたユーザーハンドラ関数ポインタ、 または __security_cookie 変数に格納された値を支配できるようになります。 Cookie が上書きされていない場合は、 攻撃者はセキュリティ チェックを含まない関数のリターン アドレスのアドレスを選択するでしょう。 この場合、プログラムは通常どおり実行され、 バッファ オーバーランを認識しないで関数から戻ります。 その直後に、プログラムは自動的に支配されます。

脆弱なコードは、 /GS で対処されないヒープ上のバッファ オーバーフローなど、 別の攻撃を受ける場合もあります。 配列に順番に書き込むのでなく、 配列の特定のインデックスを対象とする範囲外インデックス攻撃も /GS では解決できません。 チェックされない範囲外インデックスは、 基本的にメモリの任意の部分を対象とすることができ、 Cookie の上書きを回避できます。 チェックされないインデックスのもう 1 つの形式は、 配列のインデックスに負の数が指定された場合の符号付き整数と符号なし整数の不一致です。 インデックスが符号付き整数の場合は、 単純にインデックスが配列のサイズよりも小さいことを確認するだけでは不十分です。 最後に、/GS セキュリティ チェックは通常バッファ アンダーフローに対処しません。

まとめ

バッファ オーバーランはアプリケーションの致命的な欠陥であることは明らかです。 初めから厳密にセキュリティで保護されたコードを記述することに替わるものはありません。 世論で指摘されているにもかかわらず、 バッファ オーバーランのごく一部は発見が非常に困難です。 /GS スイッチは、セキュリティで保護されたコードを記述することに関心がある開発者にとっては役に立つツールです。 しかし、このツールはバッファ オーバーランがコード内に存在するという問題は解決しません。 ある状況でバッファ オーバーランの悪用を防ぐためにセキュリティ チェックを使っても、 依然としてプログラムを終了させるサービス拒否攻撃があります。 これは特にサーバー コードを対象にします。 /GS を指定してビルドすることは、 開発者が気付かない脆弱なバッファの危険を緩和するための安全な手段です。

この資料で説明したように、 可能性のある脆弱性を警告するツールは存在しますが、完全とはいえません。 何を見つければよいかを理解している開発者が行う信頼できるコード レビューに勝るものはありません。 Michael Howard および David LeBlanc の著書 「プログラマのためのセキュリティ対策テクニック」 には、 高度なセキュリティを持つアプリケーションを記述するときに、 ここで紹介しなかった多くの危険性を軽減する方法について、 優れた説明が掲載されています。

関連情報

Visual C++ .NET Articles (英語)