6.3 植物大战僵尸:寻找葵花生产速度

《植物大战僵尸》是一款非常经典的塔防类游戏,由PopCap Games公司开发并先后在多个平台上推出。主要玩法为种植各种攻击性植物,抵御僵尸攻击,该游戏可以说绝大多数九零后都接触或者玩过,本章将通过逆向分析技术对该游戏进行分析,并实现一些游戏之外的功能,以此让用户理解二进制安全技术的应用范围。

通过CE修改器遍历出控制太阳花吐出阳光的时间变量,太阳花吐出阳光是由一个定时器控制的,首先我们找到第一个太阳花的基址与偏移,然后找出第二个太阳花的动态地址,并通过公式计算得到太阳花结构长度的相对偏移,最后我们通过C语言编程实现,遍历并修改所有图中的太阳花吐出阳光的时间,最终实现全图吐阳光。

本次实验将接触到关于分析定时器的相关技巧,一般的定时器分为递增定时器与递减定时器,不过大多数游戏都会使用递减定时器,因为递减定时器更好编程判断,本游戏中的太阳花生产速度使用的就是递减定时器,太阳花生产阳光一定是一个周期性的事件,我们只要找到该定时器并改写它的时间即可实现无限吐阳光,如下是太阳花定时器的遍历技巧:

  • 首先种下第一个太阳花 > 然后CE马上搜索 > 未知的初始值
  • 回到游戏短暂等待(时钟发生变化) > 然后马上切回CE > 搜索减少的数值 > 掉一点搜一点
  • 如果中途太阳花吐出了阳光 > 则需要搜索增加的数值(1次) > 然后再搜索减少的数值
  • 最终找到一个动态地址(范围:0-5000) > 锁定该变量范围在1至10即可 > 实现无限出阳光

修改太阳花时钟有两种方式,第一种找到基址与偏移然后分别修改每一个定时器的时钟,第二种方式则是找到汇编跳转并进行改写,第一种方式要找植物相对偏移,首先我们先来猜测以下游戏作者会用什么方式存储不同植物的栏位。

如下图所示,我们猜测,游戏作者会使用二维结构体来存储植物位置,通过结构体链表将不同植物进行连接,当我们铲除植物的时候,只需要在链表中摘除相应节点,而太阳花的的地址一定是连续存储在内存中的线性空间,此游戏的矩阵可能就是5*9这么一个范围,假设在横坐标X轴如果两个植物之间的相对偏移是14C(14C就是太阳花结构体的实际长度),那么我们找到第一个植物的基址与偏移,每次相加14C的偏移量,则可遍历到下一个植物的内存地址,同理如果相减14C则就可遍历出上一个植物的内存地址,而纵坐标Y可能就是由一个1C偏移来控制的,此时我们也仅仅只是猜测。

如果我们按照上图中的方式进行推理,其计算每一个阳光时钟公式就可总结为如下,但真的是这样吗?

  • X坐标下的第1个植物:基地址 + 偏移1 + 偏移2 + 768
  • X坐标下的第2个植物:基地址 + 偏移1 + 偏移2 + 768 + 14C
  • X坐标下的第3个植物:基地址 + 偏移1 + 偏移2 + 768 + 14C + 14C
  • Y坐标下的第1个植物:基地址 + 偏移1 + 偏移2 + 768 + 1C
  • Y坐标下的第2个植物:基地址 + 偏移1 + 偏移2 + 768 + 1C + 14C

其实并不是!经过我对具体坐标的分析,在本游戏中太阳花与太阳花之间,可能使用了一维结构体来存储的植物与植物之间的属性,每次相加偏移都会遍历到下一个植物的属性上面,也就是说无论太阳花种植到在什么位置,只要相加偏移就可以遍历到下一个植物的冷却数据,而需要遍历的次数则取决于太阳花的种植数量。

首先我们种植一颗太阳花,并通过上方的遍历技巧找到当前第一个植物的动态地址,排查到最后可发现剩余14条结果,此时我们可猜测这个定时器应该在0-2000之间,应该不会大于这个参数,如下图我找到了13457080这个地址,将该地址锁定为10就可以实现第一个太阳无限吐阳光。

接着我们在第一个太阳花的旁边种植第二个太阳花,然后还是使用前面的遍历技巧找到第二个太阳花的动态地址134571CC,找到以后我们可以猜测第一个与第二个在内存中的布局应该是连续的,所以我们可以使用134571CC - 13457080 = 14C此处得到的14C其实就是太阳花结构的实际长度,也可以说是两个太阳花之间的偏移值。

既然知道了太阳花之间的相对偏移,那么我们下一步就是来找一个固定的地址,我们在第一个太阳花地址上,右键选择查找改写地址,然后可看到0045FA48 \- 83 47 58 FF \- add dword ptr [edi+58],-01 << 这条汇编指令,此汇编指令乍一看是一条加法指令,但其相加的操作数是-1也就是相减,此处就是太阳花的定时器,其每次减1直到为0则吐出阳光,这里我们就可知该定时器是一个递减定时器,我们只需要记下偏移为58下一个地址是13457028即可。

回到CE我们继续搜索十六进制地址13457028然后找到偏移为AC下一个地址为13D08948依次类推,最后读者应该能找到如下所示的内存地址;

  • add dword ptr [edi + 0x58],-01 找到第一个0x58偏移
  • mov eax,[edx + 0xac] 找到第二个0xac偏移
  • mov esi,[edi + 0x768]找到第三个0x768偏移
  • mov eax,[006A9F38] 找到最后的006A9F38基地址

最后我们使用CE添加这个基地址与偏移数据来验证一下,公式为 [[[006A9F38+768]+AC]+58]] 此时我们就可以定位到第一个太阳花的动态地址了。

根据上面的理论,我们知道太阳花的结构体大小为14C,那么我们在第一个太阳花动态地址的基础上加上14C就可以得到第二个太阳花的动态地址。

既然找到了基址与偏移,接下来就是通过C语言编程实现修改全图太阳花的冷却时间,此处贴出我实现的代码。

#include <iostream>
#include <windows.h>
#include <string>
#include <tlhelp32.h>

// 根据进程名得到进程PID
DWORD GetPidByName(const char* name)
{
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe32 = { sizeof(PROCESSENTRY32) };
DWORD pid = 0;

if (Process32First(snapshot, &pe32))
{
do
{
if (_stricmp(pe32.szExeFile, name) == 0)
{
pid = pe32.th32ProcessID;
break;
}
} while (Process32Next(snapshot, &pe32));
}
CloseHandle(snapshot);
return pid;
}

// 计算动态内存地址
int CalculateDynamicMemoryAddresses(int Pid, int Base, int Offset[], int len)
{
int temp;
HANDLE Process;
Process = OpenProcess(PROCESS_ALL_ACCESS, false, Pid);
ReadProcessMemory(Process, (LPVOID)Base, &temp, 4, NULL);
for (int i = 0; i < len; i++)
{
if (i == len - 1)
{
temp += Offset[i];
}
else
{
ReadProcessMemory(Process, (LPVOID)(temp + Offset[i]), &temp, 4, NULL);
}
}
return temp;
}

int main(int argc, char *argv[])
{
int base;
int offset[4];
int PID;

base = 0x006a9f38;
offset[0] = 0x768;
offset[1] = 0xac;
offset[2] = 0x58;

// 得到进程PID
PID = GetPidByName("PlantsVsZombies.exe");

// 得到动态地址
int addr = CalculateDynamicMemoryAddresses(PID, base, offset, 3);
printf("[*] 阳光吐出动态地址 = 0x%x \n", addr);

// 打开进程
HANDLE Process = OpenProcess(PROCESS_ALL_ACCESS, false, PID);

int SunOffset = 0;
int SunNum = 10;
while (TRUE)
{
// 循环前五个格子
for (int i = 0; i < 5; i++)
{
WriteProcessMemory(Process, (LPVOID)(addr + SunOffset), &SunNum, 4, NULL);

// 每个格子增加0x14C的偏移
SunOffset = SunOffset + 0x14c;

printf("[*] 第 %d 个太阳花吐出阳光 \n", i);
}

SunOffset = 0;
Sleep(500);
}

system("pause");
return 0;
}

当上方代码被运行,此时我们的太阳花会每隔500毫秒自动吐出一个阳光,输出效果如下图所示;

上述方法,虽然可以修改达到无限吐阳光的作用,但是这种修改方式,显然是不太合理,如果图中有100个太阳花,那么我们则只能循环100次,这种效率还是太低,其实我们可以通过直接修改硬编码的方式来实现一劳永逸的效果,之所以是一劳永逸,是因为所有太阳花的吐阳光判断都是共用一个判断函数执行的,阳光的递减时钟都会走一个地方add dword ptr [edi+58],-01 我们只需要定位到这里,然后分析出阳光产生的关键键跳转并改掉其硬编码即可。

根据上述分析可知内存地址0045FA48位置处就是计时器递减的子过程,如下分析流程,经过测试备注一些细节,我们只需要将图中的0045FA7D处的指令集,替换为nop即可实现全图的植物无线吐阳光;

通过使用写出内存字节集的功能,只需要将jg plantsvszombies.45FB64处替换为Nop即可,但需要注意的是一个Nop占用一个字节,我们需要替换6个字节Nop才可以;

根据上方的实现原理,读者应该可以写出如下代码案例,完整源代码如下所示;

#include <iostream>
#include <windows.h>
#include <string>
#include <tlhelp32.h>

// 根据进程名得到进程PID
DWORD GetPidByName(const char* name)
{
HANDLE snapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe32 = { sizeof(PROCESSENTRY32) };
DWORD pid = 0;

if (Process32First(snapshot, &pe32))
{
do
{
if (_stricmp(pe32.szExeFile, name) == 0)
{
pid = pe32.th32ProcessID;
break;
}
} while (Process32Next(snapshot, &pe32));
}
CloseHandle(snapshot);
return pid;
}

// 读取内存字节集
byte *ReadByteSet(DWORD Pid, DWORD Base, DWORD Size)
{
HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, 0, Pid);
if (handle == NULL)
{
return 0;
}

byte *buf = new byte[Size];

BOOL ref = ReadProcessMemory(handle, (LPVOID)Base, buf, Size, NULL);
if (ref == TRUE)
{
printf("[*] 读取数据成功 \n");
}
else
{
printf("[*] 读取数据失败 \n");
}
return buf;
}

// 写内存字节集
BOOL WriteByteSet(DWORD Pid, DWORD Base, unsigned char *ShellCode, DWORD Size)
{
BYTE *Buff = new BYTE[Size];
memset(Buff, *ShellCode, Size);

HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, 0, Pid);

return WriteProcessMemory(handle, (LPVOID)Base, Buff, Size, NULL);
}

int main(int argc, char *argv[])
{
// 得到进程PID
DWORD pid = GetPidByName("PlantsVsZombies.exe");
printf("[*] 进程PID = %d \n", pid);

// 写出替换自动收集功能
unsigned char AutoSun[] = { 0xEB };

BOOL ref = WriteByteSet(pid, 0x0043158F, AutoSun, 1);
if (ref == TRUE)
{
printf("[*] 替换自动收集已开启 \n");

for (size_t i = 0; i < sizeof(AutoSun); i++)
{
printf("%x \n", AutoSun[i]);
}
}
else
{
printf("[*] 替换数据失败 \n");
}

// 写出替换无限吐出阳光
unsigned char Suns[] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
ref = WriteByteSet(pid, 0x0045FA7D, Suns, 6);
if (ref == TRUE)
{
printf("[*] 替换无限吐出阳光已开启 \n");

for (size_t i = 0; i < sizeof(Suns); i++)
{
printf("%x \n", Suns[i]);
}
}
else
{
printf("[*] 替换数据失败 \n");
}
system("pause");
return 0;
}

当游戏运行后,我们首先种植好向日葵,并开启自动收集阳光以及无限阳光功能,则读者可看到如下输出效果;

其实这种修改方式并不完美,因为我们的阳光数量可能是一个整数类型,如果不加以控制,当整数变量到达所能承载的最大范围时,则程序会发生整数溢出,轻则阳光变为负数,重则直接崩溃卡死。