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
を消すだけでは入りきらない。それなので前後の命令を消したい。上図を元に、以下の場合を考える。
- [×] 上二つの
mov
命令を消す場合 - 一番上の
MOV this,dword ptr [GameWorld]
は相対参照なので、CodeCaveの方に移動するとズレてめんどくさいので却下 - [△] 下二つの
push
とmov
命令を消す場合 - 相対参照は無いので問題は無いが、最後の
MOV
でEAX
に入った値を保持したままここに戻ってこないといけないので、 ここに戻ってくるときのmov register, ...; jmp register
で別のレジスタを使わないといけないのがめんどくさい - [〇] 前後二つの
mov
とpush
を消す場合 - これは
EAX
も自由に使えるし、相対参照も無いので使いやすい!
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に入りきるか確認した後、入りきる場合はmemcpy
でpayload
を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;
}