FFRIエンジニアブログ

株式会社FFRIセキュリティのエンジニアが執筆する技術者向けブログです

構造化例外処理の仕組みの解説

はじめに

前回の記事では、デバッガがいかにコールスタックを構築しているかについて解説しました。 今回は、前回の記事では詳しく取り上げることのできなかった、構造化例外処理に関して解説します。
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 除算の例外発生時から、今回のユーザー定義の例外ハンドラが実行されるまでの過程は、以下のような流れになります。

  1. 例外発生
  2. ISR が例外ディスパッチャを復元
  3. 例外ディスパッチャによるハンドラ探索
  4. ハンドラ実行

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 のハンドラを探索します。 ここの処理を見ていくと、以下のようにRtlLookupFunctionEntryRtlVirtualUnwindの 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!!」の表示)を実行します。

ハンドラ位置と例外発生条件さえ分かればこのようなことが可能です。

図1 例外ハンドラ の上書き

余談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 を使ったセキュリティの研究開発、マルウェアの解析などを行っています。採用に関しては こちら を御覧ください。

参考文献

[1] https://docs.microsoft.com/en-us/cpp/build/exception-handling-x64?view=msvc-170&viewFallbackFrom=msvc-190

[2] https://www.osronline.com/article.cfm%5earticle=469.htm