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ファイルが無くても同じ手順でチートできるよう、無視できるときはシンボル情報を無視してチートしていきます。

トレース機能で魔法を打つ関数の特定

魔法を打つ関数はざっと以下の手順で特定できます。
  1. 画面右下に表示されてるマナのアドレスをCheatEngineで特定する
  2. そこに書き込みを行っている命令をCheatEngineで特定する(マナを消費する関数が特定できる)
  3. マナを消費する関数の呼び出し元の関数を特定し、魔法を打つ関数を特定する
上記の内、1と2のやり方は14回の記事でやりました。
CheatEngineでメモリビューを開き、Ctrl-gを押すと入力したアドレスの場所に飛べるウィンドウが出てくるので、そこに以下のように入力して、 マナを消費する関数まで飛びましょう(ここは本題じゃないのでシンボル情報の恩恵を被っています)。 このマナを消費する関数の呼び出し元を辿れば、魔法を放つ関数がある事は当たり前にわかるので、呼び出し元を辿っていきます。

今回はトレース機能というデバッガの機能を使って呼び出し元を探ります。
トレース機能とは、ブレークポイントを打った命令の後に、どんな命令が実行されていくのかを一行一行記録する機能です
やってみるとわかります。ゲーム上で魔法を取得した後、以下のようにUseMana関数のどこかの命令上で右クリックして、 Break and trace instructionsを選択しましょう。
そうすると、今度は命令何個分記録するのかなどの設定画面が出てきます。トレースは一行一行記録していくので時間がかかるので、なるべく必要最小限が良いです。 今回はとりあえずMaximal trace count1000にしておきましょう。また、Step over instead of single stepにもチェックを付けましょう。 single stepではなくstep overしろという事ですが、single stepとは命令をそのまま一つずつ記録していくことで、 例えばcalljmp命令があれば、呼び出した関数の方の命令も記録していきます。しかし、step overは、calljmp があっても、その中身は記録しません。つまり、関数呼び出しがあったらその関数の中身までは記録しません。
今回は、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すると変数名とかに置き換わってない状態の命令が見れます。

それでは、実際にこの関数呼び出しをループで回す命令を書いていきます。以下のようになります。
まず、赤い部分が元の関数呼び出しです。それに、緑のコードを加えてループ処理を追加します。 ループはこの例だと計3回行い、そのカウンタとして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;
}