バッファ オーバーランを解消せよ!
Michael Howard
Microsoft Corporation
May 28, 2002 日本語版最終更新日 2002 年 6 月 5 日
David LeBlanc と私は、「プログラマのためのセキュリティ対策テクニック (原題 Writing Secure Code)」 の目次を決める際に、バッファ オーバーランに重点を置く必要があることを強く意識していました。あまりに多くの開発者が、コードの中であまりに多くの間違いを犯し、バッファ オーバーランが悪用される可能性を生じさせています。この記事では、バッファ オーバーランがまずい理由、これが生じる理由、そしてこれを解消する方法に焦点を当てます。
バッファ オーバーランが生じる理由
バッファ オーバーランの発生には、いくつかの前提条件があります。
- C/C++ などのタイプ セーフでない言語が使用されていること。
- バッファのアクセスやコピーが安全でない方法で行われていること。
- コンパイラがバッファを、メモリ内の重要なデータ構造の隣または近くに配置していること。
個々の条件について詳しく解説していきましょう。
バッファ オーバーランは、主に C と C++ で発生する問題であり、これらの言語は、配列の境界チェックもタイプ セーフティのチェックも行わないからです。C/C++ を使用する開発者は、メモリやマシン レジスタへの直接のアクセスなど、マシンの構造に直結したプログラムを作成することができます。これにはパフォーマンス上の利点があり、適切に書かれた C/C++ アプリケーションと同等の速度を持つ、他のアプリケーションを作成するのは困難です。バッファ オーバーランは他の言語でも発生しますが、実際に起こることは稀です。また、そのようなバグが存在したとしても、通常は開発者の責任ではなく、実行環境に問題があります。
次の条件は、アプリケーションがユーザー (または攻撃者) からデータを受け取り、そのデータをアプリケーションが保持しているバッファにコピーするときに、コピー先のバッファのサイズを無視して、バッファをオーバーフローさせるということです。言い換えると、コードは N バイトしか割り当てていなかったバッファに、N バイトよりも多くのデータをコピーします。たとえば、300 ミリリットルのグラスに 500 ミリリットルの水を注いだとします。余った 200 ミリリットルの水はどうなるでしょうか? そこら中にこぼれてしまいます!
最後の、最も重要な条件は、コンパイラがそのバッファを「興味深い」データ構造の近くに配置しているということです。たとえば、スタック上にバッファを持つ関数の場合には、メモリ内で、関数のリターン アドレスがバッファの後に配置されます。したがって、攻撃者がバッファをオーバーフローさせることができれば、関数のリターン アドレスを上書きし、攻撃者が指定したアドレスにリターンさせることができます。その他の興味深いデータ構造には、C++ の v-table、例外ハンドラのアドレス、関数ポインタなどがあります。
では、具体的な例を示しましょう。
次のコードには、どのような問題があるでしょうか?
void CopyData(char *szData) {
char cDest[32];
strcpy(cDest,szData);
// cDest を使用
...
}
驚くべきことに、このコードには何も問題がないということもありえます ! すべては、CopyData() がどのように呼び出されるかにかかっているのです。たとえば、次のコードは安全です。
char *szNames[] = {"Michael","Cheryl","Blake"};
CopyData(szNames[1]);
このコードでは、名前がハードコードされており、どの文字列も 32 文字以下なので、strcpy の呼び出しはつねに安全に実行されます。しかし、CopyData の唯一の引数である szData が、ソケットやファイルなどの信頼の置けないソースから取得される場合、strcpy は null 文字に遭遇するまでデータのコピーを行うので、データの長さが 32 文字を超えていれば、cDest バッファのオーバーランが起こり、メモリ内でそのバッファの後に置かれているデータが上書きされます。残念なことに、このケースで上書きされるのは CopyData のリターン アドレスなので、CopyData が終了すると、実行は攻撃者が指定した位置から再開されます。これが問題です!
これ以外にも、問題を生じかねないデータ構造があります。たとえば、C++ クラスの v-table が、次のようにコード内で破壊されたとします。
void CopyData(char *szData) {
char cDest[32];
CFoo foo;
strcpy(cDest,szData);
foo.Init();
}
この例は、v-table もしくは、すべての C++ クラスに対して、共通なクラス メソッドのためのアドレスのリストと同様に、クラス CFoo が仮想メソッドを持っていると仮定します。
v-table が cDest バッファの上書きによって破壊されると、クラスのすべての仮想メソッド、この例では Init() が、Init() よりもむしろ、攻撃者が指定したアドレスを呼び出すかもしれません。ところで、C++ メソッドを 1 つも呼び出していなければ、コードは安全だと思う人もいるかもしれませんが、つねに呼び出されるメソッドが 1 つあります。それは、クラスの仮想デストラクタです ! もちろん、メソッド呼び出しをまったく行わないクラスが存在する場合には、そのクラスの存在理由を疑ってみる必要があります。
バッファ オーバーランの解消
では、もう少しポジティブな話に進みましょう。コード中のバッファ オーバーランをどのように解消し、防ぐかということです。
マネージ コードへの移行
われわれは、2002 年の 2 月と 3 月に、Microsoft Windows® Security Push を開催しました。この期間中、筆者のグループは 8,500 人以上の人を対象に、安全な機能の設計、作成、テスト、およびドキュメント化に必要な事柄についてのトレーニングを提供しました。われわれがすべてのデザイナーに対して行った助言は、適切なアプリケーションとツールを、ネイティブな Win32® C++ コードからマネージ コードに移行する計画を立てるということでした。この助言を行った理由はいくつかありますが、特に重要なのは、バッファ オーバーランのリスクの軽減でした。マネージ コードでは、開発者が作成したコードからポインタ、マシン レジスタ、またはメモリに直接アクセスすることはできないため、バッファ オーバーランを含んでいるコードを作成するのははるかに困難になります。したがって、特定の種類のアプリケーションとツールについては、マネージ コードへの移行、または少なくともその計画を立てることが必要になります。たとえば、管理ツールは移行のための完全な候補になります。もちろん、現実的になる必要はあります。すべてのアプリケーションを、一晩で C++ から C# やその他のマネージ言語に移行することはできないでしょう。
黄金則に従う
C と C++ のコードを書くときには、ユーザーからのデータを管理する方法を慎重に決める必要があります。信頼の置けないソースからのデータをバッファに格納する関数では、次の規則に従うようにします。
- コードにバッファの長さを渡すよう求める。
- メモリをプロービングする。
- 防衛的なコードを書く。
各項目を詳しく見ていきましょう。
コードにバッファの長さを渡すよう求める
次のようなシグニチャを持つ関数呼び出しにはバグがあります。
void Function(char *szName) {
char szBuff[MAX_NAME];
// szName をコピーして使用する
strcpy(szBuff,szName);
}
このコードの問題は、関数が szName の長さを知らないため、データを安全にコピーすることができないということです。関数は szName のサイズを引数として取るようにする必要があります。
void Function(char *szName, DWORD cbName) {
char szBuff[MAX_NAME];
// szName をコピーして使用する
if (cbName < MAX_NAME)
strcpy(szBuff,szName);
}
ただし、cbName はそのまま信頼しないほうが良いです。攻撃者が名前とバッファ サイズを設定している可能性があるので、チェックすることを必要とします!
メモリをプロービングする
szName と cbName が有効であると、どのようにしてわかるのでしょうか? ユーザーが有効な値を渡してくれるものと信頼しますか? 一般論として、その答えは「いいえ」です。バッファ サイズが有効であることを確認するための簡単な方法の 1 つは、メモリのプロービングです。次のコード例は、その方法を示しています。
void Function(char *szName, DWORD cbName) {
char szBuff[MAX_NAME];
// プロービング
szBuff[cbName] = 0x42;
// szName をコピーして使用する
if (cbName < MAX_NAME)
strcpy(szBuff,szName);
}
このコードは、コピー先バッファの末尾に値 0x42 の書き込みを試みます。単にバッファをコピーするのではなく、わざわざこのようなことを行うのはなぜだと思う人もいるかもしれません。コピー先バッファの末尾に、固定された既知の値を書き込むことで、ソース バッファが大きすぎる場合に、コードを強制的に失敗させることができます。また、開発プロセスの早い段階で、開発上のバグを発見することもできます。攻撃者のバッファをコピーして、悪意のあるコードを実行してしまうぐらいなら、実行に失敗する方がましなのです。
注意: テストの間に、バッファ オーバーランの捕獲を助けるためのデバック構造で、これを行う必要があります。
防衛的なコードを書く
プロービングは有効ですが、これによって攻撃から完全に身を守れるわけではありません。安全性を確保するための唯一の手段は、防衛的なコードを書くことです。上記のコードはすでに防衛的になっています。関数に渡されたデータが、内部バッファの szBuff 以下であることをチェックしているからです。しかし、関数の種類によっては、信頼の置けないデータの操作やコピーを行う際に、深刻なセキュリティ上の問題が生じる可能性があります。ここで重要となるポイントは、信頼の置けないデータです。コードのバッファ オーバーランのバグを探すときには、コード内でのデータの流れを追いかけ、そのデータに関する前提条件を再検討する必要があります。前提条件の間違いに気づくと、見つけたバグに驚くことでしょう。
注意すべき関数には、strcpy、strcat、gets、その他の古典的な関数が含まれています。ただし、strncpy と strncat という、strcpy と strcat のいわゆる安全な n バージョンにも気を抜いてはなりません。これらの関数は、開発者がコピー先バッファにコピーされるデータのサイズを制限できるので、よりセキュアで安全であるとされています。しかし、開発者はこの点でも間違いを犯すのです ! たとえば、次のコードを見てください。何が問題なのかわかるでしょうか?
#define SIZE(b) (sizeof(b))
char buff[128];
strncpy(buff,szSomeData,SIZE(buff));
strncat(buff,szMoreData,SIZE(buff));
strncat(buff,szEvenMoreData,SIZE(buff));
ヒントが必要な人は、個々の文字列処理関数の最後の引数に注目してください。お手上げですか? 答えを示す前に、私がよく口にするジョークを紹介しましょう。「安全でない」文字列処理関数を禁止し、より安全な n バージョンの使用を義務づけたら、残りの人生は新しく忍び込んだバグの修正に費やすことになるのです。その理由は、次の通りです。まず、最後の引数はコピー先バッファの合計サイズではありません。これは、バッファ内にどれだけのスペースが残っているかを示す値であり、コードが buff にデータを格納するたびに、buff は小さくなっていくのです。第 2 の問題は、バッファのサイズが渡された場合でも、そのサイズは、しばしば 1 つずれているということです。あなたは、文字列サイズを計算するときに、末尾の null を入れますか? 講演会の聴衆に尋ねると、だいたいは半々に分かれます。会場の半数の人はバッファ サイズの計算に末尾の null を入れ、残りの半数は入れないと答えます。第 3 に、n バージョンは結果文字列の末尾に null を置かないことがあるので、ドキュメンテーションを確認する必要があります。
C++コードを書く場合は、バイトを直接扱うのではなく、ストリングを操作するためにATL、STL、MFC、または、お気に入りのストリングを扱うクラスを使用することを考慮してください。ただポテンシャルを下げる1つとしては実行低下の可能性があり、一般的に言えば、これらのクラスを利用することは、より強固で、より整備可能なコードに結びつきます。
/GS によるコンパイル
この Visual C++® .NET の新しいコンパイル時オプションは、スタック ベースのバッファ オーバーランの潜在的な脆弱性を軽減するために、特定の関数スタック フレームに値を挿入します。このオプションは開発者のコードを直すわけではないし、バグを解消するわけでもないことに注意してください。単に、特定の種類のバッファ オーバーランが、攻撃者がプロセスにコードを挿入して、そのコードを実行できるような悪用可能なバッファ オーバーランになる可能性を減らすための手段に過ぎません。これはちょっとした保険と考えておくのがいいでしょう。Win32 アプリケーション ウィザードを使って作成された新しいネイティブな Win32 C++ プロジェクトでは、このオプションがデフォルトで有効になっています。また、Windows Server 2003 は、このオプションを付けてコンパイルされています。詳細については、Brandon Bray の 「コンパイラ セキュリティの徹底調査」 を参照してください。
脆弱性を発見する
最後に、セキュリティ上の欠陥を少なくとも 1 つ含んでいるコードを示しておきます。この欠陥を見つけることはできるでしょうか? 答えは次の記事で示します!
WCHAR g_wszComputerName[INTERNET_MAX_HOST_NAME_LENGTH + 1];
// サーバー名を取得し、Unicode 文字列に変換する
BOOL GetServerName (EXTENSION_CONTROL_BLOCK *pECB) {
DWORD dwSize = sizeof(g_wszComputerName);
char szComputerName[INTERNET_MAX_HOST_NAME_LENGTH + 1];
if (pECB->GetServerVariable (pECB->ConnID,
"SERVER_NAME",
szComputerName,
&dwSize)) {
// 残りのコードは省略
Michael Howard は、Microsoft の Secure Windows Initiative グループの Security Program Manager であり、「プログラマのためのセキュリティ対策テクニック (原題 Writing Secure Code)」 の共著者でもあります。彼の人生における興味は、人々がセキュアなシステムの設計、構築、テスト、およびドキュメント化を行えるように手助けをすることにあります。お気に入りのセリフは、「ある人にとっての機能は、別の人にとっての悪用の手段となる」というものです。
|