#HSPのチート対策いろいろ -メモリハック編-
本記事では、HSP3を使ったWindows向けゲームの簡単なチート対策について説明します。今回サンプルとして使用したゲームはHSP3付属の洞窟探検ゲーム(game/doukutu.hsp)です。
##概要
###実行環境
今回はHSPにしては低レイヤなところを触ります。時間が無かったこともありほとんどデバッグできていないのでバグだらけなのは確実なのでご了承ください。ということで、実行環境の重要な部分は明記しておきます。
OS : Windows 10(64bit)
HSP : v3.4 (32bit)
正直Windows 10でしか動かないんじゃないかとすら思ってるので、もし他の環境でサンプルコードが動作しましたら、是非コメントください m(_ _)m
###対象となるチート
ゲームのチート(不正行為)にもいろいろな種類があります。今回は、ゲーム実行中に不正な操作をするメモリハックに対するチート対策方法について考えます。実際のチート対策は逆解析対策やセーブデータ改変への対策などが必要になってきますので、この記事の内容だけで満足しないようにお願いします。
###目標
今回は、洞窟ゲーム用にHSP3でチート対策モジュールを作ります。このモジュールでは次ようなチートを検出、防止します。
- スコアの改ざん
- 実行速度の変更
ただし、次のようなチートには対応しません。
- メモリハック以外のチート
- マクロ
- Ring3(ユーザーモード)以外でのチート
- ソースコードがバレている前提でのチート
##ソースコード
いきなりですが、まずは最終的なソースコードを書きます。
#module
#uselib "ntdll.dll"
#func LdrLoadDll "LdrLoadDll"
#uselib "kernel32.dll"
#cfunc GetCurrentProcess "GetCurrentProcess"
#func WriteProcessMemory "WriteProcessMemory" sptr, sptr, sptr, sptr, sptr
#cfunc VirtualAllocEx "VirtualAllocEx" sptr, sptr, sptr, sptr, sptr
#uselib "user32.dll"
#func SendMessageA "SendMessageA"
#define MEM_COMMIT 0x1000
#define PAGE_EXECUTE_READWRITE 0x40
/*--------------------------------------------------
ハッシュにより整合性を確認する
--------------------------------------------------*/
#defcfunc ValidateHash int val, int hash
if CalculateHash(val) != hash {
return -1
}
return 0
/*--------------------------------------------------
ハッシュを計算する
--------------------------------------------------*/
#defcfunc CalculateHash int val, local h, local v
h = val & 0xF8000000
v = val << 5
v = v ^ (h >> 27)
v = v ^ rnd(0xFFFFFFFF)
return v
/*--------------------------------------------------
LdrLoadDllを無効化する
--------------------------------------------------*/
#deffunc DisableLdrLoadDll local asm, local pFilter, local msg
/* フィルタを実装する */
msg = rnd(0xFFFF)
dim asm, 14
asm(0) = 0x74ec8360 ; pushad; sub esp, 0x10
asm(1) = 0x0c245489 ; mov DWORD PTR [esp+0xC], edx
asm(2) = 0x08245489 ; mov DWORD PTR [esp+0x8], edx
asm(3) = 0x042444c7 ; mov DWORD PTR [esp+0x4], (msg)
asm(4) = msg
asm(5) = 0x2404c790 ; mov DWORD PTR [esp], (hWnd)
asm(6) = hWnd
asm(7) = 0xb8909090 ; mov eax, <SendMessageA>
asm(8) = varptr(SendMessageA)
asm(9) = 0xc483d0ff ; call eax
asm(10)= 0xb8906164 ; add esp, 0x64 ; popad
asm(11)= varptr(LdrLoadDll) + 5 ; mov eax, <original_LdrLoadDll + 5>
asm(12) = 0x90ec8b55 ; push ebp; mov ebp, esp
asm(13)= 0x9090e0ff ; jmp eax
pFilter = VirtualAllocEx(GetCurrentProcess(), 0, 14*4, MEM_COMMIT, PAGE_EXECUTE_READWRITE)
WriteProcessMemory GetCurrentProcess(), pFilter, varptr(asm), 14*4, varptr(asm)
/* トランポリン(?)を書き込む */
dim asm, 2
asm(0) = 0xFFFFFFe9 ; jmp <pFilter>
asm(1) = 0x909090FF
lpoke asm, 1, pFilter - varptr(LdrLoadDll) - 5
WriteProcessMemory GetCurrentProcess(), varptr(LdrLoadDll), varptr(asm), 5, varptr(asm)
return msg
#global
以下は、これを利用してチート対策を施したgame/doukutu.hspです。
;#packopt hide 1 ; なぜかhide 1すると実行ファイルでフリーズする
#packopt name "doukutu"
#epack "face.bmp"
; 洞窟探検ゲーム(改)
;
; HSP付属の洞窟探検ゲーム(game/doukutu.hsp)に
; チート対策を追加しました
;
#include "mod_protect.hsp"
/* [追加]LdrLoadDllをフィルターする */
DisableLdrLoadDll
oncmd gosub *filter, stat
screen 0, 640, 480
face=1
celload "face.bmp",face
celdiv face,64,64,32,32 ; パーツのサイズと中心位置
*start
cls 4
wscr=0
wxs=640
wys=480
gys=320
gy=(wys-gys)/2
spd=8
px=100
py=240
dy=0
bom=0
*main
; キャラクターにぶつかったか調べる
a=0
pget px+32,py-32
a=a+ginfo_g
pget px+32,py+32
a=a+ginfo_g
pget px+64,py
a=a+ginfo_g
if a>0 : goto *dead
redraw 0
; 洞窟を描画
color 0,192,0
boxf wxs-spd,0,wxs,wys
color 0,0,0
boxf wxs-spd,gy,wxs,gy+gys
pos 0,0
gmode 0,wxs-spd,wys
gcopy 0,spd,0
scr=scr+1
/* [追加]ハッシュ値を更新 */
scr_hash = CalculateHash(scr)
title "点数="+scr
gys=320-(scr/30)
if gys<100 :gys=100
if scr<60 : goto *mymove
dy=dy+1
if dy>16 : dy=16
stick ky,16
if ky=16 : dy=dy-3
py=py+(dy/2)
*mymove
gmode 2,64,64
pos px,py
celput face
if cn<1 :cn=rnd(16) :cn=cn+3 :ry=rnd(17) :ry=ry-8
cn=cn-1
gy+=ry
if gy+gys>448 : gy=448-gys
if gy<32 : gy=32
redraw 1
await 24
/* [追加]整合性の検証(awaitの後) */
if (ValidateHash(scr, scr_hash) == -1) {
goto *invalid
}
goto *main
*dead
; ぶつかった
font msgothic,60
pos 160,200
color 255,255,255
mes "いてーーー!"
*keymati
stick ky
if ky&32 : goto *start
wait 10
goto *keymati
/* [追加]LdrLoadDllをフィルターする */
*filter
if (banned == 1) : end // なんか終了できずにずっとfilterが呼び出されることがあるので
// ロードされたDLLのパスを取得する
sdim dllPath, 256
dupptr dllPath, lParam, 256, 2
dllPath = str(getpath(cnvwtos(dllPath), 16))
// 許可するDLLのリスト(環境依存だし本当はファイル名だけじゃなくパスも調べる必要アリ)
sdim white_list, 256, 5
white_list(0) = "oleaut32.dll"
white_list(1) = "c:\\windows\\system32\\ole32.dll"
white_list(2) = "imjppred.dll"
white_list(3) = "imesearchdll.dll"
white_list(4) = "hsp3debug.dll"
banned = 1
foreach white_list
// ホワイトリストに一致すれば許可
if (instr(dllPath, 0, white_list(cnt)) != -1) : banned = 0 : break
// よくわからないけどDLLになっていなければ許可
if (getpath(dllPath, 2) == "") : banned = 0 : break
loop
// ホワイトリストになければログを出力して終了
if (banned == 1) {
sdim log_message, 256
log_message = strf("許可されていないDLLがロードされました\nパス:%s", dllPath)
bsave "injected.log", log_message
end
}
return
/* [追加]メモリ改ざん検知 */
*invalid
dialog "メモリの改ざんを検知しました", 1, "エラー"
end
###スコア改竄の検知
スコアの改竄はCalculateHash関数とValidateHash関数で検知されます。まず、プログラムはスコア(scr)を算出した後にスコアのハッシュ(scr_hash)をCalculateHash関数で生成します。この関数はscrを元に、適当なハッシュ値を作成します。次に、ValidateHash関数で現在のスコアと以前生成したスコアのハッシュ値が等しいかを確認します。もしこれが等しくなければ、CalculateHash関数の呼出からValidateHash関数の呼出までの間にスコアが改竄されたことになります。
これを検出すると以下のようにメッセージを表示します。
ただ、どうも次に説明するDLLインジェクションの検知用ルーチンとdialogの相性が悪いらしく、実行ファイルにするとフリーズしてしまいます。まあ、チートしていない環境では問題ないから今回は無視しました。
DLLインジェクションの検知
DLLインジェクションとは、特定のプログラムに外部からDLLを読み込ませることで、そのプログラムの挙動を変更する技術です。チートツールもこの技術を多く採用しており、例えば実行速度の変更などに使われています。DisableLdrLoadDll関数はこれを防ぐ役割があります。
DLLインジェクションでは、外部からCreateRemoteThread関数を使用して、プログラム側にLoadLibraryを呼び出させることがほとんどです。したがって、プログラム側の防御策としてはLoadLibraryを潰せばいいのですが、今回は、更にLoadLibraryが呼び出すLdrLoadLibraryという関数を潰すことにしました。LdrLoadLibraryは以下のように開始されます。
mov edi, edi
push ebp
mov ebp, esp
...
これを以下のように変更すればLdrLoadLibrary関数は使えなくなります。
ret
...
しかしこれだけでは問題があります。LoadLibrary関数を使えなくしてしまうと、自分自身も困るのです。HSPは内部から何度もLoadLibrary関数を使うので、これが使えなくなるとHSPの機能がほとんど役に立たなくなってしまいます。そこで、今回はLoadLibrary関数を無効化する代わりにHSP側にLoadLibraryが呼び出されたことと、LoadLibraryで使われようとしているDLLのパスを通知するように処理を変更しました。そのDLLを許可するかはHSP側に委ねるということです。理想としては以下のような処理ができれば完璧です。
push ebp
mov ebp, esp
sub esp, 0x64
1.HSP側に通知する
2.元の処理を実行する
...
leave
ret
しかし、ここでまた問題があります。HSP側に通知する処理を入れてしまうと、元の処理部分が壊れてしまいます。また、元の処理部分を下にずれせばいいと思うかもしれませんが、機械語では相対アドレスが度々現れるので、単純に機械語列をずらしただけではやはり壊れてしまいますし、ずらすと次に存在する別の関数を上書きしてしまうかもしれません。本当はこの辺で詰んでて「もうだめだー」みたいになってたのですが、Advent Calenderで次の日を担当しているtheoldmoon0602さんが次のように、HSPへの通知部分を別の場所で実行する方法を考案してくれました。もうこれdetoursですね。
jmp <notifier> ; 「mov edi, edi; push ebp; mov ebp, esp」は上書きされます
sub esp, 0x64
...
leave
ret
notifier:
pushad ; レジスタの内容を保存します
sub esp, 0x74 ; LdrLoadLibraryで0x64使っていたので、そこに引数サイズだけ足しました
mov DWORD PTR [esp+0xC], edx ; edxにはDLLのパスへのポインタが入っています
mov DWORD PTR [esp+0x8], edx ; 上の一行とここはwparamとlparamです
mov DWORD PTR [esp+0x4], (msg) ; HSPに通知するためのメッセージID
mov DWORD PTR [esp], (hWnd) ; HSPのウィンドウハンドル
mov eax, <SendMessageA> ; LdrLoadDllでeaxは破壊されるので使用可能です
call eax
add esp, 0x64 ; subした分戻しますが、0x10はSendMessageにより使われました
popad ; レジスタの内容を元に戻します
mov eax, <LdrLoadDll + 5> ; LdrLoadDllのjmpの次の命令から開始します
; 下の二行でLdrLoadDllのjmpで潰された部分を実行します
push ebp
mov ebp, esp
jmp eax ; 何事も無かったかのようにLdrLoadDllを再開します
これをHSP側ではoncmdでキャッチできます。そこで受け取ったDLLのパスを(穴だらけの)ホワイトリストでフィルターします。本当はもっとちゃんとチェックする必要があるのでご注意ください。
検知すると次のようにすぐにプログラムが終了して、ログが保存されます。
##まとめ
- 短時間の割に(自分の環境で)ちゃんと動いてくれたので感動
- call, leave, ret, jmpあたりとスタックの関係が勉強できた
- Ring3でチート対策って凄い難しいことを再認識
- HSPはvarptrで#funcした関数のアドレスが取得できる謎仕様
将来的にはもっと幅広いチートに対応したモジュールを作れたらと考えています。とりあえず今回はこんなところで。