やっと「マトモ」になった mingw-w64

凄く場違いな話題だが、x64 Windows 向けのツールチェーンに加えられた飛躍的な進化について。x64 Windows 向けの例外処理もだいたい分かるぐらいに話す。 (カーネル/VM Advent Calendar : 1010+1001/01/11 分)

x86→x64

Windows はこのプラットフォームの変化によって、様々な部分が変化した。そして、色々な部分が進化した。例外ハンドリングもそのひとつだ。その代わり、コンパイラがマトモなモノでないとこの恩恵を得ることができない。x86 用の Windows がターゲットならばかなり腐ったコンパイラでも一丁前の実行ファイルを得られたが、x64 ではそうはいかない。具体的には次の部分がある。

  • x64 用標準 ABI の整備
    • レジスタ渡しになったのはかなりのプラスだが、関数のプロローグとエピローグの形は大きく制約されることになった。x86 だと少なくとも関数プロローグやエピローグの形はコンパイラ好みの任意のものが使用できる。
  • 例外ハンドリングの再編
    • テーブルベースの例外ハンドリングが標準になることで、セキュリティとパフォーマンスの向上。ただし弊害も。

mingw-w64 とは

32/64-bit 共用の Windows 向けツールチェーンであり、gccbinutils といったツールを使用して Windows 向けのネイティブアプリケーションを作成することができる。積極的な貢献によってユーザモードアプリケーションを作る分にはほとんど申し分ないほどに進化しており、カーネルモードのドライバも作れないことはない。
ただ、カーネルモードドライバ、特に 64-bit 向けのものを作るには、最大の障害が存在した。例外処理である。

SEH (Structured Exception Handling)

SEH (構造化例外処理) は、Windows に存在する低レベルの例外処理機構である。例えば、次のように使用する。

__try
{
    /** 例外発生の可能性があるハンドラ **/
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
    /** 例外発生時の処理 **/
}
__finally
{
    /** 何があろうと実行するルーチン **/
}

これによって、自分でカスタムのエラー処理を (C++ 例外などの高級なものから、ゼロ除算などの CPU 例外までキャッチするハンドラとして) 書くことができる。
しかしながら、これは x86 と x64 で大きくその機構が異なる。

x86 の場合

x86 用の SEH は基本的にスタックベースのものだ。スタックに例外ハンドラに関する情報を置き、以前に設定されていた例外ハンドラをリンクし、システムに登録する。こんな感じ。

function1→function2→function3→function4→function5 という呼び出し履歴がある場合:
+---------------------------+     +------------------------+     +------------------------+     +------------------------+     +------------------------+
|  fs:0 (例外登録レコード)  |<----|  function5 の例外処理  |---->|  function2 の例外処理  |---->|  function1 の例外処理  |---->|  デフォルトの例外処理  |
+---------------------------+     +------------------------+     +------------------------+     +------------------------+     +------------------------+

例外登録レコードはいわゆる __try ブロックに入ったところでリンクとシステムへの登録が行われ、次の命令の例外からはそちらを参照することになる。(これには若干のオーバーヘッドがある。) もし例外が発生したとき、function5 の例外フィルタはそれをハンドルするかもしれない。あるいは、実行を継続させるかもしれない。あるいは自分で処理ができなかったなどの理由で、後ろにある例外ハンドラにたらい回しすることもできる。ここでは function1,2,5 に __try/__except ブロックがあるので、function5 の例外ハンドラが処理できなかった場合には function2 の例外ハンドラにたらい回しされるわけだ。もちろん関数を抜けるときには自分の持ってる例外処理エントリを外した上で改めてシステムに登録し直す。
この機構は単純な反面リスクも抱えており、スタックオーバーフローで狙われる危険も大きかった (例外ハンドラに関する情報をスタックに置くので、そこを上書きされるリスクがあったのだ。) これを回避するために様々な保護機構が後付けされていった。(SafeSEH, SEHOP など。加えて ASLR はアドレスをランダムに配置することで SEH 保護機構の効果をかなり高める。)

x64 の場合

しかし、x64 Windows では最初からセキュアな方法論が採用されている。ちゃんとしたコンパイラで吐き出した x64 向け PE ファイルには、各関数に関する unwind 情報が保存されている。

- function 1 : rbp, rsi, rdi を保存
- function 2 : rbp を保存; 24 バイトのバッファを確保
- function 3 : rbp, xmm6, xmm7 を特定箇所に保存; 136 バイトのバッファを確保
- function 4 : rbp を保存....
- function 5 : ....

のように。これを何に使うか? 完全なコールスタックを復元するためである。この方法は DWARF2 Exception Handling の方法論に近い。
この場合、__try/__except 句のある関数で特別な処理 (システムへの登録と登録解除など) は行われない。これによりパフォーマンスが向上する。そして例外発生時には、そのときのスタックから元のコールスタックを復元した上で、最も近い例外ハンドラを探して呼び出す。(微妙にまちがってるけどだいたいはこんな感じ。)

  1. 例外発生アドレス (RIP) を得る
  2. RIP に対応する関数を取得する。
    • これが取得できない場合もある。例えば、関数がスタックを一切使用せず、例外ハンドラもなく、そして一切の関数を呼び出さない場合 (leaf function と呼ばれる) が該当する。
    • この場合、戻りアドレス (RIP) は現在のスタックアドレスに存在すると仮定し、2. に戻る。
  3. 関数が例外ハンドラを持っていて、なおかつ RIP が PE ファイルで指定される特定の範囲内であれば、対応する例外ハンドラを呼び出す。
    • 例外ハンドラが処理できなかった場合は次以降。
  4. 関数の unwind 情報を得る。
  5. unwind 情報に格納されているプロローグの位置を得て、関数のプロローグかどうかを判定する。
    • 下の例で、insn ends at pc+0x というのがまさにその情報。
    • プロローグなら、そのための unwind 情報 (サブセット) を算出する。
  6. あるいは、RIP に続くコード列を得て、静的解析で関数のエピローグかどうかを判定する。
    • 関数エピローグは保存した情報を復元している最中なので、これを判定することは必須。
    • これを正しく行うために、ABI で関数のエピローグにおけるコード列が大きく制約されているのだ。
    • エピローグなら、そのための unwind 情報 (サブセット) を算出する。
  7. 対応する unwind 情報を元に、レジスタを (仮想的に) 復元する。
  8. 関数の戻りアドレス (RIP) を取得し、2. に戻る。

ここからは x86 の SEH と同様だ。この方法論が優れているのは、例外ハンドリングに関する全ての情報は PE ファイルの読み取り専用領域に格納されているため、例外ハンドリングをクラッキングのために (ほとんどの場合) 使用できなくなるという点だ。
しかし悪く言えば、x64 の SEH はテーブルに頼り切っている。これは、当該プログラム中で SEH を使わない場合でも望んでいない状況/クラッシュを引き起こす可能性がある。例えば、unwind テーブルつきのバイナリから unwind テーブルなしのバイナリが呼ばれた場合、SEH は状況によっては正常に動作しない。

sample.exe → sample.dll と呼び出しの最中:
-----------------------------------------------------------------
sample.exe (MSVC でビルド、実行 [例外ハンドリングをしている])
  → sample.dll (mingw-w64 でビルド、実行 [SEH, unwind ともになし])
----------------------------------------------------------------- ↑before↓after
sample.exe (例外ハンドリングをしていたのに正常に呼び出されず、クラッシュ)
  → sample.dll (例外発生↑)

また x86 であれば強制的に例外ハンドラを登録してやればユーザの指定する動的なハンドラを実行することができたが、x64 ではこの方法は基本的に「不可」である。*1代わりに VEH (Vectored Exception Handling) と呼ばれる機構があるが、カーネルモードのドライバはこの VEH を利用することができない。
(今までの) mingw-w64 はまさにこの条件に当てはまってしまう。unwind 情報を吐き出すことができず、安全なカーネルモードドライバを書くには少々危ない感じだった。SEH のキーワードである __try/__except も mingw-w64 の gcc は解釈せず、x64 カーネルモードドライバで例外を使うにはハックが必要なものだと思っていた。厄介である。

mingw-w64 の進歩

しかし、最近のバージョンの mingw-w64 では、ちゃんと関数の unwind 情報を吐き出すようになっている。

$ x86_64-w64-mingw32-objdump -p a.exe | less
--------------------------------------------------------------
// なにもしない <main> 関数の unwind 情報
 000000000000606c:
        Flags: UNW_FLAG_NHANDLER.
        Entry has 3 codes.      Prologue size: 8, Frame offset = 0x2.
        Frame register is rbp.
         At pc 0x0000000000401550 there are the following saves (in logical order).
          insn ends at pc+0x01: push rbp.
          insn ends at pc+0x08: save stack region of size 0x0000000000000020.
          insn ends at pc+0x08: FPReg = (FrameReg) + 0x0000000000000000.

これは、binutilsgcc (4.6 系のみ) の双方が unwind 情報をサポートした効果である。では、return 0 のみを書いたシンプルな main 関数のアセンブリ出力を見てみよう。

        .globl  main
        .def    main;   .scl    2;      .type   32;     .endef
        .seh_proc       main
main:
        pushq   %rbp
        .seh_pushreg    %rbp
        movq    %rsp, %rbp
        subq    $32, %rsp
        .seh_stackalloc 32
        .seh_setframe   %rbp, 32
        .seh_endprologue
        call    __main
        movl    $0, %eax
        addq    $32, %rsp
        popq    %rbp
        ret
        .seh_endproc

すぐに、.seh_ で始まるキーワードが多数使用されていることに気づく。そう。SEH 関連の情報を吐き出す疑似命令を as がサポートし、gcc はそれに合わせた出力を行い、ld は複数オブジェクトの unwind 情報を統合することで、このような unwind 情報を吐き出すことに成功しているのだ。
これなら、このバイナリから例外を問題なく流すことができる。(mingw-w64 で作成したバイナリによって例外ハンドリングが滞ることがない。)
しかし、これは SEH を「使える」ことを意味しない。gcc は本来 SEH のための __try/__except キーワードをサポートしておらず、最近のバージョンの mingw-w64 でもエラーとなる。となるとカーネルモードでは使いようがない…そう思った。しかし mingw-w64 が吐き出したバイナリの情報を見ていると、興味深いことに気づいた。

$ x86_64-w64-mingw32-objdump -p a.exe | less
--------------------------------------------------------------
// !?
 000000000000602c:
        Flags: UNW_FLAG_EHANDLER.
        Entry has 1 codes.      Prologue size: 4, Frame offset = 0x0.
        Frame register is none.
         At pc 0x0000000000401510 there are the following saves (in logical order).
          insn ends at pc+0x04: save stack region of size 0x0000000000000028.
        User data:
          000: d0 28 00 00 01 00 00 00 1e 15 00 00 28 15 00 00
          010: d0 19 00 00 28 15 00 00
 000000000000604c:
        Flags: UNW_FLAG_EHANDLER.
        Entry has 1 codes.      Prologue size: 4, Frame offset = 0x0.
        Frame register is none.
         At pc 0x0000000000401530 there are the following saves (in logical order).
          insn ends at pc+0x04: save stack region of size 0x0000000000000028.
        User data:
          000: d0 28 00 00 01 00 00 00 3e 15 00 00 48 15 00 00
          010: d0 19 00 00 48 15 00 00

なんと! mingw-w64 の objdump では User data とのみ記されているが、これは紛れもなく例外ハンドラの情報だ。*2 gcc は SEH キーワードが使えないのになぜできるのか? 逆アセンブリと照合すると、この 2 つの関数は WinMainCRTStartup と mainCRTStartup だ。これは mingw-w64 の CRT 内に目標がありそうな雰囲気。というわけで、mingw-w64-crt/crt 中の crtexe.c を参照する。

/**
    mingw-w64 ソースコードより引用。
    当該部分はパブリックドメインである。
    http://mingw-w64.svn.sourceforge.net/viewvc/mingw-w64/trunk/mingw-w64-crt/crt/crtexe.c
    http://mingw-w64.svn.sourceforge.net/viewvc/mingw-w64/trunk/mingw-w64-crt/crt/crtexe.c?view=log
**/
int WinMainCRTStartup (void)
{
  int ret = 255;
#ifdef __SEH__
  asm ("\t.l_startw:\n"
    "\t.seh_handler __C_specific_handler, @except\n"
    "\t.seh_handlerdata\n"
    "\t.long 1\n"
    "\t.rva .l_startw, .l_endw, _gnu_exception_handler ,.l_endw\n"
    "\t.text"
    );  
#endif
  /* (中略) */
#ifdef __SEH__
  asm ("\t.l_endw: nop\n");
#endif
  return ret;
}

読めれば簡単だ。先ほどの例と同じく .seh_* というキーワードを、今度はインラインアセンブリとして記述している。(#ifdef が入っているのは、unwind 情報出力をサポートしない古い mingw-w64 に加え、テーブルベースの SEH ではない 32-bit 版をサポートするため。) しかも、ここで SEH の例外ハンドラを設定しており、例外がきちんと CRT に渡されるようになっている。*3最後の nop は、ラベルを正常に働かせるためのダミーだろう。となれば、この手法を使えばどのようなアプリケーションであってもそのための正常な SEH テーブルのデータを構築できる。というわけで、やってみよう…アレ、動かない!?

mingw-w64 のバグ

32-bit でクラッシュするのは予期してたモノ*4だけど、64-bit でもクラッシュするのは予想外だぞ。というわけで、WinDbg を駆使してなんとかバグ発見。(例外ハンドラではソフトウェアブレークポイントが無効になることを知らず、えらく時間を食った。) 昨日 mingw-w64 プロジェクトにパッチを投げて、即日で修正された

    "\t.rva .l_startw, .l_endw, _gnu_exception_handler ,.l_endw\n"

これは 4 つのポインタをデータとして出力しているが、このフォーマットは次のようになっている。*5

  • BeginAddress
    • __try ブロックの開始アドレス
  • EndAddress
    • __try ブロックの終了アドレス
  • HandlerAddress
    • 例外フィルタ (関数への RVA)
  • JumpTarget
    • __except ブロックのアドレス (mingw-w64 においては未使用)

そして、BeginAddress と EndAddress の間で例外が発生したならば例外フィルタ (そして必要ならハンドラ) を呼び出す…のだが、この判定は、次の C コードにほぼ等しい。

if (target >= BeginAddress && target < EndAddress)
    /* catch exception */

しかし、.l_endw 直後の nop 命令…の前の命令は call 命令で、実際にはこの戻りアドレスである nop 命令のアドレスが判定に用いられる。しかし nop 命令は .l_endw の後にあるため、フレームベースの例外ハンドリングが正しく働いていなかった*6。(CRT が例外を別の手段でキャッチしているため、今まで顕在化しなかっただけ。) というわけで、.l_endw と .l_endw+1 に置換するか、.l_endw ラベルの前に nop 命令を挿入するかのどちらかで直る。(もう一方も同様に修正する。) 修正版はこんな感じ。(前者の修正を適用したものだが、本家の mingw-w64 には後者のパッチが採用された。)

/**
    mingw-w64 ソースコードを修正。
    当該部分はパブリックドメインである。
    http://mingw-w64.svn.sourceforge.net/viewvc/mingw-w64/trunk/mingw-w64-crt/crt/crtexe.c
    http://mingw-w64.svn.sourceforge.net/viewvc/mingw-w64/trunk/mingw-w64-crt/crt/crtexe.c?view=log
**/
int WinMainCRTStartup (void)
{
  int ret = 255;
#ifdef __SEH__
  asm ("\t.l_startw:\n"
    "\t.seh_handler __C_specific_handler, @except\n"
    "\t.seh_handlerdata\n"
    "\t.long 1\n"
    "\t.rva .l_startw, .l_endw+1, _gnu_exception_handler ,.l_endw+1\n"
    "\t.text"
    );  
#endif
  /* (中略) */
#ifdef __SEH__
  asm ("\t.l_endw: nop\n");
#endif
  return ret;
}

ちなみにこれは mingw-w64 標準の例外ハンドリングをうまく働かせるための修正だが… mingw-w64 標準の例外フィルタ (_gnu_exception_handler) は、例外を signal に変換する機能を持つ。signal の使い方を知っているなら、Linux と同じように例外をハンドルすることが可能だ。(残念ながら sigaction*7 ではないが。)

となればやること。

さて、では別のこともやってみよう。本来は x64 カーネルモードで例外を発生させるサンプルを作る予定だったが、安全性を検証する時間がなかったので代わりに VMware を検出するサンプルを作ってみた。この状況なら signal も使えなくもないが、ネタ的にはあまり面白くないので、今は眠っていてもらう。

#include <stdio.h>
#include <stdlib.h>
#include <windows.h>

long CALLBACK
detect_vmware_except(
	PEXCEPTION_POINTERS data
)
{
	data->ContextRecord->Rbx = 0;
	data->ContextRecord->Rip += 1;
	return EXCEPTION_CONTINUE_EXECUTION;
}

__attribute__((noinline))
int detect_vmware(void)
{
	DWORD result;
	__asm__ __volatile__
	(
		".seh_handler __C_specific_handler, @except\n\t"
		".seh_handlerdata\n\t"
		".long 1\n\t"
		".rva .l_vmdetect, .l_vmdetect+1, detect_vmware_except\n\t"
		".long 0\n\t"
		".text\n\t"
		"xorl %%ebx, %%ebx\n\t"
		"movl $0x564d5868, %%eax\n\t"
		"movl $10, %%ecx\n\t"
		"movw $0x5658, %%dx\n"
		".l_vmdetect:\n\t"
		".byte 0xed" /* in (%dx),%eax */
		: "=b"(result)
		: : "%eax", "%ecx", "%edx", "cc", "memory"
	);
	return result == 0x564d5868;
}

int main(int argc, char** argv)
{
	if (detect_vmware())
	{
		printf("VMware is detected!\n");
	}
	else
	{
		printf("VMware is NOT detected.\n");
	}
	return 0;
}

VMware の検出には、バックドアポートを使用している。しかし、そこで使用している in 命令は VMware の無い環境下では例外を発生させてしまう。そこで、この部分で発生した例外をキャッチし、適切に握り潰しているのが上記のサンプルだ。64-bit、gcc 4.6 ベースの mingw-w64 では適切に動作する。ここではさらに洗練された握り潰し方を採用している。JumpTarget はダミーの .long で適当に埋めて、その上で in 命令の 1 バイトだけが __try ブロックに相当するような実装になっている。(end に相当するラベルがない。) ここで例外が発生した場合、in 命令だけをスキップさせ、VMware が検出されなかったことを示すダミーの数字を入れることで、ちゃんと「非 VMware」の検出にも成功しているのだ。
ちなみに、__attribute__((noinline)) と書いているのは、不必要な (-O3 などのオプションによる) インライン化を防止するためだ。MS 製コンパイラなら SEH のエントリを適切に生成してくれるが、mingw-w64 ではまだそうはいかない。万が一インライン化されると不必要な衝突を引き起こす可能性がある (今回のコードの場合は多分問題ないけど。) ため、この属性を付けてインライン化を防止している。

まとめ

というわけで、どうだろう? 御世辞にも洗練されたとは言えない実装だが、x64、非 MS コンパイラでも一応 SEH がマトモに使えるようになってきた。あなたなら、これをどう使う?

*1:全くできないわけではないのだが、マトモなプログラムで gs:0 を弄るヤツは見たことがない。

*2:これは objdump が例外ハンドラのための情報を解釈できないため。加えて、関数テーブルのフラグである UNW_FLAG_EHANDLER も、この関数が例外ハンドラを持っていることを示している。

*3:ソースコード中の __C_specific_handler (msvcrt, ntdll, ntoskrnl など、システム中の複数の DLL で定義されている) は、システムが保持するデフォルトの例外ハンドラで、このルーチンにより同じくソースコード中に記述されている _gnu_exception_handler が呼び出されることになる。この関数は基本的に POSIX 風の signal を呼び出し、それによって適切に処理されなければアプリケーションを強制終了させる。

*4:32-bit 版 mingw-w64-crt は、SetUnhandledExceptionFilter のみを使って signal を実装しているので、これを置き換えればクラッシュすることは予想できた。

*5:ちなみに当該部分の直前、.long 1 というのは、この 4 つ組の RVA で構成される例外ハンドラ情報の数である。

*6:いわゆる off-by-one エラーの変種。

*7:いわゆる POSIX シグナルのこと。