15: ゲーム内の関数をループで呼び出し連射させる
概要
ゲーム上では、例えばある魔法を打つ時はこの関数を呼び出す、マナを消費する時はこの関数 などのように様々な関数がある。 もっと言えば、プレイヤーや敵事にクラスがあり、そのメンバ関数として様々な機能が実装されていることが普通である。今回は、魔法を打つ関数呼び出しに、Inline Hookでループを加える事により、一回のキープレスで複数回魔法を打てるようにする。
チート対象のゲームは、x86でチートの練習用に作られた、Pwn Adventure 3を使います。
Windows対象で、
C++
を言語として用いる。ソースコードはまとめてページの一番下にある。また、今回はInline Hookは知ってる前提とするので、分からない方は11回の記事や、 14回の記事を参考にしてください。
シンボル(PDB)とは
まず、少し内容とは関係ないですが、シンボル情報とは何かについて話ます。このPwnAdventure3のメインの処理が書いてあるGameLogic.dllをGhidraとかで開くと分かると思いますが、 例えば既に
Player
といった構造体名が付いていたり、マナ消費に関する関数の名前がUseManaとなっていたりします。本来、プログラムをコンパイル/ビルドした時に、こういった関数の名前や構造体の名前、変数名などの情報は失われます。
GhidraやCheatEngineのデバッガというのは、ゲームの機械語をアセンブリやCに近い言語に復元するツールなので、機械語自体にこういった構造体名などの情報が無ければ、 この構造体の名前は
Player
だみたいに判別できません。しかし、このアドレスにある関数はこういう名前、この構造体はこういう名前という風に関連付けするファイルが別にあれば、こういった情報を知る事ができ、復元できます。 このようなある実行ファイルの変数名や構造体名などの情報の事をシンボル情報と言い、シンボル情報が書かれているファイルをPDBファイルと言います。
現に、
PwnAdventure3\Binaries\Win32\
を見てみると、GameLogic.dll
と共に、GameLogic.pdb
というファイルがあると思います。
GhidraにGameLogic.dllを読み込んだ時、このPDBファイルも読み込んでいたため、関数名とかが復元できていたというわけです。このゲームは、ハッキング用のゲームという事でこのファイルが配布されていますが、本来のゲームではPDBファイルは無い事が多いです。
そもそもこのPDBファイルはデバッグ用の物なので、実際にユーザーに渡すというのはこのゲームを解析してくださいと言ってるような物だからです。 間違ってこのファイルもユーザーに配布してるケースもあるかもしれないですが、まぁ普通はPDBファイルは無いので、 ある程度PDBファイルが無くても同じ手順でチートできるよう、無視できるときはシンボル情報を無視してチートしていきます。
トレース機能で魔法を打つ関数の特定
魔法を打つ関数はざっと以下の手順で特定できます。- 画面右下に表示されてるマナのアドレスをCheatEngineで特定する
- そこに書き込みを行っている命令をCheatEngineで特定する(マナを消費する関数が特定できる)
- マナを消費する関数の呼び出し元の関数を特定し、魔法を打つ関数を特定する
CheatEngineでメモリビューを開き、
Ctrl-g
を押すと入力したアドレスの場所に飛べるウィンドウが出てくるので、そこに以下のように入力して、
マナを消費する関数まで飛びましょう(ここは本題じゃないのでシンボル情報の恩恵を被っています)。

今回はトレース機能というデバッガの機能を使って呼び出し元を探ります。
トレース機能とは、ブレークポイントを打った命令の後に、どんな命令が実行されていくのかを一行一行記録する機能です。
やってみるとわかります。ゲーム上で魔法を取得した後、以下のように
UseMana
関数のどこかの命令上で右クリックして、
Break and trace instructions
を選択しましょう。
Maximal trace count
は1000
にしておきましょう。また、Step over instead of single step
にもチェックを付けましょう。

single step
ではなくstep over
しろという事ですが、single step
とは命令をそのまま一つずつ記録していくことで、
例えばcall
やjmp
命令があれば、呼び出した関数の方の命令も記録していきます。しかし、step over
は、call
やjmp
があっても、その中身は記録しません。つまり、関数呼び出しがあったらその関数の中身までは記録しません。今回は、
UseMana
関数の呼び出し元が見たいのであって、UseMana
関数から呼ばれる関数を見たいわけではありません。だからここにチェックを入れています。では実際に
OK
を押し、デバッガをアタッチしますがよろしいですかと聞いてくるウィンドウが出てくるのでこれもYes
を押し、トレースを始めましょう。
Tracerウィンドウが出てきた後、ゲーム上で魔法を打ってみると、ちょっとゲームが止まったかと思いますが、焦らず待つと、下図のようにトレース結果が表示されるはずです。

Expand all
を押しましょう。
そうすると、下図のようになるはずです。

UseMana
関数上でブレークポイントを打った時点から、どのような命令が実行されているのかがわかります。トレース結果の下図の部分に着目してください。

UseMana
関数からリターンした後に、GreatBallsOfFire
クラスのPerformActivate
関数に行っているので、
GreatBallsOfFire::PerformActivate
関数が呼び出し元であることがわかります。さらに、LocalWorld::Activate
関数がGreatBallsOfFire::PerformActivate
関数の呼び出し元であることもわかります。もちろんGhidraで関数名を眺めれば、こんな事をしなくても
GreatBallsOfFire::PerformActivate
関数が怪しいなというのはわかると思いますが、
PDBがない場合はこのように、確実にわかるマナの値という情報から、マナを消費する関数を探し、その関数の呼び出し元を辿っていくという方法がつかえます。
とはいえ、呼び出し元の何番目が実際に魔法を打つ関数なのかというのは、GreatBallsOfFire::PerformActivate
というシンボル情報があったからこれだってすぐ分かりましたが、
実際はその関数周辺をGhidraで解析していき、処理内容からこれが魔法を打つ関数だと判断していくことになります。今回はそこまではめんどくさいのでやりませんが。
アセンブラでの関数呼び出し
実際にコードを書く前に、アセンブラで関数呼び出しがどのように行われるかだけ説明します。もちろん基本は
call <関数の先頭アドレス>
なのですが、引数や戻り値がどこにあるのか、その時コールスタックはどうなっているのかという話です。例えば引数の値は、レジスタで渡したり、スタックで渡したりと色んな方法が考えられますが、実は方法は一つだけではなく複数の種類があり、 どのように引数を渡したりするかという関数呼び出しのルールを、呼び出し規約と言います。
一般的な呼び出し規約は
__stdcall
という物で、引数は全てスタックに入れられ、関数からリターンする前にスタックの状態は元通りにしてから戻ってくる規則で、
戻り値はeax
レジスタに入ります。簡単に表すと以下のような感じです。
push arg5
push arg4
push arg3
push arg2
push arg1
call Func
mov retval, eax
また、ゲーム上の関数は大体はあるクラスに所属するメンバ関数となっていますが、メンバ関数はthis
を扱えるように、
__thiscall
という呼び出し規約になっていて、this
の部分がecx
レジスタに入るようになっています。
push arg4
push arg3
push arg2
push arg1
mov ecx, this
call Func
mov retval, eax
また、__fastcall
という呼び出し規約もあり、これは最初の二つの引数はレジスタで渡し、スタックの状態を戻すのは関数から返ってきた後に行う規約で、
以下のようになっています。
push arg5
push arg4
push arg3
mov edx, arg2
mov ecx, arg1
call Func
add esp, ...
mov retval, eax
要は、関数呼び出しをいじる際はこのように引数やスタックの状態に気を付けないといけないということです。もっと言えば、レジスタに関しては全部に気を付けないといけないです。なぜなら、関数を呼び出した後、呼び出し先でどのようにレジスタが使われるかはわからないので、 関数を呼び出す前に、保持しておきたいレジスタの値はスタックに退避などしておく必要があります。
インラインフックで書き換える命令の構築
上記で、魔法を打つ関数はGreatBallsOfFire::PerformActivate
であることがわかったので、その呼び出し元であるLocalWorld::Activate
関数をGhidraで見てみます。
トレース結果から、LocalWorld::Activate+19
の位置にリターンしてきているという事がわかるので、その一個前のcall
命令がGreatBallsOfFire::PerformActivate
を呼び出していることがわかります。

call
させれば、一度に何度も魔法を打つようになる事がわかります。では、どこからどこまでの命令を移動するかを考えます。上図において、
JZ
命令までがデコンパイル上のif(param_1 != (Player *)0x0)
にあたり、それ以降の命令MOV > LEA > PUSH > MOV
では関数呼び出しに必要な引数の準備をしています。そしてCALL
をして魔法を打つ関数を呼び出しています。関数を何回も呼び出す場合、関数を呼び出す前に引数ももちろん毎回セットしないといけないので、
MOV > LEA > PUSH > MOV > CALL
の5つの命令を移動する事にしましょう。
ちなみにGhidraだとレジスタの名前が変数名に置き換わったりしていますが、実際のレジスタ名に直したい場合は、機械語の方をコピーしてこのサイトとかで
Disassembleすると変数名とかに置き換わってない状態の命令が見れます。それでは、実際にこの関数呼び出しをループで回す命令を書いていきます。以下のようになります。

ebx
レジスタを使っています。
また、jne $-0x15
というのは、一つ前のdec ebx
の結果が0じゃなければ(ebx
が0じゃなければ)、
現在の位置から0x15
バイト前の位置にジャンプするという意味です。緑の部分の内、何故push/pop ebx
をしているのかというと、
call [eax+0x80]
で呼び出した関数の中で、ebx
が書き換わるかもしれないからです。
書き換わっても大丈夫な用に、一度スタックに退避して、関数からリターン後に取り出しているわけです。ここで、勝手に
ebx
レジスタをカウンタとして使ってしまいましたが、元々ebx
レジスタに入っていた値は、
これら命令の後に使われるかもしれません。それなので、ピンクの部分でスタックに退避し、最後青い部分で元の命令の続きに戻る直前で復元しているわけです。インラインフックのDLLの作成と実行
では、実際にインラインフックを行うDLLを書いていきますが、ぶっちゃけ14回のコードとほぼ変わらないので、 ソースコードの説明は省きます(ソースコードはこのページの最後にあります)。これをビルドして実際に実行してみると、3発連射できるはず… なのですが、実は少し問題があります。
以下のgifを見るとわかる通り、確かに3発打っているのはマナの消費量とかも見てわかりますが、打つ間隔が短すぎて、3発中2発は衝突しています。

ちなみに、以下のようにアセンブラを書けば、魔法を打つ関数の呼び出しの合間に
Sleep(100)
を入れる事ができますが、
どうやらスリープしている間は魔法も止まってしまうみたいで結局衝突してしまいます。
push ebx
mov ebx, 0x5
push ebx
mov eax, [ebp+0x8]
mov ecx, [ebp+0xc]
lea edx, [eax+0x70]
push edx
mov eax, [ecx]
call [eax+0x80]
push 0x64
call Sleep
pop ebx
dec ebx
jne $-0x1d
pop ebx
mov ecx,jmpBackAddr
jmp ecx
結果は思った通りでは無いですが、ゲーム内の関数をループで呼び出す という点については完成です。次の記事では、魔法が重ならないよう、プレイヤーの位置を魔法を打つたびにずらしていき、対応していきます。
ソースコード
1回で3発の炎魔法を打つInlineHookのDLL
#include "pch.h"
#include <stdlib.h>
DWORD jmpBackAddr;
void __declspec(naked) cheats() {
__asm
{
push ebx
mov ebx, 0x3
push ebx
mov eax, [ebp + 0x8]
mov ecx, [ebp + 0xc]
lea edx, [eax + 0x70]
push edx
mov eax, [ecx]
call[eax + 0x80]
pop ebx
dec ebx
jne $ - 0x15
pop ebx
mov ecx, jmpBackAddr
jmp ecx
}
}
// cheatsに飛ばす命令を作成して返す
// B9 xx xx xx xx - mov ecx, cheatsの先頭アドレス
// FF E1 - jmp ecx
BYTE* initJmpToCheats(DWORD size) {
BYTE* jmpToCheats = (BYTE*)malloc(size);
memset(jmpToCheats, 0x90, size);
BYTE buf1[] = { 0xB9 };
memcpy(jmpToCheats, buf1, sizeof(buf1));
DWORD* cheatsAddr = (DWORD*)cheats;
memcpy(&jmpToCheats[sizeof(buf1)], &cheatsAddr, sizeof(DWORD));
BYTE buf2[] = { 0xFF, 0xE1 };
memcpy(&jmpToCheats[5], buf2, sizeof(buf2));
return jmpToCheats;
}
DWORD WINAPI ThreadMain(LPVOID params) {
DWORD baseAddr = (DWORD)GetModuleHandle("GameLogic.dll");
DWORD* target = (DWORD*)(baseAddr + 0x3b2ba);
DWORD size = 15;
// 8b 4d 0c MOV ECX, [EBP+0xC] <-- target
// 8d 50 70 LEA EDX, [EAX+0x70]
// 52 PUSH EDX
// 8b 01 MOV EAX, [ECX]
// ff 90 80 00 00 00 CALL [EAX+0x80]
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;
}