0x00 开头废话
最近突然想玩一款农场类的游戏,就随便找了一个休闲种地的游戏玩了玩。
在玩的时候发现游戏还挺不错,挺养生的。但是这游戏有一个缺点,就是它的拖拉机太虚了,油箱小得跟个可乐罐似的,动不动就要跑去加油。
Nobody knows 拖拉机 better than me. 拖拉机的油箱不可能这么小。
虽然没油了可以去加油站加油,但是这就严重影响了游戏体验。种地种一半,没油了,就得满地图跑加油站加油。那这还是养生游戏吗?
所以我打算给游戏打个Hotpatch,让油量不会减少,这样就能开心地种地了。而且只锁定一个油量,不改其它东西,也不会影响游戏体验。
0x01 寻找存储油量的地址和减少油量的代码
对这种没啥保护的游戏,首先考虑用CE。
由于无法知道当前油量的精确数值,所以先模糊搜索,得到所有可能为Data的地址。
得到一大堆地址。没事,游戏里走两步墨迹一会(注意不要让油量发生变化),然后再筛出那些未变化的数值。
可以看到还是又很多地址。然后,随便干些事情,让油量减少一些。
然后筛出那些减小的数据。
然后再闲逛一下,筛出不变的数据。如此往复,就能筛掉大部分不符合条件的地址。
最后几十个可能会比较难筛。这时候可以跑去加加油,然后筛出增加的数据,再筛几次减少的,就能找到存储油量的地址了。
最后能够筛出个位数的地址。这时候就比较好找了。三个数据,很明显前两个不可能是用来存储油量的,那就是第三个地址0x2EBD41FD8D4存储的是油量。
看一下这块内存:
能看到游戏作者用了32位的int来存储油量。
接着,把CE的Debugger挂上游戏进程,用内存断点来看看谁在写这块内存。
挂上Debugger之后,随便做点降油量的事情,然后看结果:
能看到是0x2EB8526E42D这个地址的代码写了这块地址。看看这个函数的反汇编:
能看出来,这个函数有两个参数。一个参数是存有油量的结构体,油量是它一个4 bytes的成员,偏移为0xDC。另一个参数是要减少的油量。
先把剩余油量取出来:
2EB8526E410 - 8B 87 DC000000 - mov eax,[rdi+000000DC]
然后跟需要减少的油量比较:
2EB8526E416 - 3B C6 - cmp eax,esi
如果油量够的话,就用剩余油量减去需要扣除的油量,然后重新存回结构体中:
2EB8526E420 - 8B 8F DC000000 - mov ecx,[rdi+000000DC]
2EB8526E426 - 2B CE - sub ecx,esi
2EB8526E428 - 3B C1 - cmp eax,ecx
2EB8526E42A - 0F42 C1 - cmovb eax,ecx
2EB8526E42D - 89 87 DC000000 - mov [rdi+000000DC],eax
那么,这就简单了。我们只需要把油量存回的那条指令给NOP掉,就可以快乐地玩耍了:
2EB8526E42D - 89 87 DC000000 - mov [rdi+000000DC],eax
先看一下这块内存是在哪个section中。我们先拿到这一块的基地址0x2EB8526E000:
顺便得到了这块内存的保护状态。目前是PAGE_EXECUTE_READWRITE,看起来应该是动态分配的地址,而且为了动态写入代码,把所有的权限都加上了。这样的话,改起来就简单了。
把Debugger挂上去,看一下这块内存映射到了哪里:
能看到,这块内存其实是从0x000002EB85220000开始分配的,大小为0x100000。这一整个大块内存的权限都为ERW。
我对Unity不是很熟悉,不太清楚这块内存是怎么分配的,但是大致能猜出来是先分配了一大块内存,然后把需要的代码分成模块载入。
0x02 实现Hotpatch
这块地址不太好通过基地址来找,所以考虑使用特征码搜索的方法来实现Hotpatch。
首先把特征码确定好。使用修改油量的指令加上后两条指令的机器码作为特征码:
89 87 DC000000 - mov [rdi+000000DC],eax
48 8B 87 A8000000 - mov rax,[rdi+000000A8]
48 85 C0 - test rax,rax
确定特征码为:
89 87 DC 00 00 00 48 8B 87 A8 00 00 00 48 85 C0
首先遍历一下进程,拿到游戏进程的句柄:
DWORD pid = FindProcess(PROCESS_NAME);
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
FindProcess用来查找指令进程名的PID。遍历搜索进程PID的方法一搜一大把,就不赘述了。
接着在内存中搜索特征码:
const BYTE signature[] = { 0x89 ,0x87 ,0xDC ,0x00 ,0x00 ,0x00,0x48 ,0x8B ,0x87 ,0xA8 ,0x00 ,0x00 ,0x00, 0x48 ,0x85 ,0xC0 };
LPVOID targetAddr = SearchMem(hProc, signature, sizeof(signature));
SearchMem实现如下:
LPVOID SearchMem(HANDLE hProc, const BYTE signature[], DWORD sigSize)
{
MEMORY_BASIC_INFORMATION info;
BYTE* chunk = NULL;
BYTE* current = 0;
while (VirtualQueryEx(hProc, current, &info, sizeof(info)) == sizeof(info))
{
if ((info.State == MEM_COMMIT) && (info.Protect & PAGE_EXECUTE_READWRITE) && !(info.Protect & PAGE_GUARD))
{
current = (unsigned char*)info.BaseAddress;
chunk = new BYTE[info.RegionSize];
SIZE_T bytesRead;
if (ReadProcessMemory(hProc, current, &chunk[0], info.RegionSize, &bytesRead))
{
for (size_t i = 0; i < (bytesRead - sigSize); ++i)
{
if (memcmp(signature, chunk + i, sigSize) == 0)
{
return current + i;
}
}
}
delete[] chunk;
}
current += info.RegionSize;
}
return NULL;
}
使用VirtualQueryEx来遍历所有的section。使用current来记录当前地址。每遍历完一个section就将current加上这个section的大小来遍历下个section。
如果当前section状态为MEM_COMMIT,权限标志为PAGE_EXECUTE_READWRITE(之前分析得到了这块内存是ERW权限,所以直接指定就行),并且PAGE_GUARD标志位没有置1,则把这块内存读出来搜索特征码。
使用一个叫chunk的buffer来存放这块内存。使用ReadProcessMemory将整个section读出来,然后对每一个byte进行遍历,使用memcpy来判断接下来的一块内存是否与特征码相等。如果找到特征码,则反汇其地址,否则返回NULL。
找到特征码之后,把 ``` mov [rdi+000000DC],eax ``` 这条指令覆盖成全NOP。这条指令一共6个bytes,全部覆盖成0x90即可。
const BYTE payload[] = { 0x90,0x90,0x90,0x90,0x90,0x90 };
WriteProcessMemory(hProc, targetAddr, payload, sizeof(payload), &dwBytesWritten);
由于这块内存本来就有WRITE权限,所以不需要使用VirtualProtect来改内存权限了。否则需要使用VirtualProtect增加写权限,写完之后再把权限改回去。
这样就实现好了。进游戏试试。进游戏之后先随便做些操作,消耗一些油量,目的是确保减少油量的相关代码已经载入内存中。为了好看,我每次Patch之前还会把油加满。
然后运行Patcher:
游戏里试试:
能看到油量不会减少了。这下终于能愉快地玩耍了。之后每次启动游戏运行一下patcher就能使油量不减少。