はじめに
この記事を読まれる皆さんは、普段からデバッガーを使っているのではないでしょうか。 デバッガーを用いて関数の呼び出し履歴であるコールスタックを参照することは、ダンプファイルの解析やリバースエンジニアリングに役立ちます。 では、そんなデバッガーのコールスタックの構築方法はご存じでしょうか。
VisualStudio でビルドされた 32bit の Windows 実行ファイル(x86)を確認すると、関数のプロローグで call 直後にスタックのベースポインタ(ebp)を保存、ebp をスタックの先頭に更新という処理が見られます。
このため、32bit の実行ファイルにおいては ebp を辿っていくことで、コールスタックを構築しています。 しかし、64bit の実行ファイルでは、関数プロローグを確認するとスタックのベースポインタの保存が見られない場合もあります。 こういった場合デバッガーはどのようにしてコールスタックを構築しているのでしょうか。
本記事では普段のデバッガ利用ではあまり気にしていない、64bit の実行ファイルにおけるコールスタック構築の仕組みを解説します。 Windows の x86(AMD64)の実行ファイルを取り扱いますので、これに関するアセンブリがある程度理解できる方を対象読者としています。
32bit・64bitの関数プロローグ比較
始めに 32bit の実行ファイルと 64bit の実行ファイルの関数プロローグを比較してみます。
比較のために、以下のようなプログラムを VisualStudio で 32bit・64bit それぞれビルドしてみます。
そして、func1
関数を逆アセンブルすることで、getchar
呼び出し時点におけるコールスタックの構築方法の違いを見てみます。
#include <iostream> void func1(int a) { getchar(); printf("func1 %d", a); } int wmain(int argc, wchar_t **argv) { func1(10); return 0; }
32bit のビルドでは以下のようなアセンブリが出力されました。 関数の先頭で ebp をプッシュし、esp の値を ebp に代入しています。 call 命令は関数呼び出し前にリターンアドレスを push するため、ebp を参照することで、call 直前にプッシュされたこの関数のリターンアドレスを調べることができます。 これを繰り返してくことで、コールスタックの構築が可能になります。
00971090 push ebp 00971091 mov ebp,esp 00971093 call getchar (097484Dh) 00971098 mov eax,dword ptr ss:[ebp+8h] ; a 0097109B push eax 0097109C push 987858h ; "func1 %d" 009710A1 call printf (0971040h) 009710A6 add esp,8 009710A9 pop ebp 009710AA ret
一方 64bit では以下のような出力になりました。 32bit と異なり、ebp の拡張レジスタである rbp がプッシュされていません。 このような短い関数の場合においては、00007FF6B0C910F4 で 0x28 バイトのスタック確保が行われていることが分かります。 そのため目視にて、ローカル変数確保直前のスタックの先頭である rsp + 0x28 を参照することでリターンアドレスを得られるということが分かります。 しかし、関数内の任意の場所でこのような方法を用いたコールスタックを構築する方式の場合、スタック確保しているアドレスの特定やバイナリのパースなどいくつか問題があります。 またパースの不備などで、コールスタックが正常に取れないこともありそうです。 特に巨大な関数内や条件分岐後でコールスタックを構築を試みた場合、ローカル変数を確保しているアドレスの特定が困難です。 もっと良い方法はないのでしょうか。
00007FF6B0C910F0 mov dword ptr [rsp+8h],ecx 00007FF6B0C910F4 sub rsp,28h 00007FF6B0C910F8 call getchar (07FF6B0C94580h) 00007FF6B0C910FD mov edx,dword ptr [rsp+30h] ; a 00007FF6B0C91101 lea rcx,[__real@c33fffffffffffff+8h (07FF6B0CAA0C0h)] ; "func1 %d" 00007FF6B0C91108 call printf (07FF6B0C91060h) 00007FF6B0C9110D add rsp,28h 00007FF6B0C91111 ret
Exception Directory
実は、64bit 実行ファイルのコールスタック構築の方式では、レジスタに格納されている値とは別のものが使われています。 それは、Windows の実行ファイルのフォーマットとして使用される Portable Executable(PE) 内の Exception Directory です。
PE 内にはそのプログラムを実行するために必要な情報が保存されています。 代表的な例として、そのプログラムの使用する外部関数情報を記録する Import Directory があげられます。 他にも様々な情報が含まれており、Exception Directory もこの 1 つです。
Exception Directory の主目的は、構造化例外処理ですが今回はこれに関しては説明しません。
Exception Directory の指す先には、スタック確保や、関数呼び出しを行う関数に関する情報が、RUNTIME_FUNCTION という構造体で全て記録されています[1]。 逆にスタック確保及び関数呼び出しを行わない関数は、これに対する RUNTIME_FUNCTION は存在しません。
RUNTIME_FUNCTION の定義は以下です[2]。
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;
BeginAddress 及び EndAddress は関数の始まりと終わりの RVA(ロードアドレスからの差分)になります。 UnwindInfoAddress は UNWIND_INFO 構造体への RVA であり、ここにプロローグサイズ、確保するスタックサイズといった、コールスタック構築に必要な情報が保存されています。 つまり RUNTIME_FUNCTION を順に参照することで、リターンアドレスを保存して、コールスタックを構築しています。
検証
本当に Exception Directory を使用してデバッガーがコールスタックの構築しているのか検証してみます。
使用するデバッガーは WinDbg です。
WinDbg は k コマンドでコールスタックを見ることができます。
例で取り上げたプログラムの 64bit 版のgetchar
呼び出し時点(例のアドレスでは 00007FF6B0C910F8)でコールスタックを確認してみます。
結果としては以下のようになり、正常にコールスタックが記録されていることが分かります。(シンボルファイルが存在するため、本来はソースコードのディレクトリなどの情報も記載されていますがここでは省略しています)
0:000> k # Child-SP RetAddr Call Site 00 00000085`d250fa60 00007ff6`b0c91137 testx64!func1+0x8 01 00000085`d250fa90 00007ff6`b0c91364 testx64!wmain+0x17 02 (Inline Function) --------`-------- testx64!invoke_main+0x22 03 00000085`d250fac0 00007ffe`e2277034 testx64!__scrt_common_main_seh+0x10c 04 00000085`d250fb00 00007ffe`e3de2651 KERNEL32!BaseThreadInitThunk+0x14 05 00000085`d250fb30 00000000`00000000 ntdll!RtlUserThreadStart+0x21
続いて、RUNTIME_FUNCTION の情報を書き換えて同様の検証をします。
実験としてwmain
の RUNTIME_FUNCTION の UnwindInfoAddress を 0 にしてみます。
もし WinDbg が RUNTIME_FUNCTION を使用してコールスタックを構築しているならば、invoke_main
以降のコールスタックが取れないはずです。
RUNTIME_FUNCTION の書き換えについては、今回は PE-bear[3]を使用しました。
当環境ではwmain
の RVA は 0x1120 でした。
そのため、BeginAddress が 0x1120 となる RUNTIME_FUNCTION を探し、UnwindInfoAddress の値を 0 にして実行ファイルを上書きします。
Exception Directory は下図のように、Exception タブから参照できます。
書き換えたプログラムで検証の結果、以下のように wmain
までしか表示されませんでした。
予想通りinvoke_main
以降のコールスタックが消えています。
やはり WinDbg は RUNTIME_FUNCTION を使用してコールスタックを構築しているようです。
0:000> k # Child-SP RetAddr Call Site 00 0000001f`13f9fd90 00007ff6`d7401137 testx64!func1+0x8 01 0000001f`13f9fdc0 00000000`00000000 testx64!wmain+0x17
余談:最適化が進んだ32bitの実行ファイルの場合
実は 32bit の実行ファイルでもコンパイラの最適化によっては ebp が保存されないこともあります。
例とは異なるプログラムのwmain
にはなりますが、以下のように出力されることもあります。
; push ebpなどのプロローグはない 005F1890 push esi 005F1891 mov esi,dword ptr [__imp__CreateDirectoryW@8 (060C000h)] 005F1897 push 0 005F1899 push 617694h 005F189E call esi (略)
こういった場合、コールスタックはどうなるでしょうか。 WinDbg の場合、pdb ファイルの有無で変わります。 結論から申しますと pdb ファイルが存在しない場合、このような関数はコールスタック上に現れません。
pdb ファイルとはデバッグ時に利用するシンボルなどを記録した情報になります。
pdb ファイルは VisualStudio の場合、通常実行ファイルと同じフォルダに生成されます。
上記ファイルの 005F1890 時点におけるコールスタックを取ってみます。
まず pdb ファイルが存在する場合、以下のようになりました。
__scrt_common_main_seh
内のinvoke_main
からwmain
が呼ばれていることが分かります。
0:000> k # ChildEBP RetAddr 00 0075fb44 005f49aa test!wmain 01 (Inline) -------- test!invoke_main+0x1c 02 0075fb8c 7609fa29 test!__scrt_common_main_seh+0xfa 03 0075fb9c 77e87a9e KERNEL32!BaseThreadInitThunk+0x19 04 0075fbf8 77e87a6e ntdll!__RtlUserThreadStart+0x2f 05 0075fc08 00000000 ntdll!_RtlUserThreadStart+0x1b
続いて pdb ファイルを消して同様の検証をします。
__scrt_common_main_seh
およびinvoke_main
のコールスタックが消えています。
0:000> k # ChildEBP RetAddr WARNING: Stack unwind information not available. Following frames may be wrong. 00 010ff790 7609fa29 test+0x1890 01 010ff7a0 77e87a9e KERNEL32!BaseThreadInitThunk+0x19 02 010ff7fc 77e87a6e ntdll!__RtlUserThreadStart+0x2f 03 010ff80c 00000000 ntdll!_RtlUserThreadStart+0x1b
以下の__scrt_common_main_seh
のプロローグを確認するとわかるのですが、こちらも同様にベースポインタを保存していません。
そのため、ebp を利用したコールスタック構築においてこの関数を表示できません。
006948b0 push 14h 006948b2 push offset test!_CT??_R0?AVout_of_rangestd+0x74 (006b8a48) 006948b7 call test!__SEH_prolog4 (00695400) 006948bc push 1 006948be call test!__scrt_initialize_crt (00694b97) (略)
また、コールスタックにも表示されていますが、アセンブリを確認するとinvoke_main
はインライン展開されており、__scrt_common_main_seh
の処理の一部ということが分かります。
他にも pdb ファイルが存在しない場合のコールスタックを確認すると、Stack unwind の情報が利用できないと表示されています。
これらより WinDbg はコールスタックの構築を pdb ファイルを基に行っており、存在しない場合においては ebp を使用しコールスタックを構築していることが推測できます。
そのため、 pdb ファイルが存在しない場合においては、完全なコールスタックの構築をできないことがあります。
まとめ
32bit の実行ファイルの場合は、レベルの高い最適化やインライン展開等されていない場合に限り、特別なデータを参照することなくコールスタックの構築が可能です。 これは関数がベースポインタを保存するため、その関数のリターンアドレスの取得が可能であるためです。 一方 64bit の実行ファイルにおいて、コールスタックを構築するためには、殆どの場合 RUNTIME_FUNCTION を参照する必要があります。 32bit の場合と異なり、基本的に関数内でベースポインタを保存しないことにより、関数のリターンアドレスの取得が困難なためです。
64bit の実行ファイルには、各関数のスタックサイズ等の情報を保持した、RUNTIME_FUNCTION というデータが存在します。 デバッガ等は RUNTIME_FUNCTION を参照し、スタックサイズなどの情報からリターンアドレスが保存されているアドレスを取得できます。 取得したリターンアドレスが属する関数の RUNTIME_FUNCTION を参照することで、さらにこの呼び出し元関数の情報を取得できます。 これを繰り返していくことで 64bit の実行ファイルにおいてもコールスタックの構築が可能になります。
おわりに
今回は簡単な検証を通じて、デバッガーが 64bit の PE においては PE 内の情報を用いてコールスタックを構築していることを確認しました。 短い記事ではありますが、64bit のコールスタックってどうやって作っているんだろう、と疑問に思われている方の解決に役立てれば幸いです。
エンジニア募集
FFRIセキュリティでは Windows を使ったセキュリティの研究開発、マルウェアの解析などを行っています。採用に関しては こちら を御覧ください。
参考文献
[1] https://docs.microsoft.com/en-us/cpp/build/exception-handling-x64?view=msvc-170
[2] https://docs.microsoft.com/en-us/windows/win32/api/winnt/ns-winnt-runtime_function