December 2017

Volume 32 Number 12

C++ - スタックベースのバッファーを保護するための Visual C++ サポート

Hadi Brais | December 2017

ソフトウェアの動作がその機能仕様の想定と異なる場合、そのソフトウェアには欠陥やバグがあるといえます。データなどのリソースへのアクセスや変更を定める機能仕様のルールには、1 つのセキュリティ ポリシーをまとめて構成できるようにする必要があります。セキュリティ ポリシーでは、基本的に、ソフトウェアのセキュリティを確保する条件と、特定の欠陥が単なる新たなバグではなく、セキュリティ上の不備と見なすべき状況を定義します。

世界中でさまざまな脅威が発生している状況から、現在、セキュリティの重要性がかつてないほど高まっています。そのため、ソフトウェア開発ライフサイクル (SDL) の一環としてセキュリティの確保は不可欠です。セキュリティの確保には、データの保存先、使用する C/C++ ランタイム API、ソフトウェアの安全性を向上できるツールなどをどのように選択するかも関係します。C++ Core Guidelines (bit.ly/1LoeSRB、英語) に従うと、適切かつ保守が容易なコードの記述が容易になります。また、Visual C++ コンパイラには、コンパイラ スイッチを使って簡単にアクセスできる多数のセキュリティ機能が用意されています。こうした機能は、静的セキュリティ分析と動的セキュリティ分析のいずれかに分類されます。静的セキュリティ チェックの例には、/Wall スイッチと /analyze スイッチ、C++ Core Guidelines チェッカーの使用などがあります。これらのチェックは静的に実行され、生成されるコードには影響しません。ただし、コンパイル時間は確実に長くなります。一方、動的チェックは出力される実行可能バイナリに挿入されます。この挿入はコンパイラまたはリンカーで実行されます。今回は、動的セキュリティ分析オプションの 1 つ /GS スイッチを取り上げます。このスイッチは、スタックベースのバッファー オーバーフローに対する保護を提供します。このスイッチがオンになっているときにコードが変換されるしくみや、コードのセキュリティを確保できる場合とできない場合についても説明します。ここでは、Visual Studio Community 2017 を使用します。

そのようなコンパイラ スイッチがあるなら、すべてオンにすればよいのにと思うかもしれません。一般的に、そのしくみを理解しているかどうかにかかわらず、推奨のスイッチはすべて使用することをお勧めします。  ただし、特定の手法の原理を詳しく把握しておくと、コードへの影響を見極められるようになり、さらに適切な活用方法を判断できるようになります。一例として、バッファー オーバーフローを考えてみます。コンパイラでは確かにそのような欠陥に対処するスイッチが用意されています。しかし、このスイッチで採用される検出メカニズムは、バッファー オーバーフローが検出された場合に、強制的にプログラムをクラッシュさせます。これでセキュリティは改善されるでしょうか。この答えは場合によって変わります。まず、バッファー オーバーフローはすべて不適切なものですが、そのすべてがセキュリティの脆弱性につながるわけではありません。そのため、必ずしもエクスプロイトが発生することは意味しません。また、エクスプロイトが行われたとしたら、検出メカニズムがトリガーされたときには既に損害が生じているかもしれません。さらに、アプリケーションの設計方法によっては、プログラムを突然クラッシュさせることが適切でない場合もあります。そのしくみ自体がサービス拒否 (DoS) 攻撃の脆弱性になったり、データの損失や破損を伴うさらに深刻な事態を引き起こす恐れもあります。  この後説明していきますが、唯一合理的な対策は、保護メカニズムを無効にしたり変更することではなく、そのようなクラッシュに対するアプリケーションの抵抗力を高めることです。

これまで MSDN マガジン向けにコンパイラの最適化に関するコラムを多数執筆してきました (最初のコラム、msdn.com/magazine/dn904673 を参照してください)。その主な目標は実行時間を短縮することでした。セキュリティも、コンパイラ変換の目標の 1 つと考えることができます。つまり、実行時間を最適化する代わりに、潜在的なセキュリティの欠陥を減らすことでセキュリティを最適化します。この捉え方は役に立ちます。というのも、実行時間も短縮し、セキュリティも強化しようと複数のコンパイラ スイッチを指定すると、コンパイラに複数の目標が生じ、それらの目標が矛盾する可能性があるためです。この場合、これらの目標を何らかの形でバランスを取るか、優先順位を付ける必要があります。ここでは、コードの一部の要素に対する /GS スイッチの影響、特に、速度、メモリ使用量、実行可能ファイルのサイズに与える影響について説明します。このように目標が矛盾することも、コードのスイッチの影響を説明する 1 つの理由です。

ここからは制御フロー攻撃を取り上げ、特にスタック バッファー オーバーフローに注目します。このようなオーバーフローが起こるしくみと、攻撃者がそれをエクスプロイトする方法を説明します。その後、/GS スイッチがコードに及ぼす影響の詳細と、/GS スイッチによってスタック バッファー オーバーフローのエクスプロイトをどの程度軽減できるかを見ていきます。最後に、BinSkim 静的バイナリ分析ツールを使用して、ソース コードがなくても、特定の実行可能バイナリに対して多数の重要な検証チェックを実行する方法を紹介します。

制御フロー攻撃

バッファーとは、処理対象のデータを一時的に保持するメモリのブロックです。バッファーはランタイム ヒープから割り当てるか、Windows VirtualAlloc API を直接使用してスレッド スタックから割り当てるか、グローバル変数として割り当てることができます。ランタイム ヒープからバッファーを割り当てるには、C のメモリ割り当て関数 (malloc など) か、C++ の new 演算子を使用します。スタックからバッファーを割り当てるには、自動配列変数または _alloca 関数を使用します。バッファーの最小サイズは 0 バイト、最大サイズは最大空きブロックのサイズによって決まります。

C と C++ プログラム言語には、C# などの他の言語から差別化する固有の特徴が 2 つあります。

  • ポインターに任意の演算を実行できる。
  • (OS の視点から見て) ポインターの参照先が割り当て済みのメモリである限り、いつでもポインターを正しく逆参照できる。ただし、ポインターの参照先がその所有メモリでない場合は、アプリケーションの動作が不明確なものになる可能性がある。

こうした特徴によってこの 2 つの言語は非常に強力になります。しかし、これらは同時に大きな脅威にもなります。特に、バッファーの内容にアクセスしたり、反復処理を行うポインターは、誤ってまたは悪意を持って、バッファーの境界外を参照するように変更できます。その結果、隣接するメモリやその他の場所のメモリの読み取りや書き込みが実行されます。バッファーの最大アドレスよりも後ろに書き込むことをバッファー オーバーフローと呼びます。バッファーの最小アドレス (このバッファーのアドレス) よりも前に書き込むことをバッファー アンダーフローと呼びます。

スタックベースのバッファー オーバーフローの脆弱性は、非常に人気が高いソフトウェア (名前は出せません) で最近発見されました。これは、sprintf 関数を安全性でない方法で使用したことから発生したものです。これを次のコードで示します。

sprintf(buffer, "A long format string %d, %d", var1, var2);

このバッファーはスレッド スタックから割り当てられており、固定サイズです。ただし、バッファーに書き込まれる文字列のサイズは、指定された 2 つの整数を表現するのに必要な文字数によって変わります。考えられる最大の文字列を保持するには、バッファーのサイズでは不十分です。そのため、大きな整数が指定されるとバッファー オーバーフローが発生します。オーバーフローが発生すると、スタック上方の隣接メモリが破損します。

これが危険である理由を示すために、宣言する関数のスタック フレームにおいて、スタックから割り当てられたバッファーが通常どこに配置されるかを考えてみます。このとき、その配置は標準の x86 呼び出し規約に従い、コンパイラの最適化も考慮に入れます。これを図 1 に示します。

代表的な x86 スタック フレーム
図 1 代表的な x86 スタック フレーム

まず、呼び出し元は、レジスタ経由では渡されない引数を特定の順序でスタックにプッシュします。次に、x86 の CALL 命令により、リターン アドレスがスタックにプッシュされ、呼び出し先の最初の命令にジャンプします。フレーム ポインターの省略 (FPO) の最適化が行われない場合は、呼び出し先により現在のフレーム ポインターがスタックにプッシュされます。最適化で除去されなかった例外処理構造体が呼び出し先で使用される場合は、例外処理フレームが次にスタックに配置されることになります。このフレームには、呼び出し先で定義される例外ハンドラーへのポインターと、その例外ハンドラーに関するその他の情報が含まれます。最適化で除去されなかった非静的ローカル変数のうち、レジスタでは保持できない、またはレジスタから吐き出された非静的ローカル変数が、特定の順序でスタックから割り当てられます。次に、呼び出し先で保存するレジスタを、スタックに保存する必要があります。これらのレジスタは呼び出し先で使用されます。最後に、_alloca を使用して割り当てられる動的なサイズのバッファーが、スタック フレームの一番下に配置されます。

スタック上のデータ項目にはいずれも特定の配置要件が存在することがあります。そのため、必要に応じてパディング ブロックが割り当てられる可能性があります。呼び出し先のコード要素のうち、スタック フレームを設定するものはプロローグと呼ばれます (引数は除きます)。関数がその呼び出し元に戻る際には、エピローグと呼ばれるコード要素によって、リターン アドレスとそれ以前のスタック フレームの割り当て解除が実行されます。

x86 や x64 の呼び出し規則と ARM 呼び出し規則が主に異なるのは、ARM ではリターン アドレスとフレーム ポインターがスタック上ではなく、専用のレジスタに保持される点です。とはいえ、スタック上の他の値はポインターである可能性があるため、スタック バッファーの境界外アクセスは、ARM でもセキュリティの深刻な問題になります。

スタック バッファー オーバーフロー (バッファーの上限よりも上への書き込み) では、バッファーよりも上に保持されるコードやデータ ポインターが上書きされる可能性があります。スタック バッファー アンダーフロー (バッファーの下限よりも下への書き込み) では、呼び出し先で保存するレジスタの値が上書きされる可能性があります。これはコードの場合もあれば、データ ポインターの場合もあります。任意の境界外書き込みが行われると、それがきっかけとなってアプリケーションがクラッシュするか、定義されていない形で動作することになります。ただし、攻撃者は、悪意を持って作り上げた攻撃を仕掛けることで、アプリケーションまたはシステム全体の実行を制御できるようになります。これは、攻撃者の目的を実行するコード要素がコード ポインター (リターン アドレスなど) の参照先になるように、コード ポインターを上書きすることで実現されます。

GuardStack (GS)

スタックベースの境界外アクセスを軽減するために、(特定のポインターが境界内にあるかどうかを確認する if ステートメントを追加することで) 必要な境界チェックを手動で追加したり、これらのチェックを実行する API (snprintf など) を使うことができます。ただし、さまざまな理由により、脆弱性が残る可能性はあります。たとえば、バッファーの境界を判断したり、境界チェックを実行するために、不適切な整数演算や、型の変換を使用する場合などです。そのため、エクスプロイトの可能性を阻止または軽減する動的なメカニズムが必要になります。

総合的な軽減手法としては、アドレス空間をランダム化することや、非実行可能スタックを使うことなどが挙げられます。専用の軽減手法はその目標に応じて次のいずれかに分類できます。1 つは、発生前の境界外アクセスをキャプチャすることでそのアクセス自体を防ぐこと、もう 1 つは、境界外アクセスの発生後のいずれかの時点でそのアクセスを検出することです。どちらも可能ですが、阻止する場合はパフォーマンスのオーバーヘッドが大幅に増えます。

Visual C++ コンパイラには 2 つの検出メカニズムが用意されています。これらはやや似ていますが、目的が異なり、パフォーマンスへの影響にも差があります。1 つ目のメカニズムはランタイム エラー チェックの一部です。これは、/RTC スイッチを使用して有効にできます。2 つ目は GuardStack です (Visual Studio のドキュメントとセキュリティ チェックではバッファー セキュリティ チェックと呼ばれています)。これは、/GS スイッチを使うことで有効にできます。

/RTC スイッチを使用すると、コンパイラによって小さな追加メモリ ブロックがインターリーブ方式でスタックから割り当てられ、スタック上のすべてのローカル変数がこうした 2 つの追加メモリ ブロック間に挟み込まれます。これらの各追加ブロックには特別な値 (現在は 0xCC) が設定されます。この処理は呼び出し先のプロローグにより行われます。エピローグではランタイム関数が呼び出され、これらのブロックが破損したかどうかが確認されます。その後、バッファー オーバーフローまたはバッファー アンダーフローが発生している可能性が報告されます。この検出メカニズムではパフォーマンスとスタック領域に多少オーバーヘッドが増えます。ただし、このメカニズムは軽減策としてだけでなく、プログラムの正確性をデバッグして確保する目的でも使用できるように設計されています。

一方、GuardStack はオーバーヘッドが小さくなるように設計されており、エクスプロイトされる恐れのある運用環境でも実際に機能する軽減策になっています。そのため、/RTC はデバッグ ビルドに使用し、GuardStack は両方のビルドに使用することをお勧めします。また、コンパイラでは、コンパイラを最適化しながら /RTC を使用することはできません。一方、GuardStack はコンパイラの最適化と互換性があり、その最適化を妨げることはありません。既定では、Visual C++ プロジェクトのデバッグ構成ではどちらも有効になりますが、リリース構成では GuardStack しか有効になりません。ここでは、GuardStack のみについて詳しく説明します。

GuardStack が有効になっている場合、代表的な x86 コール スタックは図 2 のようになります。

GuardStack (/GS) を使用して保護される代表的な x86 スタック フレーム
図 2 GuardStack (/GS) を使用して保護される代表的な x86 スタック フレーム

図 1 に示したスタック レイアウトと比較すると 3 つの違いがあります。第 1 に、クッキーまたはカナリアと呼ばれる特別な値がローカル変数のすぐ上に割り当てられています。第 2 に、オーバーフローが表面化する可能性のより高いローカル変数が、他のすべてのローカル変数よりも上に割り当てられています。第 3 に、バッファー オーバーフローに特に影響されやすい一部の引数が、ローカル変数よりも下の領域にコピーされています。当然、これらの変更を行うには、別のプロローグとエピローグを使用します。これについては後ほど説明します。

x64 では、保護される関数のプロローグには次のような追加命令が含まれることになります。

sub         rsp,8h
mov         rax,qword ptr [__security_cookie] 
xor         rax,rbp 
mov         qword ptr [rbp],rax

追加の 8 バイトがスタックから割り当てられ、__security_cookie グローバル変数の値のコピーに初期化されます。その後、この値と RBP レジスタに保持された値との間で XOR 演算が行われます。/GS が指定されていると、gs_cookie.c ソース ファイルから作成されたオブジェクト ファイルがコンパイラによって自動的にリンクされます。このファイルでは __security_cookie が、x64 の場合は uintptr_t 型の 64 ビットのグローバル変数として、x86 の場合は同じ型の 32 ビットのグローバル変数として、それぞれ定義されています。そのため、/GS を指定してコンパイルされた各移植可能な実行可能 (PE) イメージには、このグローバル変数の単一の定義が含まれ、この変数がそうした PE イメージの関数のプロローグとエピローグで使用されます。x86 でも、32 ビット レジスタとクッキーが使用される点を除いて、コードは同じです。

セキュリティ クッキー使用の背景にある基本的な考え方は、関数から戻る直前に、そのクッキーの値が参照クッキー (グローバル変数) の値と異なっているかどうかを検出するというものです。これにより、エクスプロイトの試みまたは単なる純粋なバグによって引き起こされる、潜在的なバッファー オーバーフローが示されます。クッキーのエントロピが非常に高いことがきわめて重要です。それにより、攻撃者による推測が非常に難しくなります。特定のスタック フレームで使用されているクッキーを攻撃者が見つけ出せる場合、GuardStack は失敗します。GuardStack で可能なことと不可能なことについての詳細は、後ほど説明します。

コンパイラがイメージを出力するときに、参照クッキーに任意の定数値が渡されます。そのため、基本的にはコードを実行する前に、参照クッキーを慎重に初期化しなければなりません。最近のバージョンの Windows では GuardStack が認識され、読み込み時にクッキーが高エントロピの値に初期化されます。/GS が有効になっている場合、EXE または DLL のエントリ ポイントではまず、__security_init_cookie を呼び出すことでこのクッキーが初期化されます。なお、この __security_init_cookie は gs_support.c で定義され、process.h で宣言されています。イメージのクッキーが Windows ローダーによって適切に初期化されていなくても、この関数によりクッキーが初期化されます。

RBP で XOR 演算を実行していないと、実行中の任意の時点で (境界外読み取りなどを使用して) 参照クッキーをリークするだけで、十分 GuardStack を妨害できることに注意してください。RBP で XOR 演算を実行することで、効率的にさまざまなクッキーを生成できるようになります。そのため、攻撃者が 1 つのスタック フレームのクッキーを突き止めるには、参照クッキーと RBP の両方を把握することが必要になります。RBP 単体では高エントロピは保証されません。その値が、コンパイラによるコードの最適化方法、それまでに使用されたスタック領域、Address Space Layout Randomization (ASLR) により実行されたランダム化 (有効にされている場合) によって変わるためです。

x64 では、保護される関数のエピローグには次のような追加命令が含まれることになります。

mov         rcx,qword ptr [rbp]
xor         rcx,rbp 
call        __security_check_cookie
add         esp,8h

まず、スタックのクッキーに対して XOR 演算が実行され、参照クッキーと同じになる予定の値が生成されます。コンパイラが出力する命令により、プロローグとエピローグで使用されている RBP の値が同じであることが確認されます (何らかの方法で破損していない限り)。

vcruntime.h で宣言された __security_check_cookie 関数がコンパイラによってリンクされます。スタック上のクッキーを検証することがその目的です。これは主に、そのクッキーを参照クッキーと比較することで実行されます。確認に失敗した場合、コードは gs_report.c で定義されている __report_gsfailure 関数にジャンプします。Windows 8 以降では、この関数は __fastfail を呼び出すことでプロセスを終了します。他のシステムでは、この関数は可能性のあるハンドラーを削除した後に UnhandledExceptionFilter を呼び出すことでプロセスを終了します。どちらの方法でも、Windows エラー報告 (WER) によってそのエラーがログに記録され、どのスタック フレームのセキュリティ クッキーが破損しているかについての情報が格納されます。

Visual C++ 2002 で /GS が初めて導入されたときには、コールバック関数を指定することで、エラーが発生したスタック クッキー チェックの動作をオーバーライドできました。しかし、スタックが未定義状態になり、オーバーフローが検出される前に一部のコードが実行済みになっていたため、その時点で確実にできることはほとんどありませんでした。そのため、Visual C++ 2005 以降のバージョンではこの機能が削除されました。

GuardStack のオーバーヘッド

オーバーヘッドを最小限に抑えるには、コンパイラから脆弱だと見なされている関数のみを保護します。さまざまなバージョンのコンパイラでは、ドキュメントに記載されていない各種アルゴリズムを使用して、関数が脆弱であるかどうかを判断する場合があります。ただし、通常、関数で配列または大きなデータ構造を定義していて、そのようなオブジェクトへのポインターを取得している場合、その関数は脆弱だと見なされると考えられます。特定の関数の宣言に __declspec(safebuffers) を適用することで、その関数を保護しないことを指定できます。しかし、保護されている関数内でインライン化されている関数に対してこのキーワードを適用した場合や、保護されている関数が目的の関数内でインライン化されている場合は、このキーワードが無視されます。また、strict_gs_check プラグマを使用して、強制的にコンパイラで 1 つ以上の関数を保護することもできます。/sdl を使用することにより有効になるセキュリティ開発ライフサイクル (SDL) チェックでは、すべてのソース ファイルとその他の動的なセキュリティ チェックに対して、厳密な GuardStack が指定されます。

GuardStack は脆弱なパラメーターを、ローカル変数よりも下にあるより安全な場所にコピーするため、オーバーフローが発生しても、これらのパラメーターは破損しづらくなります。ポインターまたは C++ 参照であるパラメーターは、脆弱だと見なされることがあります。詳細については、/GS のドキュメントを参照してください。

ここでは、パフォーマンスとイメージ サイズの両方に関連するオーバーヘッドを確認するために、C/C++ の運用アプリケーションを使用して多数の実験を実施しました。strict_gs_check をすべてのソース ファイルに適用しているため、その結果はコンパイラによってどの関数が脆弱と見なされているかとは無関係です (/sdl を使用すると固有のオーバーヘッドがある他のセキュリティ チェックが有効になるため、/sdl は使用していません)。その結果、最大のパフォーマンス オーバーヘッドは 1.4% で、最大のイメージ サイズ オーバーヘッドは 0.4% でした。最悪のシナリオが発生するとしたら、ほとんど動作することのない保護されている関数を呼び出すために、プログラムで多くの時間が費やされれている場合でしょう。適切に設計された実際のプログラムでは、そのような動作にはなりません。GuardStack では、スタック領域で無視できないオーバーヘッドが発生する可能性があることも覚えておいてください。

GuardStack の有効性について

GuardStack は特定の種類の脆弱性のみを軽減するように設計されています。つまり、スタック バッファー オーバーフローの軽減です。さらに重要なことに、この脆弱性に対して GuardStack を単独で使用しても、高度な保護が提供されない可能性があります。次のように、攻撃者が GuardStack を回避する方法があるためです。

  • 破損したクッキーが検出されるのは、関数から戻るときのみです。クッキーが破損してからその破損が検出されるまでの間に、多くのコードが実行される恐れがあります。そのようなコードでは、クッキーより上か下にあるスタックの他の値が上書きされて、使用される場合があります。これにより、アプリケーションの実行を (部分的に) 制御するチャンスが攻撃者にもたらされます。この場合、検出がまったく行われないことさえあります。
  • クッキーが上書きされていなくても、バッファー オーバーフローが起こる可能性はあります。最も危険なケースは、_alloca を使用して割り当てられたバッファーがオーバーフローする場合です。この場合、保護されている引数や呼び出し先で保存するレジスタでさえ、上書きされる恐れがあります。
  • 境界外メモリ読み取りを使用することで、クッキーの一部がリークする場合があります。さまざまなイメージで異なる参照クッキーが使用されており、またクッキーは RBP で XOR 演算されているため、攻撃者がリークしたクッキーを利用することは難しいでしょう。しかし、Windows Subsystem for Linux (WSL) に導入された別の方法によりクッキーがリークする恐れもあります。WSL では Linux のフォーク システム呼び出しがエミュレートされるようになっており、この呼び出しによって、親プロセスを複製する新しいプロセスが生成されます。攻撃を受けているアプリケーションで、受信クライアント要求を処理する新しいプロセスがフォークされた場合、悪意のあるクライアントによりごく少数の要求が発行され、セキュリティ クッキーの値が突き止められる可能性があります。
  • 特定の状況におけるイメージの参照クッキーを推測するために、多数の手法が提案されています。参照クッキーが推測されて実際に攻撃が成功した事例は認識していませんが、成功の確率は無視できるほど小さくはありません。RBP で XOR 演算を実行することで、そのような攻撃に対する非常に重要な防御層が 1 つ追加されます。
  • GuardStack によって脆弱性は軽減されますが、そのために DoS やデータ損失を筆頭とする別の潜在的な脆弱性を招くことになります。クッキーの破損を検出すると、アプリケーションが直ちに終了します。サーバー アプリケーションの場合、攻撃者はサーバーのクラッシュを引き起こすことができます。その結果、重要なデータが損失したり、破損する恐れがあります。

したがって、まず、静的分析ツールの助けを借りて、適切で安全なコードを記述するよう努めることが重要です。次に、多層防御戦略に従い、GuardStack と、Visual C++ で提供されているその他の動的な軽減策 (リリース ビルドではその多くが既定で有効化されます) を、リリースするコードで採用します。

/GS と /ENTRY

EXE または DLL ファイルを生成するコンパイル時にコンパイラで指定される既定のエントリ ポイント関数 (*CRTStartup) では、次の処理が順に実行されます。参照セキュリティ クッキーの初期化、C/C++ ランタイムの初期化、アプリケーションの main 関数の呼び出し、およびアプリケーションの終了という 4 つの処理です。カスタム エントリ ポイントを指定するには、リンカーの /ENTRY スイッチを使用します。ただし、カスタム エントリ ポイントと /GS の影響が合わさると、興味深いシナリオにつながることがあります。

カスタム エントリ ポイントとそこで呼び出される関数は保護対象になります。Windows ローダーで適切にクッキーが初期化されている場合、保護対象の関数で使用される参照クッキーのコピーは、プロローグおよびエピローグと同じものになります。そのため、問題は発生しません。

Windows で適切にクッキーが初期化されておらず、カスタム エントリ ポイントで __security_init_cookie の呼び出しが最初に行われる場合は、すべての保護対象の関数で適切な参照クッキーが使用されます。ただし、カスタム エントリ ポイントは除きます。参照クッキーのコピーはエピローグで作成されることを思い出してください。そのため、カスタム エントリ ポイントが通常どおり戻ると、そのエピローグでクッキーが確認され、その確認に失敗して誤検知が発生します。この問題を回避するには、通常どおり戻るのではなく、プログラムを終了する関数 (exit など) を呼び出します。

Windows で適切にクッキーが初期化されておらず、カスタム エントリ ポイントで __security_init_cookie が呼び出されなかった場合は、すべての保護対象の関数で既定の参照クッキーが使用されます。さいわい、このクッキーに対して RBP で XOR 演算が実行されるため、使用されるクッキーのエントロピがゼロになることはありません。そのため、ASLR による保護を筆頭に、多少の保護が確保されます。ただし、__security_init_cookie を呼び出して適切に参照クッキーを初期化することをお勧めします。

GuardStack を検証するための BinSkim の使用

BinSkim は、軽量な静的バイナリ分析ツールです。このツールでは、特定の PE バイナリで使用されている一部のセキュリティ機能について、その使用の適切さが検証されます。BinSkim でサポートされている 1 つの特別な機能が GuardStack です。BinSkim は MIT のライセンスを受けたオープン ソース (github.com/Microsoft/binskim、英語) で、すべて C# で記述されています。最新バージョンの Visual C++ (2013 以降) でコンパイルされた x86、x64、および ARM Windows のバイナリをサポートします。BinSkim は、スタンドアローン ツールとして使用するか、さらに興味深いことに、開発者のコードに組み込んで使用することができます。たとえば、開発者のアプリケーションで PE プラグインをサポートしている場合は、BinSkim を使用することで、推奨されているセキュリティ機能がプラグインで採用されているかどうかを確認し、採用されていないときはそのプラグインを読み込まないようにすることができます。ここでは、BinSkim をスタンドアロン ツールとして使用する方法について説明します。

GuardStack に関して言えば、BinSkim では指定されたバイナリが次の 4 つのルールに準拠しているかどうかが確認されます。

  • EnableStackProtection: 関連付けられた PDB ファイルに格納されている対応するフラグをチェックします。フラグが見つからない場合は、ルールがエラーになります。それ以外の場合は、合格します。
  • InitializeStackProtection: 関連付けられた PDB ファイルに定義されているとおりにグローバル関数のリストを反復処理し、__security_init_cookie 関数と __security_check_cookie 関数を探します。両方とも見つからない場合は、ツールにより /GS が有効になっていないと見なされます。この場合、EnableStackProtection がエラーになります。__security_init_cookie が定義されていない場合は、ルールがエラーになります。それ以外の場合は、合格します。
  • DoNotModifyStackProtectionCookie: イメージの読み込み構成データを使用することで、参照クッキーの場所を調査します。その場所が見つからない場合、ルールがエラーになります。読み込み構成データで参照クッキーが定義されていることが示されていても、そのオフセットが無効な場合は、ルールがエラーになります。それ以外の場合は、ルールは合格します。
  • DoNotDisableStackProtectionForFunctions: 関連付けられた PDB ファイルを使用して、__declspec(safebuffers) 属性が適用された関数があるかどうかを判断します。いずれも見つからない場合、ルールがエラーになります。それ以外の場合は、合格します。Microsoft SDL では __declspec(safebuffers) の使用が許可されていません。

BinSkim を使用するには、まず GitHub リポジトリからソース コードをダウンロードしてそれをビルドする必要があります。BinSkim を実行するには、お好みのシェルで次のコマンドを実行します。

binskim.exe analyze target.exe --output results.sarif

複数のイメージを分析するには、次のコマンドを使用できます。

binskim.exe analyze myapp\*.dll --recurse --output results.sarif --verbose

ファイル パスでワイルド カードを使用できることに注意してください。--recurse スイッチは、BinSkim でサブディレクトリ内にあるイメージも分析する必要があることを指定します。--verbose スイッチは、失敗したルールだけでなく、合格したルールも結果ファイル内に含めるように BinSkim に通知します。

結果ファイルの形式は、Static Analysis Results Interchange Format (SARIF) になります。これをテキスト エディターで開くと、そのエントリが図 3 のようになっているのが分かります。

図 3 BinSkim 分析結果ファイル

{
  "ruleId": "BA2014",
  "level": "pass",
  "formattedRuleMessage": {
    "formatId": "Pass ",
    "arguments": [
      "myapp.exe",
    ]
  },
  "locations": [
    {
      "analysisTarget": {
        "uri": "D:/src/build/myapp.exe"
      }
    }
  ]
}

すべてのルールにはルール ID があります。ルール ID BA2014 は、DoNotDisableStackProtectionForFunctions ルールの ID です。Microsoft SARIF SDK (github.com/Microsoft/sarif-sdk、英語) には、SARIF ファイルを Visual Studio で表示する Visual Studio 拡張機能のソース コードが含まれています。

まとめ

GuardStack による動的軽減手法は、スタック バッファー オーバーフローの脆弱性に対する非常に重要な検出ベースの軽減策です。Visual Studio では、デバッグ ビルドでもリリース ビルドでも既定で有効になります。GuardStack は、ほとんどのプログラムでオーバーヘッドが無視できるものになるように設計されているため、幅広く使用できます。しかし、これはスタック バッファー オーバーフローの脆弱性に対する根本的な解決策にはなりません。バッファー オーバーフローは、スタックから割り当てられたバッファーで発生するのが一般的ですが、割り当て済みのメモリ領域で発生することもあります。特に、ヒープベースのバッファー オーバーフローも同じくらい高い危険性があります。このような理由から、Visual C++ と Windows で提供されている他の軽減手法を使用することが非常に重要になります。たとえば、制御フロー ガード (CFG)、Address Space Layout Randomization (ASLR)、データ実行防止 (DEP)、Safe Structured Exception Handling (SAFESEH)、Structured Exception Handling Overwrite Protection (SEHOP) などがあります。これらの手法はいずれも相乗的に機能して、アプリケーションを強固にします。これらの手法の詳細については、bit.ly/2iLG9rq (英語) を参照してください。


Hadi Brais は、インド工科大学デリー校で博士号を取得した研究者で、コンパイラの最適化、コンピューター アーキテクチャ、および関連ツールとテクノロジについて研究しています。彼のブログは hadibrais.wordpress.com (英語) で、連絡先は hadi.b@live.com (英語のみ) です。

この記事のレビューに協力してくれた技術スタッフの   Shayne Hiet-Block (マイクロソフト)、Mateusz Jurczyk (Google)、Preeti Ranjan Panda (IITD)、Andrew Pardoe (マイクロソフト) に心より感謝いたします。


この記事について MSDN マガジン フォーラムで議論する