VC++.NETの注目機能−真のインターネット対応機能とは−

 かって、ウィルスはFDのブートセクターやダウンロードしたプログラムの内部に存在していた。したがって、怪しいプログラムやディスクのブートセクター(早い話がFDからのブート)にさえ気をつけていれば、基本的には安全だった。それがメールに添付されたトロイの木馬であっても実際の拡張子がSCRやVBS、DOTといった(VBAやスクリプトを含む)実行プログラムであれば同じことだ。

 この常識をWindowsで最初に打ち破ったのは、UNYUN氏によるアクロバットリーダーのバグを利用したバッファオーバーラン攻撃の可能性の指摘ではないだろうか(http://www.adobe.com/misc/pdfsecurity.html)。何しろ、実行プログラムとは全く縁がないはずの単なるPDFをダブルクリックすれば、起動されたアクロバットリーダが勝手に攻撃を開始するからだ(ちなみに、アクロバットリーダのVer.5ではこのバグは修正されている)。こうなると、MFCのドキュメント/ビュー構造を利用して拡張子に紐付けを行うすべてのプログラムが、関連付けられたファイルの処理にバッファオーバーランバグを持てば攻撃対象となりうることは明らかだ。

 特に、このバッファオーバーランバグを突いて大きな被害をもたらしたCodeRedおよびCodeRedIIは記憶に新しい。これらにいたっては、Index Serverのたった1つのコンポーネントのバッファオーバーランバグのために、ポート80で通常のサービスを行っているというだけで自動的にIISが他のサーバに対して感染して攻撃を始めたわけだから厄介だ。

 このように初期のウィルスや最近のマクロウィルスと異なり、バッファオーバーラン攻撃が特に問題となるのは、実行プログラムがデータ(データファイルやHTTPリクエスト)に隠されているため検出が困難なことと、感染と発症がオンメモリーでリアルタイムに行われることだ(ちなみにCodeRedのようにファイルへ感染せずにネットワークを介して蔓延していくものは、ワームと呼ぶのが一般的だ)。

 1988年11月のインターネットワーム事件で使用された侵入方法のひとつがfingerdのバッファオーバーランバグを突いたものであったことを考えると、実に古くて新しい問題と言えなくもない。

 VC++.NETは、C#やVB.NETの登場により、主な適用領域をDirectXアプリケーションのような高度な最適化が必要となる処理にその主戦場を移すことになるだろう。そこには特にスケーラビリティやレスポンスを極限まで追求するようなWebサービス開発に対して提供されているATLサーバープロジェクトなどがある。ここでCodeRedのことを考えれば、ATLサーバープロジェクトにおいてバッファオーバーランバグが混入した場合の問題は明白だ(ま、自サイト内のみでしか動かなければそう簡単にはいきはしないが)。しかし、C++でプログラムを行う以上、スタックの使用は回避できないし、また完全にバグのない(特に全く想定しようのない攻撃手法のようなものについて検証された)ソフトウェアの作成が困難なことは実際に開発を行っている読者諸兄においては良くご存知のことであろう。だからと言って、.NET Frameworkのマネージドコードで全て解決できるかと言えば、既存資産やC++開発スキルを持ったプログラマ、極限までに最適化を施す必要がある処理といった存在を無視するわけにもいかないのもまた事実である。逆に言えばそのためのATLサーバープロジェクトだ。

 これはある意味、矛盾した状況ではある。この矛盾に対するひとつの解決策としてVC++.NETでは、新たなコンパイルオプションとしてバッファセキュリティチェックという項目が付加された(ちなみにこの機能はデフォルトでリリースビルド時には有効になっている――)。本稿では、このバッファセキュリティチェックについて取り上げてみよう。

 その前に、バッファオーバーラン攻撃について説明しておくことにしよう。通常、こういった攻撃手法はおそらく寝た子を起こさないようにという配慮からだろうが、あまり具体的には語られないものだが、ある程度まで突っ込んだ解説を行わないと、バッファセキュリティチェックが提供する機能がわかりにくいからだ。

 バッファオーバーラン攻撃の原理は以下のとおりだ。

  1. 関数の戻りアドレスはスタック上に置かれる。
  2. 自動変数はスタック上に取られる。
  3. ターゲットアプリケーションはワームコードを自動変数上にコピーする。このとき、バッファオーバーランにより1.でスタック上に置いた戻りアドレスが書き換えられる。
  4. 関数から呼び出し元への復帰時に4.で書き換えられたアドレスへ制御が移される。通常、このアドレスはバッファオーバーラン攻撃データ内のアドレス置き換え個所以降に含まれる。
 次のリストはこの攻撃方法の原理を示したものだ。もちろん、実際の攻撃は外部のプログラムに対して実行コードを抽入する仕組みであるから、こんな簡単にC++で記述できるわけではない。

buffov.c

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void foo()
{
    printf("Hello foo !\n");
    exit(0);
}

void bar()
{
    char a[16];
    long l = (long)foo;
    strcpy(&a[20], (char*)&l);
    printf("dummy\n", a); // 変数aを使用するためのダミー呼び出し
}

int main(int argc, char* argv[])
{
    bar();
    return 0;
}
 このプログラムをコマンドラインから
cl buffov.c
と入力してコンパイル/リンクを行うか、あるいはVC++6でWin32コンソールアプリケーションプロジェクトを作成し「単純アプリケーション」の中に記述してリリース版をビルドし、コマンドラインプロンプトから実行すれば、dummyの次の行にHello foo !と表示されるのが確認できるはずだ(VC++6SP5でのコード生成。ジャンプテーブルの配置によって結果は変る可能性がある)。  なお、コメントされているprintf("dummy\n", a);の行は意味を持たないが、この行を省略すると最適化により自動変数aそのものが削除されてしまうため注意が必要だ。
D:\home\Project\buffov\Release>buffov
dummy
Hello foo !
 ここでmain()から呼び出されている関数bar()の中の
strcpy(&a[20], (char*)&l);
の個所が擬似バッファオーバーラン攻撃の実体だ。もちろん、自動変数aは配列要素を16として宣言しているため、&a[20]以降に対するstrcpyがバッファオーバーランとなる。このコードによって、戻りアドレスがmain()のreturn 0;の行からfoo()の先頭アドレス(正確にはfoo()へのジャンプ命令へのポインタ)に置き換えられるため、プログラム上どこからも呼び出されていない関数foo()が実行されて、Hello foo !が表示されるというわけだ。  もちろん実際の攻撃は、例えば
char query[64];
char* pQuery = GetHTTPQueryString("MyQuery");
strcpy(query, pRequestData);  // pRequestData >= 64 でオーバーラン
のようなコードに対して、想定(ここでは最大63バイト)外のクェリーを送り込むことによって実行されることになる。

 それでは、このプログラムを今度はVC++.NETでビルドしてみよう。

 VC++.NETの「ファイル」→「新規作成」→「プロジェクト」の「Visual C++ プロジェクト」から「Win32 プロジェクト」を選択しプロジェクト名として「buffov」と入力後、表示されたダイアログの「アプリケーションの設定」タブで「コンソールアプリケーション」を選択してから「完了」ボタンをクリックする。

 生成されたbuffov.cppに、上記プログラムを入力し、「ソリューション構成」を「Release」にしてビルド、実行すると今度はが表示される。ここで「OK」をクリックするとプログラムは異常終了する。無事、foo()関数の呼び出しを防御できたようだ。すなわちバッファオーバーランを利用した攻撃によってアプリケーションは異常終了はするものの、少なくてもワームに制御を奪われることは回避できるというわけだ。

 なお、VC++.NETで生成すると、バッファセキュリティチェックをオフにした場合、VC++6SP5と異なり、関数foo()は実行されずにクラッシュする。これは、foo()へのジャンプテーブルが256の整数倍位置に配置されるため、strcpyによって1バイトのみ破壊されてアドレスそのものが置換されないためである(実際に実行される例を次に示す)。  ただし、バッファセキュリティチェックは処理効率に影響を与えないように考慮されているため、文字列処理があるからと言って必ずしもコードが生成されるわけではないことにも注意が必要だ。

 例えば、bar()を以下のように書き換えてみよう。

void bar()
{
    char a[4];
    long* l = new long[3];
    *(l + 2) = (long)foo;
    memcpy(a, l, 12);
    printf("dummy\n", a); // 変数aを使用するためのダミー呼び出し
}
 この場合は、VC++.NETでもfoo()が呼び出される。これは、char配列aのサイズが4と小さいためにVC++がバッファとして認識しないためではないかと思われる。どうもセキュリティチェックコードが生成されるのはchar配列のサイズが5以上の場合のようだ。したがって、外部から与えられた文字列のstrcpyなどの対象となる文字列は5以上を指定したほうが良いだろう。

 また、あくまでもセキュリティチェック(戻りアドレスの検証)のための機構であってバッファオーバーランそのものに対する特効薬ではないということも重要だ。すでに見たようにバッファオーバーランが発生すれば強制的に処理が中止されるか、あるいはクラッシュしてしまう。いずれにしろ、ワームによる乗っ取りは防止できるかも知れないが正常に処理を継続することははできないのだ。

 これは逆に言うと、戻りアドレスの検証しか行わないため、バッファセキュリティチェックをONにした場合の実行時オーバーヘッドがわずか(アセンブラで8ステップ程度)しかないということだ*)。

*)ここでは関数の戻り値のガードについてのみ取り上げているので、テストコードによってはそれ以外のチェックコードが生成される可能性もある。

 いずれにしろ、インターネット時代に限りなくシステムに近い位置で処理を記述するVC++プログラマは、プログラム外部から受け取った文字列(引数、データファイルの内容、HTTPリクエストなど)をバッファにコピーする場合は、

char buff[size];
strncpy(buff, arg, size);
buff[size - 1] = 0;
あるいは、
lstrcpynA(buff, arg, size);
のように、オーバーランを回避するように考慮しなければならない。また、最適化とバッファセキュリティチェックが有効にはたらくように、string関数やchar配列を使用するようにし、キャストを利用した処理は避けたほうが良い(バッファセキュリティチェックをONにするとバッファと考えられるchar配列をスタックの底のほうに移動するといった考慮も行われていることが観察できる)。

 なお余談になるが、文字列の加工にsprintf(あるいはWin32APIのwsprintf)を使用することがベテランCプログラマによって推奨されているが、これについても外部から入力された文字列を扱う場合は

char buff[128];
sprintf(buff, "入力データ:%s", arg);
のような書き方は禁物だ。既にVC++6にも実装されている_snprintf(_swnprintf)を使用して
char buff[128];
_snprintf(buff, sizeof(buff), "入力データ:%s", arg);
buff[sizeof(buff) - 1] = 0;
のように記述すべきである。

参考:http://www.trl.ibm.co.jp/projects/security/ssp/main.html


これを書いたのは2001年10月14日より前(当然、β2で調べてる)で、この後、ちょっと某所で揉まれてもう少し調べたりしたので、追補。

β2で調べたということから、リリース版では動作が変っている可能性も無いわけでは無い。しかし、米国リリース時の騒動から察するに同じなんだろうな。

戻り値の破壊を検出した場合の処理は、_set_security_error_handler()によってユーザー定義関数を設定することも可能。

チェック方法は、リターンアドレスをクッキーとXORした値をスタックに格納し、リターン直前にスタックから取り出しリターンアドレスとXORしてクッキーと等しいかチェックする。これは、何を吠えようが(なんか、米国リリース時にグシャグシャあったときに、MSの誰ぞかが独自に考えたとか言ったそうだが−zdnetで読んだような気がするがわからなかった-って言うか、公式見解じゃんhttp://www.microsoft.com/japan/msdn/visualc/compiler.asp-3/29追補-でも「独自に実装」か。確かに独自に考えたってのとは違うから、僕の書いたのも誤爆だ)まさにStackGuardそのもの。

さすがにクッキーの作成には、QueryPerformanceCounter()を使用しているので予測は不可能。

したがってセキュリティホールというわけでは無い。これは言いがかりもいいところ。しかし設計思想というのは正しい。

さて、問題は、VC++の出番が、DirectXにしろ、ATLサーバにしろ、COMがからむということだ。そして、COMの場合thisポインタをCXレジスタで渡すというわけにはいかない。そして、メソッド内では通常、this経由で関数が呼び出される。当然だが関数から退出する前にだ。

それはそれとして、hsjさんによると、SEHフレームに気をつけろということなので、そっちもよろしく。(これも防御されないに1000コリドール)

ようするに、GSオプションには意味はあるけど、そんなのに安住せずにプログラマは気をつけなさいという結論だ。

余談だが、当然ながら、外部入力と関係ないとこでは気にする必要はないわけだけど。

BOOL join(LPSTR s1, LPSTR s2)
{
    char buff[64];
    DWORD dw;
    int cb = sprintf(buff, "%s+%s=%s%s", s1, s2, s1, s2);
  return WriteFile(g_h, buff, cb, &dw, NULL);
}
なんて書いても、joinを呼び出すのが
BOOL success;
if (flag & 1)
  success = join("1", "2");
else if (flag & 2)
  success = join("3", "4");
else
  success = join("5", "6");
なんてのだけだってわかってたら、もちろん全然OK(RS232C経由でデバイス制御したりするようなヤツだったりとか)。でも、こういうのって癖になったりしないようにしようね、って話なのだ。制御系とオープン系とじゃ違うってことだったり。

上にIBMへのリンクを出してるけど、敬愛するnobu-さんのとこ(http://www.espada.net/~nobu-/)にも資料がある。こちらもよろしく。


戻る
Copyright(c) 2001-2002 arton  under GNU Public License
Last modified: Fri Mar 29 01:10:11 LMT 2002