コンパイラが出力するCALL直後のマーカーには実際に意味があるという話
この記事はWindows x64 PE(Portable Executable)バイナリに関する話です。
UBマーカー
このようなディスアセンブリを見たことはありますか。
.text:0000000140097D70 panic_unwind::imp::exception_copy proc near
.text:0000000140097D70 ; DATA XREF: __rustc::__rust_start_panic+67↑o
.text:0000000140097D70 ; .pdata:00000001400D8804↓o
.text:0000000140097D70
.text:0000000140097D70 var_30 = qword ptr -30h
.text:0000000140097D70 var_28 = qword ptr -28h
.text:0000000140097D70 var_20 = qword ptr -20h
.text:0000000140097D70 var_18 = xmmword ptr -18h
.text:0000000140097D70
.text:0000000140097D70 55 push rbp
.text:0000000140097D71 48 83 EC 50 sub rsp, 50h
.text:0000000140097D75 48 8D 6C 24 50 lea rbp, [rsp+50h]
.text:0000000140097D7A 48 8D 05 47 6B 01 00 lea rax, off_1400AE8C8 ; "Rust panics cannot be copied"
.text:0000000140097D81 48 89 45 D0 mov [rbp+var_30], rax
.text:0000000140097D85 48 C7 45 D8 01 00 00 00 mov [rbp+var_28], 1
.text:0000000140097D8D 48 C7 45 E0 08 00 00 00 mov [rbp+var_20], 8
.text:0000000140097D95 0F 57 C0 xorps xmm0, xmm0
.text:0000000140097D98 0F 11 45 E8 movups [rbp+var_18], xmm0
.text:0000000140097D9C 48 8D 15 35 6B 01 00 lea rdx, off_1400AE8D8 ; "library\\panic_unwind\\src\\seh.rs"
.text:0000000140097DA3 48 8D 4D D0 lea rcx, [rbp+var_30]
.text:0000000140097DA7 E8 84 F7 FF FF call _ZN4core9panicking9panic_fmt17h4d3d8cfb17141925E ; core::panicking::panic_fmt
.text:0000000140097DA7
.text:0000000140097DA7 ; ---------------------------------------------------------------------------
.text:0000000140097DAC CC db 0CCh
.text:0000000140097DAC panic_unwind::imp::exception_copy endp
一見するとなんの変哲もないディスアセンブリですが、注意深く見てみると、callの直後に0xCCが挿入されていることが確認できます。0xCCはx86-64ではよく知られたブレークポイントを発生させるInt3命令ですが、Rustのパニックの性質上、直前のcore::panicking::panic_fmtの呼び出しが帰ってくることはないため、この関数はnoreturnとなり、理論上、この0xCCが実行されることはありません。
もっとよく知られたLLVMによるコード生成では、帰ってくることのないcallやint 29h(Windows固有の__fastfail例外)の直後にUB(Undefined Behavior、未定義動作)マーカーとしてud2などの命令が挿入されることもあります。もっとも、これはRustでコンパイルされたバイナリではより一般的です。
.text:00000001400281AB loc_1400281AB: ; CODE XREF: test__formatters__json__impl$1__write_run_start_std__io__stdio__StdoutLock_+18A↑j
.text:00000001400281AB 48 C7 45 08 00 00 00 00 mov [rbp+70h+var_68], 0
.text:00000001400281B3 48 8D 05 9E 50 07 00 lea rax, off_14009D258 ; "library\\test\\src\\formatters\\json.rs"
.text:00000001400281BA 48 89 44 24 20 mov [rsp+0F0h+var_D0], rax
.text:00000001400281BF 4C 8D 05 6A 50 07 00 lea r8, unk_14009D230
.text:00000001400281C6 48 8D 55 B8 lea rdx, [rbp+70h+var_B8]
.text:00000001400281CA 4C 8D 4D 08 lea r9, [rbp+70h+var_68]
.text:00000001400281CE 31 C9 xor ecx, ecx
.text:00000001400281D0 E8 5D D1 06 00 call _ZN4core9panicking13assert_failed17haca43a901ddfd3c0E ; core::panicking::assert_failed
.text:00000001400281D0 ; } // starts at 14002815C
.text:00000001400281D0
.text:00000001400281D5 ; ---------------------------------------------------------------------------
.text:00000001400281D5
.text:00000001400281D5 loc_1400281D5: ; DATA XREF: .rdata:00000001400C0B4C↓o
.text:00000001400281D5 0F 0B ud2 ; UB マーカー
.text:00000001400281D5 ; } // starts at 140027FD0
あるいは、noreturn以外の基本ブロックにおけるcall直後のnopなどは一般的に命令IPのアラインメントを目的としていますが、本記事では扱いません。
それでは、これらの0xCCとud2マーカーは同一の意味を持つのでしょうか。いいえ、Windows x64バイナリでは、call直後のマーカーが実際にマーカー以外の目的を持つことがあります。
巻き戻し
Windows x64では、SEH(Structured Exception Handler、構造化例外処理)が例外処理を行います。これは、__try、__catch、__finallyや、try、catchなどのC++EH、あるいは言語固有の例外ハンドラが含まれます。x86バイナリではフレームベースの巻き戻しがおこなれますが、ここでは言及しません。
Windows x64のPEバイナリには、これらの例外をどのように処理し、巻き戻しを行うべきかといった情報を参照することのできる例外テーブルというものが存在します。これは、RUNTIME_FUNCTIONと呼ばれる構造体で表されます。
BeginAddress及びEndAddressは、その巻き戻し情報が結び付けられる関数のRVA(Relative Virtual Address、イメージ相対アドレス)範囲を表しています。UnwindInfoAddressは、UNWIND_INFO構造体へのRVAを表します。
typedef struct _IMAGE_RUNTIME_FUNCTION_ENTRY {
DWORD BeginAddress;
DWORD EndAddress;
union {
DWORD UnwindInfoAddress;
DWORD UnwindData;
} DUMMYUNIONNAME;
} RUNTIME_FUNCTION, *PRUNTIME_FUNCTION, _IMAGE_RUNTIME_FUNCTION_ENTRY, *_PIMAGE_RUNTIME_FUNCTION_ENTRY;
例外テーブルは、このRUNTIME_FUNCTIONの配列です。
Windowsは、関数で例外が発生した際、バイナリの例外テーブルをバイナリサーチし、ControlPc(例外が発生した命令のRIP)に対応する巻き戻し情報を探し、その情報に基づいて不揮発性レジスタの復元やクリーンアップハンドラなど言語固有のハンドラを呼び出します。
NTSYSAPI PRUNTIME_FUNCTION RtlLookupFunctionEntry(
DWORD64 ControlPc,
PDWORD64 ImageBase,
PUNWIND_HISTORY_TABLE HistoryTable
);
ここで例えば、コールスタックに複数の関数がある場合を仮定します。
foo
bar
baz
この場合、bazで例外が発生すると、bar及びfooが独自のフレームを持っており、不揮発性レジスタの保存をしている可能性がありますから、正常に巻き戻しを行って状態を復元するためには、コールスタック内の関数をすべて処理する必要があります。この場合、すべての関数が巻き戻し情報を持っていれば、Windowsはコールスタックを辿り、すべての関数に対して巻き戻しを行います。
ここで、下記のアセンブリを思い出してみましょう。
.text:0000000140097D70 panic_unwind::imp::exception_copy proc near
.text:0000000140097D70 ; DATA XREF: __rustc::__rust_start_panic+67↑o
.text:0000000140097D70 ; .pdata:00000001400D8804↓o
.text:0000000140097D70
.text:0000000140097D70 var_30 = qword ptr -30h
.text:0000000140097D70 var_28 = qword ptr -28h
.text:0000000140097D70 var_20 = qword ptr -20h
.text:0000000140097D70 var_18 = xmmword ptr -18h
.text:0000000140097D70
.text:0000000140097D70 55 push rbp
.text:0000000140097D71 48 83 EC 50 sub rsp, 50h
.text:0000000140097D75 48 8D 6C 24 50 lea rbp, [rsp+50h]
.text:0000000140097D7A 48 8D 05 47 6B 01 00 lea rax, off_1400AE8C8 ; "Rust panics cannot be copied"
.text:0000000140097D81 48 89 45 D0 mov [rbp+var_30], rax
.text:0000000140097D85 48 C7 45 D8 01 00 00 00 mov [rbp+var_28], 1
.text:0000000140097D8D 48 C7 45 E0 08 00 00 00 mov [rbp+var_20], 8
.text:0000000140097D95 0F 57 C0 xorps xmm0, xmm0
.text:0000000140097D98 0F 11 45 E8 movups [rbp+var_18], xmm0
.text:0000000140097D9C 48 8D 15 35 6B 01 00 lea rdx, off_1400AE8D8 ; "library\\panic_unwind\\src\\seh.rs"
.text:0000000140097DA3 48 8D 4D D0 lea rcx, [rbp+var_30]
.text:0000000140097DA7 E8 84 F7 FF FF call _ZN4core9panicking9panic_fmt17h4d3d8cfb17141925E ; core::panicking::panic_fmt
.text:0000000140097DA7
.text:0000000140097DA7 ; ---------------------------------------------------------------------------
.text:0000000140097DAC CC db 0CCh
.text:0000000140097DAC panic_unwind::imp::exception_copy endp
この関数はPUSH RBPで関数フレームを作成し、core::panicking::panic_fmtは巻き戻し情報を持っており、Rustのパニックは最終的にRaiseExceptionを呼び出すため、このコードパスは必ず正常に巻き戻し可能でなければなりません。
ここで改めてcall直後の0xCCの意味について考えてみます。core::panicking::panic_fmtの呼び出しを行うと、リターンアドレスがスタックにプッシュされます。このリターンアドレスは巻き戻しを行う上で、Windowsが例外をディスパッチする際に参照するため、重要です。そのリターンアドレス、本当に関数の中にありますか?
いいえ、そのリターンアドレスは関数の中には存在しません。
RtlLookupFunctionEntryが内部的に関数境界をどのように扱うかを見てみます。RtlLookupFunctionEntryはControlPcがRUNTIME_FUNCTIONの範囲にあるかどうか をチェックしています。
つまり、Windowsの巻き戻し情報の検索では、[BeginAddress, EndAddress)のように、厳密な半開区間で関数の範囲を扱います。
PRUNTIME_FUNCTION __stdcall RtlLookupFunctionEntry(
ULONG64 ControlPc,
PULONG64 ImageBase,
PUNWIND_HISTORY_TABLE HistoryTable)
{
// ...
if (ControlPc >= ImageBase + FunctionEntry->BeginAddress &&
ControlPc < ImageBase + FunctionEntry->EndAddress)
// ...
}
つまり、call直後のマーカーは、callが関数境界にあるとき、リターンアドレスを関数内に留めるという意味に(も)なりえるということでした。あくまでアドレスの調整のためですから、他にUBマーカーなどとしての役割がない限り、1バイト以上の何でもかまわないということです。
もっとわかりやすい例を示します。seh_proc及びseh_endprocはコンパイラに対して、RUNTIME_FUNCTIONの境界を示します。
.text
.globl foo
.align 16
.seh_proc foo
foo:
pushq %rbp
.seh_pushframe
movq %rsp, %rbp
.seh_setframe %rbp, 0
subq $32, %rsp
call cexit
;.byte 0CCh は不要
.seh_endproc
コンパイラは境界にあるcallを認識し、適切に余裕を持ったRUNTIME_FUNCTIONを出力します。
.text:0000000140001000 sub_140001000 proc near ; CODE XREF: main+C↓p
.text:0000000140001000 ; DATA XREF: .pdata:ExceptionDir↓o BeginAddress
.text:0000000140001000 55 push rbp
.text:0000000140001001 48 89 E5 mov rbp, rsp
.text:0000000140001004 48 83 EC 20 sub rsp, 20h
.text:0000000140001008 E8 03 00 00 00 call sub_140001010
.text:0000000140001008
.text:0000000140001008 sub_140001000 endp ; EndAddressはここではない
.text:0000000140001008
.text:0000000140001008 ; ---------------------------------------------------------------------------
.text:000000014000100D algn_14000100D: ; DATA XREF: .pdata:ExceptionDir↓o EndAddress
.text:000000014000100D CC CC CC align 10h
以上、コンパイラが出力するCALL直後のマーカーには実際に意味があるという話でした。この動作をデバッグして原因を突き止めるのに6時間以上を溶かしました。
Discussion
ちなみにこの問題はLLVMでSEHが"非常に壊れている"原因でもあるので、いつかこれも書きたい。
MSVCのSEH/EHコンパイラテストはClang/LLVMでクラッシュする…