PlantsVsZombies逆向相关


记录在逆向PVZ中的一些过程

0x01 前期准备

I. 游戏逆向分类

  • 脚本类

    找图, 模拟鼠标键盘操作

  • 内存类

    模拟内存数据操作, 调用游戏内部函数

  • 封包类

    破解游戏客户端, 模拟客户端发包

本次为内存类的逆向

II. 工具准备

  • 植物大战僵尸

  • 任意调试器

  • Cheat Engine

0x02 逆向过程

I. 基址寻找

1. 寻找阳光基址

寻找过程比较简单 找到基址后原路返回

最后定址到阳光基址如下

阳光基址 = [[[PlantsVsZombies.exe + 0x355E0C] + 0x868] + 0x5578]

2. 寻找冷却时间的基址

每个植物都有自己的冷却时间

所以要分析结构体的数组

一栏植物结构体大小0x50字节

+0 (首地址)
...
...
+24 冷却时间

基址定位

第n栏的冷却地址 = [[[[PlantsVsZombies.exe + 0x355E0C] + 0x868] + 0x15C] + 索引*0x50 + 0x28 + 0x24]

II. 逻辑修改

1. 阳光逻辑修改

种植植物阳光减少逻辑位置

PlantsVsZombiesNoCool.exe + 0x27696

逻辑如下

这里我的做法是把sub直接改成了add

2. 取消植物的冷却时间

植物冷却时间增加位置

PlantsVsZombiesNoCool.exe + 0x9CDFC

逻辑如下

修改逻辑即可

这里我直接将跳转给nop掉了 比较粗糙

其它方法可以自行尝试

III. 外部注入

1. 实现秒杀

思路

  • 炸弹全屏
  • 僵尸血量全空

先看第一种

猜测至少有的种植函数参数: 植物ID 行数x 列数y

那先通过选中植物找到植物ID的地址

找到之后查找访问 在种植的时候开始访问的地方则就是种植函数的位置了

找到的种植函数地址

[PlantsVsZombies.exe + 0x18D70]

函数情况如下

五个参数

EDI,行数,列数,植物ID, -1

还有一个参数EDI未知 去寻找一下他的基址

这个参数的基址如下

EDI = [[PlantsVsZombies.exe+0x355E0C] + 0x868]

最后尝试后得知樱桃炸弹的ID为2

call测试工具测试一下能不能正常执行

可以看到成功生效了

参数

push -1	//固定
push 2	//植物ID
mov eax, 0x4	//行数
push 4	//列数
mov edi, dword ptr ds:[0x755e0c]
mov edi, dword ptr ds:[edi+868]
push edi	//EDI
call 418d70

2. 控制僵尸的刷新

思路就是找到刷新僵尸的call 然后顺藤摸瓜

猜测参数会有行数 + ID

找到修改僵尸的call后 发现周围没有类似刷新僵尸的call 就顺着堆栈往回找

最后找到函数位置为

[PlantsVsZombies.exe + 0x19A60]

函数情况如下

三个参数

EDI, 僵尸ID, 行数

EDI找一下基址

EDI = [[PlantsVsZombies.exe + 0x355E0C] + 0x868]

0x03 修改制作

各个基址都找到咯

可以开始着手写修改了

这里就给出关键的代码

如果想更深一步了解 不嫌弃的话可以参考我的项目(不完整の渣代码)

I. 偏移定义

#pragma once

//********************************************
/* 游戏结构 */
//[PlantsVsZombies.exe + 0x355E0C]
#define GAMEFIXEDADDRESS 0x755E0C
//游戏开始结构
#define GAMESTART 0x868
//阳光基址
#define SUNSHINEOFFSET 0x5578
//栏位信息数组
#define FIELDOFFSET 0x15C
//栏位结构体大小
#define SIZEOFFIELD 0x50
//种植植物call
#define PLANTCALL 0x418D70
//刷僵尸call
#define ZOMBIECALL 0x419a60
//********************************************
//指令
#define SWITCH_INIT 1
#define SWITCH_CHANGESUN 2
#define SWITCH_NOCD 3
#define SWITCH_KILL 4
#define SWITCH_ZOMBIE 5
//********************************************
//共享内存名
#define ShareMemName "PVZTrainer"

II. 主程序

int main() {

	int UserInput = 0, ScanfRet = 0;
	BaseTool baseTool;
	while (true) {
		ShowMenu();
		ScanfRet = scanf_s("%d", &UserInput);
		//结束判断
		if (ScanfRet == EOF) {
			return 0;
		}
		//分发
		switch(UserInput) {
		case SWITCH_INIT:
		{
			if (baseTool._getProcessID() == 0) {
				if (baseTool.FindProcessByName("PlantsVsZombies.exe")) {
					cout << "进程加载完成" << endl;
					DWORD base = GetBaseAddr(baseTool._getProcessID());
				}				
				else {
					cout << "进程加载失败" << endl;
					break;
				}
			}
			else {
				cout << "进程已经加载完毕" << endl;
			}
			if (baseTool.Inject() == true) {
				cout << "注入完成" << endl;
			}
			else {
				cout << "注入失败" << endl;
			}
			break;
		}
		case SWITCH_CHANGESUN:
		{
			baseTool.EditSunshine();
			cout << "修改完成" << endl;
			break;
		}
		case SWITCH_NOCD:
		{
			baseTool.NoCoolDown();
			cout << "操作完成" << endl;
			break;
		}
		case SWITCH_KILL:
		{
			baseTool.KillAll();
			break;
		}
		case SWITCH_ZOMBIE: 
		{
			baseTool.CreateZombie();
			break;
		}
		default: 
		{
			baseTool.Deject();
			return 0;
		}
		}
		Sleep(1000);
		system("cls");
	}
	//清理内核对象

	return 0;
}

III. 阳光修改

/******************************************************************************************/
//修改阳光
//参数: void
//返回值: void
/******************************************************************************************/
void BaseTool::EditSunshine() {
	if (ProcessHandle == NULL) {
		cout << "请先初始化!\n" << endl;
		return;
	}
	int num;
	cout << "输入修改值:" << endl;
	cin >> num;
	DWORD Address = 0;
	ReadProcessMemory(ProcessHandle, (LPCVOID)GAMEFIXEDADDRESS, (LPVOID) &Address, sizeof(DWORD), NULL);
	ReadProcessMemory(ProcessHandle, (LPCVOID)(Address + GAMESTART), (LPVOID) &Address, sizeof(DWORD), NULL);
	WriteProcessMemory(ProcessHandle, (LPVOID)(Address + SUNSHINEOFFSET), (LPCVOID) &num, sizeof(DWORD), NULL);
	return;
}

IV. 全员无冷却

两种思路

  • 开线程刷新栏位数组
  • 直接改代码逻辑(像我之前直接把跳转nop掉)

前者需要通过基址经过两级跳转定址 然后开线程不停刷新每个栏位的CD

而后者相对来说更简单 直接通过基址转移到对应机器码 然后进行修改或恢复就可以了

这里我选择写的前者

/******************************************************************************************/
//无冷却功能
//参数: void
//返回值: void
/******************************************************************************************/
void BaseTool::NoCoolDown() {
	if (ProcessHandle == NULL) {
		cout << "请先初始化!\n" << endl;
		return;
	}
	if (NoCDThread != NULL) {
		cout << "之前已经开启 现在帮您关闭" << endl;
		TerminateThread(NoCDThread,0);
		NoCDThread = NULL;
		return;
	}
	NoCDThread = CreateThread(NULL, 0, NoCDThreadFunc, (LPVOID)ProcessHandle, NULL, NULL);
}

//线程函数
DWORD WINAPI NoCDThreadFunc(LPVOID p) {
	while (true) {
		DWORD Address = 0;
		ReadProcessMemory((HANDLE)p, (LPCVOID)GAMEFIXEDADDRESS, (LPVOID)& Address, sizeof(DWORD), NULL);
		ReadProcessMemory((HANDLE)p, (LPCVOID)(Address + GAMESTART), (LPVOID)& Address, sizeof(DWORD), NULL);
		if (Address == NULL) {
			cout << "请先进入游戏" << endl;
			return 0;
		}
		ReadProcessMemory((HANDLE)p, (LPCVOID)(Address + FIELDOFFSET), (LPVOID)& Address, sizeof(DWORD), NULL);
		//这里忘了记录栏位数目的地址 就直接写上限了 不知道栏位没开全会不会报错
		for (int i = 0; i < 10; i++) {
			DWORD tempAdd = (DWORD)((char*)Address + i * 0x50 + 0x28 + 0x24);
			DWORD CoolValue = 0x100000;
			WriteProcessMemory((HANDLE)p, (LPVOID)(tempAdd), (LPVOID)& CoolValue, sizeof(DWORD), NULL);
		}
	}
	return 0;
}

V. 全员秒杀

植物ID知道了

写个循环遍历 在每个位置上种上一次爆炸果

主程序

void BaseTool::KillAll() {
	*(int*)g_pShareMemory = 4;
}

DLL端

void DllTool::KillAll() {
	for (int i = 0; i < 5; i++) {
		for (int j = 0; j < 9; j++) {
			__asm {
				pushad
				pushfd
				push - 1
				push 2
				mov eax, i
				push j
				mov ecx, dword ptr ds : [GAMEFIXEDADDRESS]
				mov ecx, dword ptr ds : [ecx + GAMESTART]
				push ecx
				mov ecx, PLANTCALL
				call ecx
				popfd
				popad
			}
		}
	}
}

VI. 僵尸刷新

按照提示刷僵尸就行了

主程序

void BaseTool::CreateZombie() {
	int row = 0, zombieID = 0, num = 0;
	cout << "选择要刷的行数:" << endl;
	cin >> row;
	cout << "选择要刷的僵尸ID:" << endl;
	cin >> zombieID;
	cout << "选择要刷的数量:" << endl;
	cin >> num;
	*((int*)(g_pShareMemory)+1) = row;   
	*((int*)(g_pShareMemory)+2) = zombieID;
	*((int*)(g_pShareMemory)+3) = num;
    //赋值完毕再通知DLL
	*(int*)g_pShareMemory = SWITCH_ZOMBIE;
}

DLL端

void DllTool::CreateZombie() {
	int row = 0, zombieID = 0, num = 0;
	row = *((int*)(g_pShareMemory)+1);
	zombieID = *((int*)(g_pShareMemory)+2);
	num = *((int*)(g_pShareMemory)+3);
	while (num > 0) {
		num--;
		__asm {
			pushad
			pushfd
			push row
			push zombieID
			mov edi, dword ptr ds : [GAMEFIXEDADDRESS]
			mov eax, dword ptr ds : [edi + GAMESTART]
			mov edi, ZOMBIECALL
			call edi
			popfd
			popad
		}
	};
}

0x05 添加/过掉检测

添加检测

外置检测 这里可以通过DLL劫持注入代码

保护的思路

  • 从游戏的关键数据下手

    用线程扫描相关数据位置 不满足逻辑条件 则判断被修改

  • 从数据完整性下手

    这里可以用CRC校验

    游戏启动时先计算CRC值

    然后开线程和初始CRC进行对比

0x06 内容总结

本次实践涉及知识面

  • 游戏内存基址寻找

  • 跨进程内存修改

  • 远程线程注入DLL

  • 进程通信

之后需要学习更多的注入方式,以及对于这种修改的防护技术。


Author: Luluting
Reprint policy: All articles in this blog are used except for special statements CC BY 4.0 reprint policy. If reproduced, please indicate source Luluting !
  TOC