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;
}