🤔

コンパイラが出力するCALL直後のマーカーには実際に意味があるという話

に公開2

この記事は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によるコード生成では、帰ってくることのないcallint 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のアラインメントを目的としていますが、本記事では扱いません。

それでは、これらの0xCCud2マーカーは同一の意味を持つのでしょうか。いいえ、Windows x64バイナリでは、call直後のマーカーが実際にマーカー以外の目的を持つことがあります。

巻き戻し

Windows x64では、SEH(Structured Exception Handler、構造化例外処理)が例外処理を行います。これは、__try__catch__finallyや、trycatchなどの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が内部的に関数境界をどのように扱うかを見てみます。RtlLookupFunctionEntryControlPcRUNTIME_FUNCTIONの範囲にあるかどうか をチェックしています。

BeginAddress <= ControlPc < EndAddress

つまり、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の境界を示します。

program.S
.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