はじめに
前回の記事では、デバッガがいかにコールスタックを構築しているかについて解説しました。
今回は、前回の記事では詳しく取り上げることのできなかった、構造化例外処理に関して解説します。
Windows では、メモリ違反などの例外発生時にユーザーがあらかじめ定義した処理(この処理を例外ハンドラと呼びます) の実行が可能です。
この仕組みの 1 つが構造化例外処理、もしくは Structured Exception Handling(SEH) と呼ばれるものになります。
以降は構造化例外処理のことを SEH と称します。
64bit の SEH の仕組みにおいては 32bit とは異なり脆弱性攻撃における利用というものが難しくなりましたが、他の観点では SEH の悪用が不可能というわけではありません。
また、 例外ハンドラの呼び出しというのは、負荷の少ない処理ではありません。
そのため、SEH がどのように実装されているかを把握し、潜在的なリスクやパフォーマンスへの影響の理解することは大切です。
本記事では、64bit の実行ファイルに関する SEH の仕組みについて解説します。
記事内のデバッグ等は Windows 10 x64 を対象に行っています。
SEHとは
SEH の簡単な例を以下に示します。
以下のようなプログラムを実行したときに、invalid
という変数の初期化時に例外が発生します。
SEH はこのような例外発生時において、__try
のブロック内の例外をキャッチし、例外発生時は__except
ブロックに記載された処理を実行可能です。
この__except
の実行がユーザー定義の例外ハンドラとなります。
今回の場合であれば、invalid
の内容は表示されることなく、「Exception!」とコンソールに表示されます。
__except
内のブロック実行後は、そのブロック直後から再開されます。この場合であればprintf
が実行され「END」と表示されます。
int wmain(int argc, wchar_t **argv) { __try { int invalid = 1 / 0; printf("%d\n", invalid); } __except(EXCEPTION_EXECUTE_HANDLER) { printf("Exception!\n"); } printf("END\n"); }
SEH はカーネル空間でも使用可能ですが、今回はユーザー空間での SEH の解説になります。 ちなみに他の例外処理方法として、ベクタ例外処理がありますがこちらはユーザー空間のみで使用可能です。
__except
の括弧内で指定しているEXCEPTION_EXECUTE_HANDLER
はフィルター式といい、これによってハンドラの動作が変わります。
EXCEPTION_CONTINUE_EXECUTION
: 例外発生時のコンテキストを実行EXCEPTION_CONTINUE_SEARCH
: このハンドラを実行せず次のハンドラを探索EXCEPTION_EXECUTE_HANDLER
: このハンドラを実行
SEH はネストが可能で、以下のようなことも可能です。
func1
内で例外発生時、対応しているハンドラのフィルター式はEXCEPTION_CONTINUE_SEARCH
のため実行されず、wmain
内に定義されたハンドラが実行されます。
func1
内の__except
に与えるフィルター式がEXCEPTION_EXECUTE_HANDLER
であれば、func1
のハンドラが実行されます。
つまり、例外時のコンテキストから例外を探索し、EXCEPTION_EXECUTE_HANDLER
のハンドラが見つからない場合は呼び出し元のコンテキストのハンドラを探索、という流れになります。
#include <stdio.h> #include <windows.h> void func1() { char dummy[1]{}; __try { int invalid = 1 / (int)dummy[0]; // コンパイルエラー回避 printf("%d\n", invalid); } __except (EXCEPTION_CONTINUE_SEARCH) { printf("nothing\n"); } } int wmain(int argc, wchar_t **argv) { printf("Start\n"); __try { func1(); } __except (EXCEPTION_EXECUTE_HANDLER) { printf("Exception!!\n"); } }
SEH の使用方法に関してこれ以上掘り下げませんが、詳しく知りたい場合は Microsoft の開発者用ページなどをご参照ください。
SEH は以上のように例外をユーザー空間で処理することが可能になりますが、どのようにして実装されているのかを確認していきます。
例外ハンドラの格納場所
始めに、例外ハンドラがどこに保存されているかを確認します。 ユーザーが定義した例外ハンドラに関する情報が保存されている場所は、前回解説した PE の Exception Directory の RUNTIME_FUNCTION です。 RUNTIME_FUNCTION によって、関数とハンドラを紐づけています。
RUNTIME_FUNCTION のメンバである、 UNWIND_INFO 構造体のメンバに例外ハンドラの情報が存在します。
UNWIND_INFO の定義を以下に示します[1]。
Flags にUNW_FLAG_EHANDLER
もしくはUNW_FLAG_UHANDLER
が含まれているとき、 ExceptionHandler に関連するハンドラの RVA が保存されます。
typedef struct _UNWIND_INFO { UBYTE Version : 3; UBYTE Flags : 5; UBYTE SizeOfProlog; UBYTE CountOfCodes; UBYTE FrameRegister : 4; UBYTE FrameOffset : 4; UNWIND_CODE UnwindCode[1]; /* UNWIND_CODE MoreUnwindCode[((CountOfCodes + 1) & ~1) - 1]; * union { * OPTIONAL ULONG ExceptionHandler; * OPTIONAL ULONG FunctionEntry; * }; * OPTIONAL ULONG ExceptionData[]; */ } UNWIND_INFO, *PUNWIND_INFO;
ExceptionHandler がユーザー定義ハンドラの RVA となっているのではなく、language-specific handler という共有のハンドラが設定されます。
この理由の 1 つとしては、__except
のフィルター式を評価するためです。
例外発生直後にユーザー定義のハンドラを実行してしまえば、フィルター式を評価することなく強制的に一番近いハンドラが実行されてしまうため、一度このようなクッションが必要となります。
language-specific handler の型定義は以下であり[1]、Visual Studio でのビルドは標準で__C_specific_handler
が設定されるようです。
typedef EXCEPTION_DISPOSITION (*PEXCEPTION_ROUTINE) ( IN PEXCEPTION_RECORD ExceptionRecord, IN ULONG64 EstablisherFrame, IN OUT PCONTEXT ContextRecord, IN OUT PDISPATCHER_CONTEXT DispatcherContext );
ユーザーが定義したハンドラは UNWIND_INFO の ExceptionData 配列の要素として RVA で保存されます。
この配列は__C_specific_handler
の DispatcherContext 内のデータとしてハンドラに渡されます。
SEH では例外発生時のプログラムカウンタが属する関数の RUNTIME_FUNCTION を探し、紐づいたハンドラがあればそれを実行します。 もしハンドラが無かったり、フィルター式でハンドラを実行しないようであれば、呼び出し元のコンテキストを復元して... の繰り返しになります。 呼び出し元の復元に関しては前回の記事で解説したように、同じく RUNTIME_FUNCTION を使用します。 RUNTIME_FUNCTION を使用したコールスタック構築というのは、 SEH のネスト対応のための構造を利用しているということになります。
例外ハンドラが実行されるまで
0 除算の例外発生時から、今回のユーザー定義の例外ハンドラが実行されるまでの過程は、以下のような流れになります。
- 例外発生
- ISR が例外ディスパッチャを復元
- 例外ディスパッチャによるハンドラ探索
- ハンドラ実行
2 番の過程から見ていきましょう。
2. ISRが例外ディスパッチャを復元
CPU は例外等の割り込み発生時に現在実行している処理を中断し、idtr というレジスタに登録された Interrupt Service Routine(ISR)をカーネル空間で実行します。
割り込みの目的は、ページフォールトといったシステムに重大な影響を及ぼすような状況に陥ったことや、ハードウェアから入力が来たことを CPU へ即座に伝えることです。
例に挙げたページフォールトであれば、ISR 内でページのスワップなどを行います。
その後ユーザー空間へ戻るわけですが、ページフォールトの場合は、スワップ成功時指定したページがメインメモリに確保されているため、例外発生時のコンテキストで実行可能です。
しかし、0 除算の場合はページフォールトと異なりそのまま実行しても再び例外が発生するため、少なくとも何らかの対応が必要です。
Windows の場合は一旦ユーザー空間の例外ハンドラへプログラムカウンタを移し、その後の動作はこのハンドラに委ねます。
KiUserExceptionDispatcher
という関数がこれを担当し、0 除算に対応する ISR はプログラムカウンタをここに変更した後、ユーザー空間へ復帰します。
つまり例外発生時の挙動はKiUserExceptionDispatcher
によって決定されます。
これは全てのプロセスに読み込まれる ntdll.dll からエクスポートされた関数で、いきなりユーザー定義のハンドラに遷移することはありません。
ハンドラと表記しましたが、実際には例外ハンドラを探して実行する例外ディスパッチャの役割を担います。
3. 例外ディスパッチャによるハンドラ探索
KiUserExceptionDispatcher
の処理を逆アセンブルすると以下のような処理が見えます。
ここで重要な処理はRtlDispatchException
の呼び出しです。
この関数が例外ハンドラを探索します。
第二引数で渡されている値が例外発生時のコンテキストとなります。
00007ffa`33f30bd0 cld 00007ffa`33f30bd1 mov rax, qword ptr [ntdll!Wow64PrepareForException (00007ffa`3400f230)] 00007ffa`33f30bd8 test rax, rax 00007ffa`33f30bdb je ntdll!KiUserExceptionDispatch+0x1c (00007ffa`33f30bec) 00007ffa`33f30bdd mov rcx, rsp 00007ffa`33f30be0 add rcx, 4F0h 00007ffa`33f30be7 mov rdx, rsp 00007ffa`33f30bea call rax 00007ffa`33f30bec mov rcx, rsp 00007ffa`33f30bef add rcx, 4F0h 00007ffa`33f30bf6 mov rdx, rsp 00007ffa`33f30bf9 call ntdll!RtlDispatchException (00007ffa`33ee1210) ; 例外ハンドラ探索 (略)
実際に例外発生時のコンテキストが渡されているのか確認してみましょう。 コンテキストを確認した結果が以下になります。 第二引数として利用されるのは rdx であるため、こちらの値を CONTEXT 構造体にパースして表示しています。 かなり省略していますが、重要な箇所は rcx の値と rip の値です。
0:000> dt _CONTEXT @rdx Exception!_CONTEXT (略) +0x078 Rax : 1 +0x080 Rcx : 0 (略) +0x0f8 Rip : 0x00007ff6`8930107c +0x100 FltSave : _XSAVE_FOR (略)
rip のアドレスを逆アセンブルすると以下の結果になります。 eax を ecx で割ろうとしていることが分かりますが、コンテキストより ecx は 0 であるため 0 除算になります。 (ecx は rcx の下位 4 バイトなので、rcx が 0 であれば ecx も 0 になります)
00007ff6`8930107c idiv eax,ecx
次にRtlDispatchException
の処理を見ていきます。
前に説明した通り、この関数は引数のコンテキストを基に例外ハンドラを探索し実行します。
最初にベクタ例外処理のハンドラを探索した後、SEH のハンドラを探索します。
ここの処理を見ていくと、以下のようにRtlLookupFunctionEntry
とRtlVirtualUnwind
の 2 つの関数呼び出しを確認できます。
RtlLookupFunctionEntry
は指定したアドレスの RUNTIME_FUNCTION を得る関数です。
また、RtlVirtualUnwind
は関数呼び出し前のコンテキストを仮想的に復元し、それに紐づく例外ハンドラを得る関数です。
つまりRtlDispatchException
はこの 2 つの関数を使用し、呼び出し元を順に辿り例外ハンドラを取得しています。
(略) 00007ffa`33ee1367 call ntdll!RtlLookupFunctionEntry (00007ffa`33ec2be0) ; RUNTIME_FUNCTIONの取得 00007ffa`33ee136c mov r13, rax 00007ffa`33ee136f test rax, rax 00007ffa`33ee1372 je ntdll!RtlDispatchException+0x690c1 (00007ffa`33f4a2d1) 00007ffa`33ee1378 mov rdx, qword ptr [rbp+18h] 00007ffa`33ee137c lea rax, [rbp+10h] 00007ffa`33ee1380 mov qword ptr [rsp+38h], rbx 00007ffa`33ee1385 mov r9, r13 00007ffa`33ee1388 mov qword ptr [rsp+30h], rax 00007ffa`33ee138d mov r8, r15 00007ffa`33ee1390 lea rax, [rbp+20h] 00007ffa`33ee1394 mov ecx, 1 00007ffa`33ee1399 mov qword ptr [rsp+28h], rax 00007ffa`33ee139e mov qword ptr [rsp+20h], r14 00007ffa`33ee13a3 call ntdll!RtlVirtualUnwind (00007ffa`33ec0de0) ; コンテキストの復元とハンドラ取得 (略)
関数によっては SEH を使用していないものもあり、その場合はRtlVirtualUnwind
の実行後RtlLookupFunctionEntry
へ遷移し、呼び出し元のコンテキストを復元します。
例外ハンドラが見つかったときは、RtlpExecuteHandlerForException
を呼び出し見つかった例外ハンドラを実行します。
ここでいう例外ハンドラというのは通常__C_specific_handler
になり、この関数内でユーザー定義のハンドラを実行します。
フィルター式がEXCEPTION_CONTINUE_SEARCH
の場合、RtlLookupFunctionEntry
へループします。
4. ハンドラ実行
__C_specific_handler
ではユーザー定義の例外ハンドラを実行します。
行っていることは単純で、フィルター式を評価し、引数の DispatcherContext からハンドラを探して実行します。
フィルター式には関数も指定可能(フィルター関数)で、関数が指定されていた場合は__C_specific_handler
内で実行しその戻り値をフィルター式とします。
フィルター関数の使用により、例外の種類に応じてハンドラの動作を切り替えること等が可能です。
フィルター式がEXCEPTION_EXECUTE_HANDLER
であればユーザー定義のハンドラを実行します。
ただし単純に無条件ジャンプや call で呼び出すのではなく、RtlUnwindEx
を使用し、コンテキストを見つかったハンドラのコンテキストへ復元しての実行になります。
これはハンドラ実行後に__except
ブロック直後の処理へ戻すためです。
コンテキストを変更するため、例外ハンドラ実行後にコールスタックを取得しても、__C_specific_handler
や、RtlDispatchException
は履歴に残りません。
以上が例外発生からハンドラ実行までの流れになります。
ここまで確認していただいたように、SEH の例外ハンドラ呼び出しというのは、カーネル空間へのスイッチやコンテキストの復元などの処理を含みます。
そのため、NULL チェックなどの基本的な確認を怠り、例外が多発するような状況となった場合、パフォーマンスへの影響が懸念されます。
余談1:32bitのSEHの構造
ここまで 64bit の実行ファイルでは、RUNTIME_FUNCTION で関数とハンドラを紐づけていることを確認しました。
これに対しては 32bit ではスタックとリンク構造でこれを実現しています。
32bit の場合、__try
を実行するときに、ハンドラのアドレスをスタックへ push し、SEH を管理するリンクの先頭へスタックポインタを追加します。
例外発生時は、このリンク構造をたどることで例外ハンドラを実行可能になります。
ただしこの構造には問題があり、バッファオーバーフローの脆弱性が存在した場合、例外ハンドラの上書きが可能です。
32bit の SEH やこれに関する攻撃手法は当社の研究結果に詳しくまとめてありますので、気になる方こちらをご参照ください。
32bit の SEH では上記のような問題がありましたが、本記事で確認したように 64bit では RUNTIME_FUNCTION を使用したテーブルベースになります。 そのため、バッファオーバーフローによる例外ハンドラ上書きの懸念はなくなりました。 しかし、例外ハンドラが上書きできなくなったというわけではなく、UNWIND_INFO の例外ハンドラを上書きすることで例外発生時の挙動を変化させることは可能です。 これを利用するためには、例外発生の条件や呼ばれるハンドラが属する関数の RUNTIME_FUNCTION の特定が必要で、条件が厳しいため脆弱性攻撃としての利用は困難です。 逆に言えばこれらが既知であればこの手法は利用可能なので、パッカーや解析妨害等での利用は可能になります。 以下が実際にハンドラを上書きしたインジェクションの例になります。
これは最初の例に示した「Exception!!」と表示するプログラムの wmain
のハンドラをランタイムに上書きしています。
上書きしたハンドラは「inject」と表示し、元のハンドラ(「Exception!!」の表示)を実行します。
ハンドラ位置と例外発生条件さえ分かればこのようなことが可能です。
余談2:ISRの処理
ユーザー空間での例外処理を確認しましたが、この項目では 0 除算の例外発生からユーザー空間復帰までの流れを解説します。
Windows 10 x64 では、0 除算の ISR はKiDivideErrorFaultShadow
が担当します。
処理を追っていくとKiDivideErrorFault
へジャンプしますが、この先頭処理が以下になります。
fffff807`5846cb05 にてスタック確保していますが、これはトラップフレームと呼ばれます。
トラップフレームは例外直後のコンテキストを保存する構造体であり、スタック確保直後に、汎用レジスタを保存している処理が確認できます。
fffff807`5846cb00 sub rsp, 8 fffff807`5846cb04 push rbp fffff807`5846cb05 sub rsp, 158h ; トラップフレーム確保 fffff807`5846cb0c lea rbp, [rsp+80h] fffff807`5846cb14 mov byte ptr [rbp-55h], 1 fffff807`5846cb18 mov qword ptr [rbp-50h], rax fffff807`5846cb1c mov qword ptr [rbp-48h], rcx fffff807`5846cb20 mov qword ptr [rbp-40h], rdx fffff807`5846cb24 mov qword ptr [rbp-38h], r8 fffff807`5846cb28 mov qword ptr [rbp-30h], r9 fffff807`5846cb2c mov qword ptr [rbp-28h], r10 fffff807`5846cb30 mov qword ptr [rbp-20h], r11 (略)
トラップフレームは最初に例外ディスパッチャへ渡すコンテキストのために利用されます。
つまりRtlDispatchException
の第二引数になります。
次にトラップフレームはユーザー空間へ復元するためのコンテキストとして利用されます。
ISR は iretq を実行することでユーザー空間へ戻りますが、その前にトラップフレームで復帰先のコンテキストを調整します。
具体的にはトラップフレームを更新し、トラップフレームの rip をKiUserExceptionDispatcher
へ変更します。
これにより、ユーザー空間へ復帰したときにKiUserExceptionDispatcher
から再開が可能です。
トラップフレームの書き換えはKiDispatchException
が以下のようにグローバルな領域からディスパッチャをコピーしています。
(略) fffff807`5836cc95 cli fffff807`5836cc96 mov qword ptr [r14+180h], r12 fffff807`5836cc9d mov eax, 33h fffff807`5836cca2 mov word ptr [r14+170h], ax fffff807`5836ccaa mov rax, qword ptr [nt!KeUserExceptionDispatcher (fffff807`587e9908)] ; ディスパッチャのアドレスをraxへコピー fffff807`5836ccb1 mov qword ptr [r14+168h], rax ; r14:トラップフレームの先頭アドレス 168h:ripまでのオフセット fffff807`5836ccb8 mov rcx, r14 fffff807`5836ccbb call nt!KiSetupForInstrumentationReturn (fffff807`583e8a6c) fffff807`5836ccc0 sti (略)
当環境ではKiUserExceptionDispatcher
のアドレスはシステム起動時にPspInitializeSystemDlls
によって取得されていました。
ntdll.dll のロードされるアドレスはプロセス間で固定なので、一度カーネル側でキャッシュすれば問題ありません。
最終的に ISR はKiKernelExit
を呼び出しトラップフレームのコンテキストでユーザー空間へ戻ります。
おわりに
__finally
や__leave
の動作など解説できてない点もありますが、例外発生から SEH のハンドラ実行までの概要を解説しました。
64bit の SEH に関する記事は、英語の情報であれば既にいくつか存在し、その中には事前調査として利用させて頂いたものもあります。[2] しかし、こういった日本語の情報は現状少ないため今回の執筆に至った経緯があります。
即時的な情報ではありませんが、今後も機会があれば今回のような発信をしていきます。
エンジニア募集
FFRIセキュリティでは Windows を使ったセキュリティの研究開発、マルウェアの解析などを行っています。採用に関しては こちら を御覧ください。