11: Inline Hookでゲームの命令を高度に書き換える

概要

前々回で、 ダメージを受ける命令をnop化するDLLをインジェクトしましたが、この命令は実は敵にダメージを与える時にも使われていました。 それなので、プレイヤーと敵を区別するために必要な情報を、前回 Ghidraで該当命令の周辺を解析することにより探し出し、ediレジスタにあるActor構造体のオフセット0x14にあるm_blueprintName"Player"かどうか で区別出来る事がわかりました。
今回は、ダメージを受ける命令に、プレイヤーか敵かの条件分岐を入れる事により、自分は無敵で敵にはダメージが通るように、Inline Hookという技を使って実装していきたいと思います。
この記事は前々回からの続きなので、見ていない方はそちらから見るようお願いします。
また、Windows対象で、C++を言語として用いる。ソースコードはまとめてページの一番下にある。

Inline Hook とは

インラインフックとは何かについては、この画像を見た方が早いです。
上記の記事に インラインフックとは何か や、もっとシンプルなプログラムでのInlineHookのやり方 も書いたので、こちらを事前にやってみるのがベストではあるが、 ここでもInlineHookとは何かについて説明する。

まず、そもそもの目的はダメージを受ける命令である 29 47 30 - sub [edi+30],eax に条件分岐を付け足すことだが、 もしこの前後に条件分岐のコードをそのまま書くとしたら、元々前後にあった命令をずらすか上書きしないといけない。
上書きがダメなのはもちろんの事、命令をずらすというのもかなり厄介で、例えば相対参照(この命令から~バイト前のアドレスから値を読む)を使う命令は結構あるが、 これの場所がずれると読み取る値もズレた値になってしまう可能性があるので、そういうのを全部考慮するのはほぼ不可能である。
よって、機能を加えるなど、命令を追加するような場合は、その命令の周辺に直接コードを書き込む事は不可能だということがわかる。

こんな時に役立つのが、インラインフックである。
インラインフックは、該当部分に命令を直接書き込むのではなく、代わりに "使われていないメモリ領域" に処理内容を書いておき、 該当部分に処理が回ってきたときに、この領域に飛ばし、処理が終わったら元の命令の続きに戻す という事をする。
これを図示したのが、先程リンクで提示した以下の画像である。
この画像だと、call putsの部分が今回のsub [edi+30],eaxにあたり、"使われていないメモリ領域" というのが画像のCodeCaveという部分である。 CodeCaveに処理内容を書いておき、実際にcall puts(ダメージを受ける処理)の所には、CodeCaveにジャンプするための命令しか入れていない。

また、注目すべきなのは、CodeCaveの処理内容に、call putsを改めて書いているという点である。これはつまり、29 47 30 - sub [edi+30],eaxを消しても、 さらにはそれより前後の命令を消したとしても、CodeCaveの方にその処理を書いておけば大丈夫という点である。これを今回はやっていく。

Code Cave とは

上記で出てきた CodeCave というのは自分が勝手に名前を付けたわけではない。
例えばGameLogic.dllをGhidraで見るとわかると思うが、実行ファイル内にはちらほら "0で埋められた領域" というのがあり、これがCodeCaveである
もっと言えば、実行ファイルは コードを置く領域 や データを置く領域 みたいにセクションに分かれていて、そのセクションの末尾にCodeCaveがある。
コードが置かれる領域の事を .textセクション と言い、ダメージを受ける処理もこの .textセクション にあるわけだが、 ここのセクションの一番下をGhidraで見てみると確かにCodeCaveがある事がわかる。 なんでこんな領域があるのかと言うと、これは実はまさにInline Hookをするためであり、ゲームのチート目的ではなく、例えばリリースしてしまった製品の実行ファイルにバグとかが見つかった際、 パッチを当てて修正するが、そのパッチを当てるというのがまさにInline Hookとかでコードを書き換えるという事をやっているのである。 今回はその技術をゲームのチートに使おうという事である。

Code Cave に飛ばす命令の作成

早速実装していくが、まずはCodeCaveに飛ばすためのjmp命令から紹介する。
また、一つ注意点としては、実際にゲームに埋め込む命令は機械語(アセンブラ)で書かないといけない
アセンブラ=>機械語の変換は、このサイトとか使えば簡単にできるので、 実質機械語ではなくアセンブラで書かないといけないという事になる。とはいえ、アセンブラで高度な処理は自分も書けないし、書く必要も今回は無い。

Code Cave に飛ばす命令はざっくりこんな感じである。
b8 xx xx xx xx - mov eax, CodeCaveの先頭アドレス
ff e0          - jmp eax
なぜ直接jmp CodeCaveの先頭アドレスにしないのかというと、絶対アドレスを指定してジャンプできる命令が無いからで、 何故、eaxレジスタを使うのかというのは、どうせその後の下図の部分の命令で新しい値に置き換わるから、いくらいじっても問題ないからである。

Code Cave への命令の移動

作成したCodeCaveへ飛ばす命令のサイズは上記の通り7バイトであり、3バイトであるsub [edi+30],eaxを消すだけでは入りきらない。
それなので前後の命令を消したい。上図を元に、以下の場合を考える。 よって、以下を消してCodeCaveに移し、消した所に先程のjmp CodeCaveの先頭アドレスを入れれば良い事がわかる。
8B 45 10     mov eax,[ebp+10]
29 47 30     sub [edi+30],eax
FF 77 30     push [edi + 30]

Code Cave に置く命令

CodeCaveに書く内容がメインの処理である。
書く前に注意したいのが、当たり前だが、CodeCaveのサイズは無限じゃないという点である。 つまり、CodeCaveに書ける命令は限られているので、CodeCaveのサイズを意識して命令を書かないといけない。
Ghidraで何個0x00があるかを数えるとわかるが、今回GameLogic.dllのtextセクションのCodeCaveのサイズは44バイトである。

ここで残念な事に、このサイズだと実際のm_blueprintNameの値と、"Player"という文字列を比較する事はできない。
ただ、最初の文字が'P'かどうかなら確認でき、今回のゲームだとそれで案外問題は無い。

では、実際にCodeCaveに置く命令を以下に示す。 意味は見ての通りで、幸いeaxレジスタ意外のレジスタは書き換えていない。
ちなみに、先ほどからCodeCaveのアドレス<return address>など書いているが、 これは後でDLLの方で入れるので、まだ気にしなくても良い。

Inline Hook をするDLLの作成

では実際にコードの方を書いていく。
ちなみにInline Hookを行う処理はDLLに書いて、それをゲーム上に実行させるためには毎度おなじみのDLL Injectionを行う
前回と同じく、VisualStudioでDLL/C++/Windowsのプロジェクトを作成し、x86/Win32/マルチバイトに設定した後、コードを書いていきます。

まずは、以下を書きます。 前回と同じくDllMainはスレッドを作成するだけですぐにリターンするようにします。
ThreadMainで重要なのは、targetが、消してCodeCaveに移動する命令の開始アドレスを表していて、移動する命令のサイズが sizeに入っています。続いて、codecaveAddr = baseAddr + 0x6e9d4はCodeCaveの先頭アドレスで、 0x6e9d4はGameLogic.dllの先頭アドレスからCodeCaveのアドレスまでのオフセットを表しています(Ghidraを見ればわかります)。
そして、initCodeCaveでは、CodeCaveを一度nopで初期化しています。
このように、アドレスの計算や初期化が終わったら、次はCodeCaveへ飛ぶ命令を作成しましょう。 左の線が黄色い所が新しく追加した部分です。
汚いですが、initJmpCodeCaveが命令を作っている所で、まず消した所をnopで埋めた後、 mov eax, CodeCaveの先頭アドレス; jmp eaxを入れているのですが、CodeCaveの先頭アドレスの部分を、 memcpy(&jmpToCodeCave[1], &codecaveAddr, sizeof(DWORD))で入れています。

続いて、実際にCodeCaveに置くメインの処理を追加しましょう。 payloadの部分は上記で説明した通りですが、元の命令の続きに戻る<return address>memcpy で後から追加しています。これは単純にべた書きするのではなく、変数retAddr = target + sizeから入れた方がキレイだし 後から変更しやすいからです。
if文の所でCodeCaveに入りきるか確認した後、入りきる場合はmemcpypayloadをCodeCaveに入れています。

最後に、CodeCaveのアドレスにジャンプする処理を、実際のゲーム上のダメージを受ける処理の部分に 以下のようにmemcpyすれば終了です!

試してみる

では実際にビルドして、CheatEngineからDLLインジェクションしちゃいましょう。
上手くいけば、このように自分はダメージを受けず、相手にはダメージを与えられる風になってるはずです!

おまけ:攻撃力増加

(敵が)ダメージを受ける処理はsub [edi+30],eaxだったわけですが、これの手前に、add eax, 10000を追加すれば、 実は簡単に攻撃力アップもできてしまいます! 以上の二点だけ追加してビルドして、再びインジェクトしてしましょう。 このように二回ダメージを与えないと倒せなかったGiantRatが、一撃で倒せるようになってるはずです。

上手く行かなかった場合

上手く行かなった場合は、CheatEngineのデバッガでブレークポイントを打ち、ステップ実行しながら見ていくと良いですが、 例えばDLL上の変数の値(特にアドレスとかがちゃんと取得できてるかなど)を確認したかったり、 そもそもDLLの処理がちゃんと実行されているのかというのを確かめたかったりすると思います。 普通のプログラムと違って、Printデバッグとかができないのがめんどくさいですし、MessageBoxは使えますがこれは変数展開とかが できないので結構めんどくさいです。

こういう場合は、DebugViewというツールが使えるのですが、これに関しては次の記事にまとめます。

ソースコード

プレイヤーは無敵で敵はダメージ増量する上記のDLL

#include "pch.h"
#include <stdlib.h>

// CodeCaveを全部 nop (0x90) で埋める
void initCodeCave(DWORD codecaveAddr, DWORD codecaveSize, DWORD size) {
    DWORD curProtection;
    VirtualProtect((DWORD*)codecaveAddr, codecaveSize, PAGE_EXECUTE_READWRITE, &curProtection);

    memset((DWORD*)codecaveAddr, 0x90, codecaveSize);

    DWORD temp;
    VirtualProtect((DWORD*)codecaveAddr, codecaveSize, curProtection, &temp);
}

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

    memset(jmpToCodeCave, 0xB8, 1);
    memcpy(&jmpToCodeCave[1], &codecaveAddr, sizeof(DWORD));

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

    return jmpToCodeCave;
}


// CodeCaveにメインの処理を入れる
//  - プレイヤーだったらダメージ受ける処理無しでリターン
//  - 敵だったらダメージ受ける処理有りでリターン
void injectPayload(DWORD target, DWORD size, DWORD codecaveAddr, DWORD codecaveSize) {
    BYTE payload[] = {
        0x89, 0xF8,                      // mov eax, edi
        0x83, 0xC0, 0x14,                // add eax, 0x14
        0x8B, 0x00,                      // mov eax, [eax]
        0x3C, 0x50,                      // cmp al, 'P'
        0x75, 0x0A,                      // jne $+a
        0xFF, 0x77, 0x30,                // push [edi+30]
        0xB8, 0xff, 0xff, 0xff, 0xff,    // mov eax, <return address>
        0xFF, 0xE0,                      // jmp eax
        0x8B, 0x45, 0x10,                // mov eax, [ebp+10]
        0x05, 0x10, 0x27, 0x00, 0x00,    // add eax, 10000     (damageに10000上乗せしてる)
        0x29, 0x47, 0x30,                // sub[edi+30],eax
        0xFF, 0x77, 0x30,                // push [edi+30]
        0xB8, 0xff, 0xff, 0xff, 0xff,    // mov eax, <return address>
        0xFF, 0xE0                       // jmp eax
    };

    DWORD retAddrOffset1 = 15;
    DWORD retAddrOffset2 = 36; // オフセットずれるのでココ修正忘れずに
    DWORD retAddr = target + size;

    memcpy(&payload[retAddrOffset1], &retAddr, sizeof(DWORD));
    memcpy(&payload[retAddrOffset2], &retAddr, sizeof(DWORD));

    if (sizeof(payload) < codecaveSize) {
        DWORD curProtection;
        VirtualProtect((DWORD*)codecaveAddr, sizeof(payload), PAGE_EXECUTE_READWRITE, &curProtection);

        memcpy((DWORD*)codecaveAddr, payload, sizeof(payload));

        DWORD temp;
        VirtualProtect((DWORD*)codecaveAddr, sizeof(payload), curProtection, &temp);
    }
    else MessageBox(NULL, TEXT("payload too big!"), TEXT("Error"), MB_OK | MB_ICONEXCLAMATION);
}


DWORD WINAPI ThreadMain(LPVOID params) {
    DWORD baseAddr = (DWORD)GetModuleHandle("GameLogic.dll");
    DWORD size = 9;
    DWORD* target = (DWORD*)(baseAddr + 0x20c5 - 0x3);
    // 8B 45 10   mov eax,[ebp+10]
    // 29 47 30   sub [edi+30],eax  <= baseAddr + 0x20C5
    // FF 77 30   push [edi + 30]

    DWORD codecaveAddr = baseAddr + 0x6e9d4;
    DWORD codecaveSize = 44;

    initCodeCave(codecaveAddr, codecaveSize, size);
    BYTE* jmpToCodeCave = initJmpToCodeCave(codecaveAddr, size);

    injectPayload((DWORD)target, size, codecaveAddr, codecaveSize);

    DWORD curProtection;
    VirtualProtect(target, size, PAGE_EXECUTE_READWRITE, &curProtection);
    memcpy(target, jmpToCodeCave, 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;
}