これまでの連載では,バッファ・オーバラン(BOR)の発生メカニズムとその発生防止コードをアセンブラ・レベルで詳しく見てきました。BORは担当開発者のちょっとした不注意で簡単に発生してしまいます。また,BORはソースコードにほんの少しのコードを追加するだけで,その発生を未然に防止することができます。
BORの発生メカニズムと対処方法はこのようにきわめて単純ですから,BOR管理作業は私たち人間が行うのではなく,単純な作業をもっとも得意とするコンピュータ(厳密には,コンパイラ)に任せてしまったほうが得策というものです。
そこで今回は,BOR管理用のコンパイラ・オプションとその背景をアセンブラ・レベルで詳しく見てみます。結論を先に言うと,これから説明するコンパイラ・オプションは,単純なデータとそれを操作する関数を自動的に追加挿入しているにすぎません。プログラミング初心者でも理解できるほど単純かつ明解な仕掛けです。それではさっそく,このあたりの事情をいつものようにアセンブラ・レベルで見てみましょう。
図1●2種類のBOR発生管理コンパイラ |
BOR発生防止管理コンパイラ・オプション
MicrosoftのVisual Studio .NET2003統合開発環境は,「基本ランタイム・チェック」と「バッファのセキュリティ・チェック」の2種類のBOR発生管理コンパイラを用意しています(図1[拡大表示])。図1の「コード生成」という分類項目から分かるように,これら2つのオプションは,コンパイラの中間コード生成機能を制御します。今回は,紙面の制約上,オプション2の「バッファ・セキュリティ・チェック」のみを有効にし,生成されるコードとその機能背景をアセンブラ・レベルで詳しく見てみることにします。もう1つのオプションである「基本ランタイムチェック」の説明は後日行う予定です。
一部のデバッグ専門家は,このオプションを利用できるだけでも,Visual Studio .NET 2003統合開発環境を購入する価値がある,と述べています。なお,Visual Studio .NET 2003をお持ちでない方は,評価版Visual Studio .NET 2003を収録したDVDが付属している,拙著「Visual Studio .NET 2003 オブジェクト指向ですっきり分かる クラスと名前空間」などを購入するのも1つの選択肢です。評価版を使用した後,(気に入ったら)製品版を購入するとよいと思います。
今回のサンプル・プログラム
それでは今回のサンプル・プログラムを紹介しましょう。今回は次のようなソースコードを用意してみました。このサンプル・プログラムもプログラミング言語Cで記述しています。
#include <windows.h> #include <stdio.h> #define buffer_size 10 void Server(char* pbData) { unsigned int cb = strlen(pbData); char buff [buffer_size]; CopyMemory(buff,pbData,cb); printf(buff); } int main() { char* pDes[] = {"IT Pro","Takashi Toyota","No!"}; Server(pDes[0]); Server(pDes[1]); Server(pDes[2]); return 0; }
プログラム構造は,前回のサンプル・プログラムと基本的に変わりはありません。しかし,構文的には,ポインタ配列が使われ,結構高度なものとなっています。プログラミング経験のある方は,変数がポインタであることを示すアスタリスク(*)の記述位置に自然に注意が向くと思います。そのような人の中には,「char* pDes[]」ではなく,「char *pDes[]」と記述したほうがよいのではないか,と考えている人もいるはずです。私は時折,「char * pDes[]」と記述する人も見かけます。
このあたりのコーディングスタイルに興味のある方は,C++を設計・実装したBjarne Stroustrup氏のサイトを訪問してみるとよいでしょう。同氏独特の説明が一般公開されています。ここでは,Bjarne Stroustrup氏の主張に沿ってコーディングしています。
それでは,コンパイルする前に,このサンプル・プログラムの特徴を簡単に説明しておきます。プログラム内では,Server関数を3回呼び出しています。3回の呼び出しでは,異なる長さを持つ文字列をServer関数に渡しています。前回までのサンプル・コードでは,文字列の長さをmain関数内部で計算していましたが,今回のサンプルでは,Server関数内部で計算しています。
この計算処理のmainからServerへの移動は現実を反映しています。この種のServer関数をインターネットから公開した場合,クライアントは文字数を計算した上で,計算結果とリクエストを出してくるようなことは基本的にありません。表現を変えれば,Server関数は,自分を守るために,自分が受け取る文字列の長さを自分の責任で算出している,といえます。
図2●エラー情報 |
ご覧のように,バッファ・オーバラン(BOR)が検出されています。BORが発生した原因はおわかりですね。Server関数のソースコードをみると,前回紹介したminマクロが使われていません。つまり,このサンプル・プログラムではデータ転送量の管理(より具体的には,転送元バッファサイズと転送先バッファサイズの比較処理)を怠っています。
ところで皆さんは,このエラー情報を見てどのような印象を持ちますか?「バッファ・セキュリティ・チェック」オプションはきわめて大切な機能を提供していますが,BORの検出を通知してくれるだけで,「そのBORがどこで発生したか」という,私たちが本当に知りたい情報は一切提供してくれません。今回のサンプル・プログラムでは,Server関数を3回呼び出していますが,何回目の呼び出しで問題が発生しているのかを知りたい場合には,私たちが時間をかけて特定する必要があります。
もちろん,事前に用意されているエラー処理ルーチン(専門的には,エラー・ハンドラ)を独自のもので差し替えることは可能ですが,そのためには,かなりの専門知識が必要になります。より詳しい情報を表示するエラー・ハンドラをデフォルトで用意してほしいものです。なお,後日説明する「基本ランタイム・チェック」オプションは,問題の発生個所を通知する機能を持っています。
BOR発生検出プロセスの検討
それでは,BORが検出される過程をアセンブラ・レベルで詳しく見てみましょう。皆さんの中には,これから大変難しい説明が始まるのではないか,と不安を持っている人もいると思います。しかし,これまでの連載記事から分かるように,BORは簡単に発生するとともに,その発生も簡単に抑制することができます。このため「バッファ・セキュリティ・チェック」オプションが自動追加するBOR発生検出コードも信じられないほど単純なものなのです。ここで,2種類のアセンブラ・コードをお見せします。最初のコードは,「バッファ・セキュリティ・チェック」オプションを無効にしたときに生成されるものです。2番目のコードは,同オプションを有効にしたときに,コンパイラが生成してくれるアセンブラ・コードです。まずは比較しながら,じっくりご覧ください。
(「バッファ・セキュリティ・チェック」オプション無効時に 生成されるアセンブラ・コード) void Server(char* pbData) { 00401020 push ebp 00401021 mov ebp,esp 00401023 sub esp,10h unsigned int cb = strlen(pbData); 00401026 mov eax,dword ptr [pbData] 00401029 push eax 0040102A call strlen (4014F0h) 0040102F add esp,4 00401032 mov dword ptr [cb],eax
(「バッファ・セキュリティ・チェック」オプション有効時に 生成されるアセンブラ・コード) void Server(char* pbData) { 00401020 push ebp 00401021 mov ebp,esp 00401023 sub esp,14h ←注目1 00401026 mov eax,dword ptr [___security_cookie (423B90h)] ←注目2 0040102B mov dword ptr [ebp-4],eax ←注目3 unsigned int cb = strlen(pbData); 0040102E mov eax,dword ptr [pbData] 00401031 push eax 00401032 call strlen (4014F0h)
これら2つのアセンブラ・コード間にある相違は,「注目1」から「注目3」の3点です。これら3点は次のような意味を持っています。
注目1:事前に用意された特殊なデータ(___security_cookie)を保存するための4バイトの領域を余分に確保する
注目2:特殊なデータ(___security_cookie)をEAXレジスタに一時的に退避する
注目3:EAXレジスタに退避しておいた___security_cookie値を[ebp-4]に保存する
図3●___security_cookie値保存後の[ebp-4]周辺データ |
紫の四角で囲まれた3つのデータに注目してください。左端のデータは___security_cookie値,次のデータはベース・ポインタ値,そして3番目のデータは関数実行後に戻るべきアドレスです。第4回で説明した,BORが発生するということは,これら3つのデータのうちの,ベース・ポインタ値と戻りアドレスが変更されることを意味します。ところで皆さん,ベース・ポインタ値が変更されるということは,その直前に置かれている___security_cookie値も変更されてしまうことにはなりませんか? あるメモリー・ブロックから別のメモリー・ブロックにデータを転送することは,転送先メモリブロックの先頭アドレスから(デフォルトでは)上位アドレスに向かって転送データが格納されることです。つまり,___security_cookie値の変更は,BORの発生そのものを意味するのです。それではBOR発生を検出する次のようなコードを見てみましょう。
printf(buff); 00401051 8D 4D F0 lea ecx,[buff] 00401054 51 push ecx 00401055 E8 86 00 00 00 call printf (4010E0h) 0040105A 83 C4 04 add esp,4 0040105D 8B 4D FC mov ecx,dword ptr [ebp-4] ←注目4 00401060 E8 1B 05 00 00 call __security_check_cookie (401580h) ←注目5 00401065 8B E5 mov esp,ebp 00401067 5D pop ebp
このアセンブラ・コードでは,関数呼び出しが2回発生しています。1回目は,printf関数が呼び出されています。注目は,第2回目の関数呼び出しです。 __security_check_cookieという名前の関数が呼び出されています。この関数は,「バッファ・セキュリティ・チェック」オプションによって追加挿入された関数ですが,名称が___security_cookieデータと似ていますから,混乱しないようにしてください。
プログラミング経験のある方は,「注目4」に自然に視線が向くと思います。[ebp-4]の値(保存しておいた__security_cookie値)をスタックレジスタ経由ではなく,ECXレジスタ経由で__security_check_cookie関数に渡しています。ここでは結論だけを申し上げますが,これは,処理の高速化を図るテクニックの1つです。
図4●BOR発生時の[ebp-4]周辺データ |
[ebp-4]に保存しておいた__security_cookie値は見事に破壊されています。保存しておいた__security_cookie値は15570927でしたが,データ転送後はccbed811に変化しています。セキュリティ・オプションを有効にしていなければ,EBP値と(最悪の場合)戻りアドレス値も破壊されるところでした。
今度は,__security_check_cookie関数がBORを検出する過程を追ってみましょう。次のコードをご覧ください。
void __declspec(naked) __fastcall __security_check_cookie(DWORD_PTR cookie) 00401580 cmp ecx,dword ptr [___security_cookie (423B90h)] ←注目6 00401586 jne 401589h 00401588 ret 00401589 jmp report_failure (401590h)
いかがでしょうか?皆さんもかなりアセンブラ・コードを読むことに慣れてきたのではないでしょうか。「注目6」を見ると分かるように,オリジナル___security_cookie値と保存しておいた___security_cookie値(ecx値)を比較しています。すでに紹介したように,保存しておいた___security_cookie値はすでに変更されていますから,比較処理の結果,ジャンプ命令(「jne 401589h」)が実行され,プログラムの実行権はアドレス00401589のコードに移ります。その後最終的には,report_failure関数が実行され,最初にご覧いただいたようなBOR発生検出画面が表示されることになります。
なお,__security_check_cookie関数は通常の関数ではありません。関数にはそれを呼び出すための約束事(呼び出し規約といいます)があるのですが,この__security_check_cookie関数は「__declspec(naked) __fastcall」という規約を採用しています。通常は「int __cdecl」などになっているものです。これは一般には,処理の高速化とコンパイラの介入を抑える目的で使われます。ちょっと難しくなりますが,「コンパイラの介入を抑える」ことはセキュリティ対策の1つのテクニック,でもあるのです。セキュリティ・オプションが自動挿入した関数が低速で,かつ,セキュリティ・ホールの原因を作るようでは困りますね。
今回のまとめ
- 重要な機能は,単純かつわかりやすい技術で実装されていることがある
- 関数呼び出し規約を応用すると,プログラムの動作を高速化できる
今回は以上で終了です。次回またお会いいたしましょう。ごきげんよう!