6.8 植物大战僵尸:分析植物无冷却

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

本章我们将继续分析植物无冷却这个功能的实现,默认情况下植物栏中的植物都存在一个默认的冷却周期,当读者种下植物时则默认会进入冷却状态,根据种植植物的不同冷却事件也会有所不同,这里就涉及到定时器的相关知识了,在本游戏内定时器是一个通用的,游戏作者并没有分别给每一个植物分配一个定时器,而是使用了一个通用定时器来管理所有植物的冷却,如果去查基址无论查找哪个植物最终都会定位到时钟的计时代码上,一般来说此类定时器是通用的,判断则是通过游戏中的语句进行区分的。

如下代码是本人根据理解写出的一段模拟程序;

#include <stdio.h>

struct MyStruct
{
int BotanyID; // 植物ID号
int BotanyTime; // 植物当前时间
int BotanyRecTime; // 冷却周期
int BotanyFlag; // 当前植物状态
};

int main(int argc, char *argv[])
{
struct MyStruct SunFlower = { 0, 0, 700, 0 }; // 太阳花动态地址
struct MyStruct Botany = { 1, 0, 1000, 0 }; // 豌豆射手动态地址
while (true)
{
SunFlower.BotanyTime++;
Botany.BotanyTime++;

if (SunFlower.BotanyTime == SunFlower.BotanyRecTime)
{
SunFlower.BotanyFlag = 1;
SunFlower.BotanyTime = 0;
printf("太阳花冷却完成了....\n");
}
else if (Botany.BotanyTime == Botany.BotanyRecTime)
{
Botany.BotanyFlag = 1;
Botany.BotanyTime = 0;
printf("豌豆射手冷却完成了....\n");
}
Sleep(10); // 模拟时钟定时
}
return 0;
}

上方代码中,结构体MyStruct部分存储的就是单个植物的属性,其中植物的属性可能包括植物ID,植物当前冷却计时,植物冷却周期,以及植物的当前状态,而随着选择不同植物卡片,游戏会根据选择植物的多少以及植物属性来动态分配内存空间。

经过对游戏的分析,冷却时间是一个递增的定时器(此处可通过CE查找验证),作者为什么会用递增计时器呢?因为递增到一定程度变成0,0则表示冷却完成,那么也就不需要单独使用一个标志位来存储植物当前状态了。除此之外,游戏中控制植物冷却的时钟只有一个,那么通过递增计时器,对照不同植物的冷却周期,就可用一个定时器控制所有植物冷却,而如果用递减定时器,虽然也可以,但是却不方便编程实现。

无冷却的找法有多种,我们想用第一种方式来寻找;

  • 打开CE > 回到游戏种植一颗向日葵 > 扫描未知初始数值
  • 然后切回游戏 > 马上切回CE > 搜索变动的数值 > 一直重复 > 直到冷却结束
  • 此时不进行任何建造 > 回到CE > 搜索未变动的数值 > 依次排查

这里我经过分析知道了这个定时器是一个递增定时器,那么我就使用查找递增的方式来找了。

首先当游戏关卡开启时,默认樱桃炸弹是在冷却状态下的,我们直接搜索未知初始化数据,然后回到游戏并搜索增加的数值并不断重复,当樱桃炸弹冷却结束后直接搜索减少的数值,由于冷却结束这个数值会变为0,因此直接找数值是0的就是樱桃炸弹的冷却时钟。

找到后直接点击找出是什么改写了这个地址,并手动种下一个樱桃炸弹,此时会得到一条内存增加汇编指令;

  • 0048728C - 83 47 24 01 - add dword ptr [edi+24],01 <<

接着我们种下一个豌豆射手,然后用同样的遍历技巧找到豌豆射手的冷却周期,同样的查找是什么改写了这个地址,会发现其出现的地址与樱桃炸弹地址相同,说明游戏中所有的植物都是在共用add dword ptr [edi+24],01这条代码进行计时的。

此时我们知道了樱桃炸弹的冷却时间地址是1078C0CC我们还知道豌豆射手的地址是1078C02C,接下来我们通过使用CE提供给我们的插件,结构爬行器来对比两个结构之间的差异,可知偏移为0x0的位置就是植物的当前冷却时间,而紧随其后的3601/79则分别代表的是植物的当前冷却周期,偏移为0x04位置处则可能代表最大冷却周期,偏移为0x08的位置处则可能代表的是卡片的顺序,其中2代表的樱桃炸弹卡片的位置,而0则代表豌豆射手的卡片位置。

  • 结构爬行器选择 打开查看内存 选择工具 结构爬行器

分析到此接下来就是修改了,此处我们有两种修改方式,第一种是修改植物的冷却时间锁定为0,即可实现植物无冷却,另一种改法是将植物的冷却周期修改的低一些,这样同样能实现快速完成冷却,而由于此处所有的游戏都共用一个定时器,又因为基地址无法被确定,所以此处只能通过改硬编码的方式实现无冷却,

以樱桃炸弹为切入点,使用x64dbg跳转到0x048728C并依次分析代码片段,最终读者可以定位到如下图0x48728a所示的一片区域内,

此处改法有很多,读者可以直接将add dword ptr ds:[edi+24],1里面的1修改为64这样一来植物的冷却速度就会变快,也可以直接将jle plantsvszombies.4872AC处的指令直接nop掉,由于所有植物都会共用这一处区域进行冷却的验证,所以只要此处修改,所有的植物都会无冷却。

实现替换的代码此处只给出核心部分,具体的类库实现在之前的文章中已经详细介绍过,如下所示则是核心实现过程;

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

using namespace std;

// 根据进程名得到进程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);

// 读取原始字节集
byte *Buff = new byte[2];
Buff = ReadByteSet(pid, 0x0487296, 2);

for (int i = 0; i < 2; i++)
{
printf("%02X ", Buff[i]);
}

// 写出替换无冷却
unsigned char shell[] = { 0x90, 0x90 };

BOOL ref = WriteByteSet(pid, 0x0487296, shell, 2);
if (ref == TRUE)
{
printf("[*] 替换数据成功 \n");

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

system("pause");
return 0;
}

当读者运行该代码判断,则此时游戏内的植物卡片将全部变成了无冷却状态,读者可自行验证判断;

针对于无冷却的找法还有另一种方式,当我们拿起植物时则植物会变成灰色,而当放下时则会变成白色,我们完全可以寻找卡片的状态值,该状态我们暂且定义为拿起植物此时状态变为0,放下则变为1,那么遍历的技巧则可总结为如下样子;

  • 打开CE > 搜索类型选择字节型 > 在植物亮的状态时搜索1
  • 拿起植物 > 搜索0 > 放下植物搜索1 > 拿起植物搜索0 > 一直重复

当读者拿到这个值以后,在该地址上面右键,选择是什么改写了这个地址,回到游戏,拿起植物然后直接右键放下,会出现两条汇编指令。

  • @当我们放下植物后出现:0040CDEA - C6 44 08 70 01 - mov byte ptr [eax+ecx+70],01 <<
  • @当我们拿起植物后出现:00488E73 - C6 45 48 00 - mov byte ptr [ebp+48],00 <<

我们直接点击00488E73 - C6 45 48 00 - mov byte ptr [ebp+48],00 这条指令,因为这条指令是拿起植物是的状态,我们需要将代码中的00改为01即可,查看反汇编代码并提取在它之上的几条指令作为特征码,此处我们提取特征为83 f8 1c 75 08 6a 1e这些机器码。

接着我们通过CE来验证一下是否能够定位到相应的地址上,在CE中选择搜索字节数组,然后能够搜到00488E64这个地址,而我们需要定位到00488E73,可以将两个地址相减得到相对偏移0xF(也就是十进制的15)就等于我们想要的地址,公式为00488e64 + 0xF = 当前地址

特征码定位可使用暴力枚举也可使用KMP枚举等方法实现,此处我们就使用最简单的暴力枚举,其替换补丁代码如下图所示;

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

using namespace std;

// 根据进程名得到进程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;
}

// 内存特征码搜索
ULONG ScanMemorySignatureCode(DWORD Pid, DWORD beginAddr, DWORD endAddr, unsigned char *ShellCode, DWORD ShellCodeLen)
{
unsigned char *read = new unsigned char[ShellCodeLen];

// 打开进程
HANDLE process = OpenProcess(PROCESS_ALL_ACCESS, false, Pid);

// 开始搜索内存特征
for (int x = 0; x < endAddr; x++)
{
DWORD addr = beginAddr + x;

// 每次读入ShellCodeLen字节特征
ReadProcessMemory(process, (LPVOID)addr, read, ShellCodeLen, 0);
int a = memcmp(read, ShellCode, ShellCodeLen);

if (a == 0)
{
printf("%x :", addr);
for (int y = 0; y < ShellCodeLen; y++)
{
printf("%02x ", read[y]);
}
printf(" \n");
return addr;
}
}
return 0;
}

int main(int argc, char *argv[])
{
// 通过进程名获取进程PID号
DWORD Pid = GetPidByName("PlantsVsZombies.exe");
printf("[*] 获取进程PID = %d \n", Pid);

// 开始搜索特征码
unsigned char ScanOpCode[7] = { 0x83, 0xf8, 0x1c, 0x75, 0x08, 0x6a, 0x1e };

// 依次传入开始地址,结束地址,特征码,以及特征码长度
ULONG Address = ScanMemorySignatureCode(Pid, 0x401000, 0x7FFFFFFF, ScanOpCode, 7);
printf("[*] 找到内存地址 = 0x%x \n", Address);

// 定位内存区域
ULONG OffAddress = Address + 0x0f;
printf("[+] 定位到特征位置 = 0x%x \n", OffAddress);

// 替换内存
BYTE WriteOpCode[4] = { 0xc6, 0x45, 0x48, 0x01 };

HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, false, Pid);

// 替换后输出
BOOL Ret = WriteProcessMemory(hProcess, (LPVOID)OffAddress, WriteOpCode, 4, 0);
if (Ret != 0)
{
printf("[+] 已替换内存特征 \n");
}

system("pause");
return 0;
}

运行代码片段则可实现替换0x488e73内存出的特征片段0xc6, 0x45, 0x48, 0x01并以此实现无冷却的效果,如下图所示;