21: ゲーム内関数の引数を調査する

概要

ゲームを解析している際、ある関数の引数にどんな値が入るのかを知りたい場合がある。
こういう時、実際にデバッガでその関数にブレークポイントを打って引数を逐次解析すれば良いが、それだと上手く行かない場合がある。 例えば、PwnAdventure3には GetItemByName という関数があり、引数で指定したアイテム名のアイテムへのポインタを取得できる。 そのポインタを AddItem関数の引数にセットして呼ぶことで、そのアイテムを取得する事ができる。 つまり、ゲーム上でアイテムを拾うと AddItem が呼ばれ、その前に GetItemByName が呼ばれる。
ここで、ゲーム上でアイテムを拾った時にそのアイテム名を取得したいとする。 この場合、GetItemByNameの引数を調査すれば良いが、実はGetItemByNameは他の色んな所で何度も呼び出されており、 ブレークポイントを打つと再開しても絶え間なくそこでブレークする。 これだと、プレイヤーを動かす暇さえ無いので、"アイテムを拾ったとき" の GetItemByName の引数を調査できない。
今回は、ゲーム内の関数の引数をデバッグ出力するようにフックを掛ける事で、引数を調査するという事を目標にやっていく。
チート対象のゲームは、x86でチートの練習用に作られた、Pwn Adventure 3を使います。
Windows対象で、C++を言語として用いる。ソースコードはまとめてページの一番下にある。 また、18回の記事general-cheatsのコードをそのまま使っています。
※ ネタバレ注意: 記事の最後の方の画像でPwnAdventure3で取得できるアイテムの一部が載っています

解析する関数とやる事の概観

PwnAdventure3はPDBファイルが配られているため、関数にも分かりやすい名前が付いており、その名前から関数の処理が予測できてしまう。 普通はPDBファイルは配られていないため、あまりこれに頼りたくは無いが、今回は本質ではないので多いに活用する。 また、引数の意味なども同じく本題ではないので、他の方のチートコード を参考にしている。

概要でも述べた通り、解析するのは GetItemByName の引数である。 上記のGameLogic.dllをGhidraで開いた画面から、この関数は GameLogic.dll+0x1de20 にあり、 呼び出し規約__thiscall である事がわかります。第二引数の char *param_1 にアイテム名が来るので、 以下のようなイメージでフックをし、引数をデバッグ出力するのが目的です。 今までさんざん出てきたインラインフックですが、Hookに飛んでも元の引数(itemname)を保持しているというのが今回のポイントです。

レジスタやスタックの整合性を保つ

バイナリを書き換える際、レジスタとスタックの二つに気を配るのが大切です。
特にポイントとなるのは、関数呼び出し前後でのレジスタとスタックの変化です。
今回はまず、GetItemByName の最初の数バイトを上図のように call Hook に置き換えますが、 Hook を呼び出す前後では、レジスタの内容が大きく変わるので、以下のようにしてレジスタを退避し、関数から戻ってきた後に戻す必要があります。
pushad
call Hook
popad
次にスタックについて考えます。今回の目的は、第二引数であるitemnameを出力する事で、 GetItemByName__thiscall なので、itemname は以下のようにスタックに入っているはずです。 上記の call GetItemByName 後に、上記の pushad が実行されることになります。 pushadでは、EAX, ECX, EDX, EBX, ESP, EBP, ESI, EDI の計8個のレジスタがスタックにpushされます。 そして、call Hook でリターンアドレスも入るので、call Hook の実行後にはスタックは以下のようになっているはずです。 つまり、Hook関数でitemnameにアクセスするには、このように宣言する必要があります。
void Hook(
	DWORD eax, DWORD ecx, DWORD edx, DWORD ebx, DWORD esp, DWORD ebp, DWORD esi, DWORD edi,
	void* returnaddress1, const char* itemname
)
DWORD型は別に何でも良いのですが、32bitのこのバイナリでは汎用レジスタのサイズも32bitなので、型も同じサイズのDWORDを使用しています。 これで後は、DbgPrint("%s", itemname) を中で呼び出せばデバッグ出力は完成です。 DbgPrintに関しては、12回の記事を参考にしてください。

しかし、Hookの呼び出し規約に注意しなければなりません。
今回、pushadでスタックに退避した先にある引数にアクセスするために、Hookの引数に退避したレジスタを取るという不自然な形になっています。 通常、__stdcall等の呼び出し規約では、関数の引数は関数内でしか使われないため、関数から出た後にはスタックから消去されてしまいます。 ですが、Hookから出た後にこれらが消去されると、次のpopadでレジスタの内容が復元できなくなってしまいます。
ここで、__cdecl という呼び出し規約を使います。
push arg3
push arg2
push arg1
call Func
add esp, 0xc
mov retval, eax
__cdeclは、最後の add esp, 0xc で分かる通り、関数からリターン後に引数をスタックから消す事が想定されています。 つまり、Hook__cdecl で宣言すれば、関数から出てきた後に引数は残ったままになっていることになります (リターンアドレスはHook内で消されます)。
後は、popadでレジスタを復元すれば、レジスタとスタックの整合性は合います。

実装

上記を実装すれば良いのですが、その前にもう一点だけ追加します。
GetItemByName の引数にはアイテム名が入り、それをデバッグ出力したい訳ですが、この関数は何度も呼ばれているので、 例えば一番最初に取得できるアイテムである炎の魔法(GreatBallsOfFire)を持っていると、持っているだけで大量にGreatBallsOfFireと表示されてしまいます。 しかし、本来やりたいのはどんなアイテム名がここに入るのかを見たいだけなので、繰り返し表示されるのは除外したいです。
この機能を追加した結果、Hookは最終的に以下のようになります。 ややこしい見た目ですが、要は出てきたアイテム名をitemlistsに入れておき、そこにアイテム名が既にあったら、出力から除外する という事をしているだけです。
その他は通常通りのインラインフックなので、下にあるソースコードを参考にしてください。

試してみる

実際に上記のコードをビルドして実行してみると、DbgViewに以下のような感じで取得したアイテム等の名前が出てきます。 次の記事では、実際にこのようなアイテム名を GetItemByName にセットして呼び、 取得したポインタを AddItem の引数にセットする事により、任意のアイテムを取得するチートを作成していきます。

ソースコード

GetItemByNameの引数をデバッグ出力するDLL

#include "pch.h"
#include "general-cheats.h"
#include <string>


DWORD jmpBackAddr;
vector<string> itemlists;

void __cdecl Hook(
	DWORD eax, DWORD ecx, DWORD edx, DWORD ebx, DWORD esp, DWORD ebp, DWORD esi, DWORD edi,
	void* returnaddress, const char* itemname
) {
	string buf = string(itemname);
	if (itemlists.size()==0 || ( std::find(itemlists.begin(), itemlists.end(), buf) == itemlists.end() )) {
		DbgPrint("%s", itemname);
		itemlists.push_back(buf);
	}
}

void __declspec(naked) cheats() {
	__asm{
		pushad
		call Hook
		popad
		
		push ebp
		mov ebp, esp
		and esp, 0xfffffff8
		sub esp, 0x24

		mov eax, jmpBackAddr
		jmp eax
	}
}

// cheatsに飛ばす命令を作成して返す
//   B8 xx xx xx xx  -  mov eax, cheatsの先頭アドレス
//   FF E0           -  jmp eax
BYTE* initJmpToCheats(DWORD size) {
	BYTE* jmpToCheats = (BYTE*)malloc(size);
	memset(jmpToCheats, 0x90, size);

	BYTE buf1[] = { 0xB8 };
	memcpy(jmpToCheats, buf1, sizeof(buf1));

	DWORD* cheatsAddr = (DWORD*)cheats;
	memcpy(&jmpToCheats[sizeof(buf1)], &cheatsAddr, sizeof(DWORD));

	BYTE buf2[] = { 0xFF, 0xE0 };
	memcpy(&jmpToCheats[5], buf2, sizeof(buf2));

	return jmpToCheats;
}

DWORD WINAPI ThreadMain(LPVOID params) {
	DWORD baseAddr = (DWORD)GetModuleHandle("GameLogic.dll");
	DWORD* target = (DWORD*)(baseAddr + 0x1de20);
	DWORD size = 9;
	// 55            push ebp
	// 8b, ec        mov ebp, esp
	// 83, e4, f8,   and esp, 0xfffffff8
	// 83, ec, 24    sub esp, 0x24

	jmpBackAddr = (DWORD)target + size;

	BYTE* jmpToCheats = initJmpToCheats(size);

	DWORD curProtection;
	VirtualProtect(target, size, PAGE_EXECUTE_READWRITE, &curProtection);
	memcpy(target, jmpToCheats, size);
	DWORD temp;
	VirtualProtect(target, size, curProtection, &temp);

	return 0;
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
	if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
		CreateThread(0, 0, ThreadMain, hModule, 0, 0);
	}
	return TRUE;
}