FFRIエンジニアブログ

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

64bitのPEにおけるコールスタック構築方法の解説

はじめに

この記事を読まれる皆さんは、普段からデバッガーを使っているのではないでしょうか。 デバッガーを用いて関数の呼び出し履歴であるコールスタックを参照することは、ダンプファイルの解析やリバースエンジニアリングに役立ちます。 では、そんなデバッガーのコールスタックの構築方法はご存じでしょうか。

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 タブから参照できます。

図1 Exception Directory の上書き

書き換えたプログラムで検証の結果、以下のように 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

[3] https://hshrzd.wordpress.com/pe-bear