逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷

前言

为了给学生讲解游戏辅助的原理,临时起意写了一个扫雷游戏的辅助程序,可真正实现0秒一键自动扫雷等的功能。纵观网上及冀云前辈那本书上的文章,发现已有的资料中存在有这么几个问题:1、无法实现0秒扫雷,都是至少需要1秒才能完成任务;2、雷区究竟是在游戏初始化时就设置好了,还是要等玩家点击了第一处雷区方块才进行设置?3、已有的资料中实现一键扫雷或者地雷自动标记,都是针对于特定的雷区,比如只针对高级模式,换一个模式就不能用了,更别提自定义雷区了。而我采用的方法则完美解决了上述所有问题。

我们本次研究的对象是在Windows系统中有着悠久历史的扫雷游戏。本次研究欲达成的目标有三个,一个是实现让时间停止走动,即达到0秒完成扫雷任务;二是实现在雷区中自动标注地雷分布;三是实现一键扫雷。所使用的工具有Visual Studio 2013、x64dbg以及Cheat Engine 6.3。

 

时间停止的实现

我们知道,当进入扫雷游戏,点击雷区方块开始扫雷的时候,雷区右上角的时间就会开始走动,直到玩家踩到地雷或者将地雷全部标出,时间才会停止,而游戏中的“扫雷英雄榜”也正是依据玩家扫雷耗时的长短进行排名的,而如果有某个玩家能够以0秒就完成扫雷任务,那无疑就会排在第一位。但正常来说这是一件不可能的事情,因此就需要以技术的手段实现我们的目标。

这里我们首先需要找出究竟是哪块内存区域保存有这个时间。启动扫雷和CE,将CE挂载到扫雷进程中(winmine.exe):

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第1张图片

然后我们需要进行第一次的数值搜索,目前时间是0,那么我们就先搜索0,进行First Scan:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第2张图片

这里会搜索出很多的结果,我们看不出来哪个地址保存有时间数值,因此不妨开始游戏让时间走动,以进行下一次的搜索。但是考虑到时间一旦走动,我们很难让时间停止进行精确数值的搜索,因此我们这里不妨在Scan Type这里设置一下,比如选择Smaller than,然后在Value里面填进5,意思是搜索小于5的数值。此时我们开始游戏,在5秒前回到CE进行第二轮的搜索。利用这样的思想,就可以找到保存时间的地址了:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第3张图片

可以看到,这个地址是0x0100579,我们双击这个结果,在下方出现的数据中单击右键,选择Find out what accesses this address,就可以查看是哪条语句访问了这个地址,换句话说,也就是查看时间是由哪条语句控制的:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第4张图片

于是可以看到如下结果:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第5张图片

可以发现,前两条语句明显与时间相关,因为它首先是将时间与0x3E7相比较,也就是十进制的999,意思是如果时间未超过999秒这个上限,就继续向下执行。第二条的inc语句是一个自增,控制时间的增加。因此如果想让时间停止增加,就可以将这条自增语句删掉,或者nop掉。所谓的nop掉,意思是将inc语句修改为nop语句,也就是什么都不执行,采用这样的方式可以有效删掉原始语句的功能,且不会影响程序其它逻辑功能的实现。nop的汇编代码是0x90。因此我们需要把位于0x01002FF5位置的FF 05 9C 57 00 01这六个字节的代码全都修改为0x90。可利用如下代码实现:

#include 
#include 
int main() {
	DWORD Pid;
	HANDLE hProcess = 0;
	DWORD timeAddress = 0x0100579C;
	DWORD incAddress = 0x01002FF5;
	WORD Time = 0;	
	char inc[6] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
	DWORD result1;
	DWORD result2;
	HWND hWnd = FindWindow(NULL, L"扫雷");
	if (hWnd != 0){
		GetWindowThreadProcessId(hWnd, &Pid);
		hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, Pid);
		if (hProcess == 0){
			printf("Open process failed.");
			return 0;
		}
		result1 = WriteProcessMemory(hProcess, (LPVOID)timeAddress, &Time, 2, 0);
		result2 = WriteProcessMemory(hProcess, (LPVOID)incAddress, &inc, 6, 0);
		if (result1 == 0){
			printf("Time modify failed.");
			return 0;
		} else if (result2 == 0) {
			printf("Inc modify failed.");
			return 0;
		} else {
			printf("Modify Success.");
		}
		CloseHandle(hProcess);
	} else {
		printf("Failed");
	}
	return 0; 
}

考虑到扫雷开始以后,就会直接从1秒开始计数,因此我除了要将inc语句nop掉以外,还需要将保存时间的内存数据修改为0。这样,当我们运行游戏后,随意点击一个方块开始扫雷,然后启动辅助,就能够实现时间停止,并且计数器归0的操作了:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第6张图片

于是就实现了0秒达成扫雷任务。可是采用这样的方式,我们只能在扫雷游戏开始之后才能使用辅助,而不能在游戏界面刚开始弹出的情况下使用,因为这样一来虽然可以nop掉inc函数从而实现时间停止,但是时间会停留在1秒的位置,只有再次使用辅助,修改内存中的时间将其置零,才能够真正达到0秒扫雷的效果。也就是说,当我们第一次点击雷区方块的时候,其实还有一处语句实现了当第一次按下雷区时,将时间置1的操作,所以我们有必要将这条语句找出来,将其修改掉。这里我们可以通过x64dbg来将其找出。

运行x64dbg,将扫雷程序加载进来,然后按下F9运行程序,直至扫雷界面弹出。接下来我们在之前发现的用于保存秒数的0x0100579C地址位置下一个硬件写入断点:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第7张图片

然后我们回到扫雷,随便在雷区一个位置点击鼠标左键,就可以看到扫雷被断下来了:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第8张图片

由截图中可以看到,内存区域0x0100579C已经变成了1,正待执行的指令是位于0x01003836位置的call winmane.10028B5,刚刚执行完的指令是位于0x01003830位置的inc,也就是说,正是这个inc语句,响应了我们的首次鼠标点击(左键按下)的操作,将时间置1。

对于这个语句,我们可以采用同样的思路,也就是将其nop掉,来彻底实现计数器不运行的效果。修改之前的代码如下:

#include 
#include 
int main() {
	DWORD Pid;
	HANDLE hProcess = 0;
	DWORD incAddress1 = 0x01002FF5;
	DWORD incAddress2 = 0x01003830;
	char inc[6] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
	DWORD result1;
	DWORD result2;
	HWND hWnd = FindWindow(NULL, L"扫雷");
	if (hWnd != 0){
		GetWindowThreadProcessId(hWnd, &Pid);
		hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, Pid);
		if (hProcess == 0){
			printf("Open process failed.");
			return 0;
		}
		result1 = WriteProcessMemory(hProcess, (LPVOID)incAddress1, &inc, 6, 0);
		result2 = WriteProcessMemory(hProcess, (LPVOID)incAddress2, &inc, 6, 0);
		if (result1 == 0 || result2 == 0) {
			printf("Inc modify failed.");
			return 0;
		} else {
			printf("Inc modify Success.");
		}
		CloseHandle(hProcess);
	} else {
		printf("Failed");
	}
	return 0; 
}

这样就可以实现在雷区初始化完毕后,直接将计数器永远置零的操作了

 

自动标注地雷分布的实现

我们这里接着之前的讨论,在保存有秒数的内存位置下断点以后,点击雷区中的一个方块,扫雷游戏就被断下来了:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第9张图片

结合这张图有件事需要说明的是,对于扫雷游戏而言,当我们在雷区方块中按下鼠标左键时,黄色笑脸的嘴会变成“o”型,并且雷区方块也会呈按下的状态,直至松开鼠标左键,即发生鼠标左键抬起的操作时,该方块会被打开,同时黄色小人也会变回笑脸的样子。知道这点很重要,因为我们目前正处于鼠标左键按下的响应代码里面。因此我们现在不妨单步执行,看看什么时候扫雷界面会发生变化。

可以发现,当我们执行完位于0x010038B1位置的call语句后,雷区我们按下的位置出现了一个1:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第10张图片

说明刚才执行的那个call语句,就是用来计算雷区的函数,而用于生成雷区的函数,应该不会在它之后的。所以不妨在0x0100384F的位置下一个断点,然后重新载入扫雷游戏。这里我们不妨打开第1行的第3个雷区方块,然后单步走,查看雷区情况。我们发现,位于0x01003860位置开始的两个赋值语句,是将地址0x01005118以及0x0100511C位置的数据赋给eax和ecx,所以我们不妨看一下这两个地址里面的内容:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第11张图片

可以看到,一个保存的是03,一个是01,初步怀疑,这两个地址保存的是我们鼠标所点击的雷区坐标。为了进一步确认这个猜想,不妨重新载入扫雷,这次我们选择的是第2列的第4行,查看一下相关位置的内存情况:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第12张图片

可以看到我们的猜想是正确的。总结一下,我们应该对数据敏感,应该故意输入一些特定的数据,以方便我们调试查找,每遇到与内存操作相关的指令,不妨进入到该内存空间看看保存了什么东西,然后通过大胆的猜想与细心的推理,往往能够得到重要的线索。

继续单步执行,来到0x0100389B的位置:

由刚才的发现我们知道,我们所点击的行数会保存在ECX寄存器里面,而列数会保存在EAX寄存器里面。由这部分反汇编代码可以看到,这里将ECX也就是行数赋给了EDX,然后利用逻辑左移的方式移了5位,得到了0x80的结果。由目前的信息来看,我们不知道为什么要这么计算,因此先不管它。但是当前执行的指令却用到了EDX,也就是将EDX与EAX相加然后再加上0x1005340,将运算结果当作内存地址,取出这个地址中的内容。由于说EDX和EAX的内容是通过我们的输入所得到的,因此可以知道,这里的基地址是0x1005340,而EDX+EAX的值很可能是偏移。我们不妨看一下内存0x1005340中的内容:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第13张图片

这里首先可以看到,位于0x010053C2的位置,也就是EDX+EAX+0x1005340的地址处的数据是绿色的00,然后整片内存区域以0F为主,另外10似乎是围出了一个矩形区域。在这个矩形区域中,除了10和0F之外,还有10个8F,这与初级扫雷的默认雷数一致。如果我们再往上看16个字节的数据,又会发现一些新的有意思的数据:

可以看到,从0x01005330开始,这里的一行绿色数据包含有0x0A、0x09以及0x09这三个数值,很明显这三个数据正是当前雷区的地雷数量以及宽、高等信息。这里我们怀疑0x10用于表示边界,那我们不妨将这块数据复制出来,利用文本工具编辑整理一下,得到如下结果:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第14张图片

然后我们点击继续运行(F9)来看一下:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第15张图片

再看一下雷区情况:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第16张图片

进行提取整理:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第17张图片

对比发现,雷区中的数字是以ASCII码的形式呈现的,由此便验证了我们之前的所有猜想。利用上述分析思想,如果玩家用鼠标右键标注了旗子,则标注旗子的地方在内存中会以0x8E进行标记。大家也可以自行尝试不同雷区在内存中的编排情况,原理与上述初级雷区是完全一致的,这里不再赘述。

现在我们已经知道雷区的起始位置在0x01005340,那么它的结束位置在哪里呢?也就是说,程序会在内存中开辟多大空间来保存雷区的数据?为了找出答案,我们可以看看扫雷游戏的雷区极限大小是多少。这里可以选择菜单栏中的“游戏”->“自定义”,可尝试将高度和宽度都设置为99,然后查看雷区情况:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第18张图片

可以发现,尽管我们设置的是99,但是游戏却自动给我们将这两个数值变为了24和30,也就是说这就是雷区的极限值。然后再查看一下内存中的雷区情况:

因为我们已经知道0x10代表边界,而最后一个0x10即边界点位于0x0100567F,于是就可以知道,包含有边界的雷区的分布会在0x01005340到0x0100567F之间,这之间共有832字节的数据信息,一会在程序中会使用到这个数值。

然后还有个小问题不妨也研究一下,那就是雷区的分布究竟是在窗口初始化的时候就已经规划好的呢,还是说是在我们随意点击一个雷区方块的时候才分配的呢?我们依旧载入初级难度,先看一下初始化后的内存情况:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第19张图片

可以发现,当前雷区中的第一个格子就是地雷,可是当我们点击雷区第一个方块以后,内存情况如下:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第20张图片

可以看到,第一个格子的数据变成了41,表明以这个格子为中心的3×3范围内有一个地雷,而这个地雷的位置就在它的右边,这与初始化时候的分布完全不一样。于是也就说明了,程序初始化的时候确实会进行雷区的分布,但是在我们随意点开一个雷区方块以后,又会进行雷区的重新分配。所以,为了获取真实雷区的情况,必须要先点开一个雷区方块才可以。但是经过测试,有一种情况下,雷区不会重新分配。也就是在雷区初始化以后,我们将雷区中任意一个方块标记成一个地雷的话(点击鼠标右键),雷区就不会重新分配了。关于这点,也可以通过上述方法进行验证,这里不再演示。

综上,我们如果想将雷区中的地雷全部标上小旗,原理就是首先获取整个雷区的数据,将其中的0x8F(地雷)替换为0x8E(小旗),然后写入内存,刷新界面即可。完整程序如下:

#include 
#include 

int main() {
	DWORD Pid = 0;
	HANDLE hProcess = 0;

	// 控制时间自增的指令地址
	DWORD incAddress1 = 0x01002FF5;
	DWORD incAddress2 = 0x01003830;
	// 将时间自增NOP掉的6字节指令
	char inc[6] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
	DWORD result1,result2;

	PBYTE pByte = NULL;
	DWORD dwHeight = 0, dwWidth = 0;

	// 获取扫雷游戏对应的窗口句柄
	HWND hWnd = FindWindow(NULL, L"扫雷");
	if (hWnd != 0){
		// 获取扫雷进程ID
		GetWindowThreadProcessId(hWnd, &Pid);
		// 打开扫雷游戏获取其句柄
		hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, Pid);
		if (hProcess == 0){
			printf("Open winmine process failed.");
			return 0;
		}
		
		// NOP掉时间自增的语句
		result1 = WriteProcessMemory(hProcess, (LPVOID)incAddress1, &inc, 6, 0);
		result2 = WriteProcessMemory(hProcess, (LPVOID)incAddress2, &inc, 6, 0);
		if (result1 == 0 || result2 == 0){
			printf("Inc modify failed.");
			return 0;
		}		
		else{
			printf("Inc modify Success.");
		}

		// 存放雷区的起始地址
		DWORD dwBoomAddr = 0x01005340;
		// 雷区的最大值(包含边界)
		DWORD dwSize = 832;
		pByte = (PBYTE)malloc(dwSize);
		DWORD dwTmpAddr = 0;
		
		// 读取整个雷区的数据
		ReadProcessMemory(hProcess, (LPVOID)dwBoomAddr, pByte, dwSize, 0);
		BYTE bClear = 0x8E;
		int i = 0;
		int n = dwSize;
		
		while (i < dwSize) {
			if (pByte[i] == 0x8F) {
				dwTmpAddr = 0x01005340 + i;
				WriteProcessMemory(hProcess, (LPVOID)dwTmpAddr, &bClear, sizeof(BYTE), 0);
				n--;
			}
			i++;
		}

		// 刷新扫雷的客户区
		RECT rt;
		GetClientRect(hWnd, &rt);
		InvalidateRect(hWnd, &rt, TRUE);

		free(pByte);
		CloseHandle(hProcess);
	}
	else {
		printf("Get hWnd failed.");
	}
	return 0; 
}

利用这个程序,我们在打开扫雷游戏之后,不论雷区的大小是怎样的,运行我们的辅助程序,就可以0秒实现地雷的自动标注了:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第21张图片

 

一键扫雷功能的实现

由上述分析可见,扫雷程序并没有因为我们标记出了所有的地雷而判定我们任务完成。这是因为我们仅仅是将雷区数据显示出来了,而没有真正“激活”扫雷程序,可以理解为,扫雷程序并没有检测到我们鼠标在雷区的点击操作,因此我们目前这种表面化的功夫并不算数。因此,为了实现一键扫雷,我们需要在雷区模拟鼠标的点击,也就是鼠标左键按下与抬起的操作。让程序模拟鼠标点击所有的非雷区域,这样就可以完成一键扫雷了。

由于我们的Windows应用程序基本都是基于消息机制的,因此我们可以通过利用SendMessage()这个API函数,通过指定的窗口句柄将消息发送给指定的窗口,也就是在获取到扫雷的窗口句柄后,就可以利用这个函数向该窗口发送鼠标按键消息,从而实现模拟鼠标的操作了。该函数的定义如下:

LRESULT SendMessage(
  HWND   hWnd,     // handle to the destination window
  UINT   Msg,      // message
  WPARAM wParam,   // first message parameter
  LPARAM lParam    // second message parameter
);

在这个函数中,第1个参数hWnd是要接收消息的窗口句柄,这里是我们之前所获取到的扫雷窗口句柄;第2个参数Msg是要发送消息的消息类型,这里因为我们要模拟鼠标的按键操作,因此使用WM_LBUTTONDOWN模拟鼠标左键的按下操作,使用WM_LBUTTONUP模拟松开鼠标左键的操作;第3和第4个参数是消息的两个附加参数,其中第3个参数我们这里使用MK_LBUTTON,表明是鼠标左键的操作,第4个参数是鼠标按下的坐标,也就是x轴和y轴的位置坐标。关于这个坐标,MSDN的说明如下(https://docs.microsoft.com/en-us/windows/desktop/inputdev/wm-lbuttondown):

The coordinate is relative to the upper-left corner of the client area.

说明该坐标是基于客户区的左上角,但是这个客户区是怎么定义的呢:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第22张图片

如上图所示,究竟A点是客户区的左上角,还是说B点才是呢?如果A点为坐标原点,那么第一块雷区的坐标就应为(AC,CE),如果B点为坐标原点,那么第一块雷区的坐标就应为(BD,DE)。经过实际测试,MSDN中所谓的客户区,其实是以B点作为起点的位置,即原点坐标(0,0),而雷区中心即E点的坐标为(16,61),每个雷区小方块的大小为16×16,于是可以知道,这里需要循环计算出雷区每一个小方块的坐标,这个坐标与保存有雷区的二维数组下标紧密相关。假设这个二维数组是mine[y1][x1],其中y1表示的是雷区有多少行,x1表示雷区的列数,那么每个雷区方块的坐标为:

x = x1 * 16 + 16;
y = y1 * 16 + 61;

在获得了坐标以后,就可以通过如下语句来模拟鼠标的点击操作了:

SendMessage(hWnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELONG(x, y));
SendMessage(hWnd, WM_LBUTTONUP, MK_LBUTTON, MAKELONG(x, y));

需要说明的是,SendMessage函数的第四个参数只能接收一个参数,但是我们的坐标是两个数值,因此需要将两个数值合并成一个数值,也就是使用MAKELONG函数(https://docs.microsoft.com/en-us/windows/desktop/winmsg/window-macros),将两个16位数据组合成一个32位的数据。因此接下来的工作就是获取雷区的二维数组,从而得到x1和y1的值。

经过之前的分析我们知道,扫雷游戏的雷区长宽数据会保存在雷区上方,即内存0x01005330的位置,可用如下代码获取雷区大小的数据:

DWORD dwInfo = 0x01005330;
DWORD dwHeight = 0, dwWidth = 0;
ReadProcessMemory(hProcess, (LPVOID)(dwInfo + 4), &dwWidth, sizeof(DWORD), 0);
ReadProcessMemory(hProcess, (LPVOID)(dwInfo + 8), &dwHeight, sizeof(DWORD), 0);

这样,雷区的高度就保存在了dwHeight里面,宽度保存在了dwWidth里面。我们之前已经通过代码获取了整个雷区的数据,保存在了数组pByte[]里面,但是这里面是整个雷区的数据,包含有832个字节,而我们现在只需要dwHeight×dwWidth这么多的数据,也就是只与雷区相关的数据,这样一来,雷区的边界0x10以及多余的0x0F都应该删去,如下图所示:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第23张图片

上图是初级雷区的内存分配图,可以发现,其实不论是哪一种雷区,其最开始一定是雷区的最上一条边界的数据,包含有dwWidth+2这么多的0x10,其中的这个2是边界的起点和终点。因此我们在进行雷区转换的时候,首先应该跳过这第一条边,也就是如果发现两个连续的0x10则认为它是雷区的上边,则会跳过。接下来可能会读取到0x0F,则继续往下读,直到读取到下一个0x10为止,说明已经到了雷区的第一行,此时依旧应该跳过当前的这个0x10,从下一个字节的数据开始读取,读取dwWidth这么多的字节,然后再后移dwWidth+2这么多的字节,以此循环,直到已经读完了dwHeight这么多行为止,说明整个雷区的有效数据已经获取完毕。代码如下:

int h = dwHeight;
int count = 0;
PBYTE pTmpByte = NULL;
pTmpByte = (PBYTE)malloc(dwHeight*dwWidth);
while (i < dwSize) {
    if (pByte[i] == 0x10 && pByte[i + 1] == 0x10) {
        i = i + dwWidth + 2;
        continue;
    } else if (pByte[i] == 0x10) {
        for (j = 1; j <= dwWidth; j++) {
            pTmpByte[count] = pByte[i + j];
            count++;					
        }
        i = i + dwWidth + 2; continue;
        h--;
        if (h == 0) break;				
    }
    i++;
}

获取到雷区数据以后,它是以一维数组的形式保存在pTmpByte[]里面的。下面我们需要得到该数组中每一个数据在二维数组模式下的下标,即x1和y1值。其中y1是行下标,表明是第几行,行数从0开始;x1是列下标,表明是第几列,列数也是从0开始的。该数组中的数据量为dwHeight×dwWidth,可以采用循环的方式逐个读取数组中的数据然后计算出其二维下标值。假设这个一维数组下标为i,则行下标可以用i除以dwWidth然后取整数商的方式获得,列下标可以用i模dwWidth的方式,也就是通过取余运算获得。因此包含有鼠标点击的完整代码如下:

int x1 = 0, y1 = 0;
int x = 0, y = 0;
for (i = 0; i < dwHeight*dwWidth; i++){
    if (pTmpByte[i] != 0x8F) {
        x1 = i % dwWidth; 
        y1 = i / dwWidth;
        x = x1 * 16 + 16;
        y = y1 * 16 + 61;
        SendMessage(hWnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELONG(x, y));
        SendMessage(hWnd, WM_LBUTTONUP, MK_LBUTTON, MAKELONG(x, y));
    }
}

综合以上,整个0秒一键扫雷功能的完整代码如下:

#include 
#include 

int main() {
	DWORD Pid = 0;
	HANDLE hProcess = 0;

	// 控制时间自增的指令地址
	DWORD incAddress1 = 0x01002FF5;
	DWORD incAddress2 = 0x01003830;
	// 将时间自增NOP掉的6字节指令
	char inc[6] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 };
	DWORD result1, result2;

	// 获取扫雷游戏对应的窗口句柄
	HWND hWnd = FindWindow(NULL, L"扫雷");
	if (hWnd != 0){
		// 获取扫雷进程ID
		GetWindowThreadProcessId(hWnd, &Pid);
		// 打开扫雷游戏获取其句柄
		hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, Pid);
		if (hProcess == 0){
			printf("Open winmine process failed.");
			return 0;
		}

		// NOP掉时间自增的语句
		result1 = WriteProcessMemory(hProcess, (LPVOID)incAddress1, &inc, 6, 0);
		result2 = WriteProcessMemory(hProcess, (LPVOID)incAddress2, &inc, 6, 0);
		if (result1 == 0 || result2 == 0){
			printf("Inc modify failed.");
			return 0;
		}

		// 存放雷区的起始地址
		DWORD dwBoomAddr = 0x01005340;
		// 雷区的最大值(包含边界)
		DWORD dwSize = 832;
		PBYTE pByte = NULL;
		pByte = (PBYTE)malloc(dwSize);

		// 读取整个雷区的数据
		ReadProcessMemory(hProcess, (LPVOID)dwBoomAddr, pByte, dwSize, 0);
		int i = 0;
		int j = 0;
		int n = dwSize;
		
		// 读取雷区的长和宽
		DWORD dwInfo = 0x01005330;
		DWORD dwHeight = 0, dwWidth = 0;
		ReadProcessMemory(hProcess, (LPVOID)(dwInfo + 4), &dwWidth, sizeof(DWORD), 0);
		ReadProcessMemory(hProcess, (LPVOID)(dwInfo + 8), &dwHeight, sizeof(DWORD), 0);

		int h = dwHeight;
		int count = 0;

		// 雷区转换,去掉雷区多余的数据
		PBYTE pTmpByte = NULL;
		pTmpByte = (PBYTE)malloc(dwHeight*dwWidth);
		while (i < dwSize) {
			if (pByte[i] == 0x10 && pByte[i + 1] == 0x10) {
				i = i + dwWidth + 2;
				continue;
			}
			else if (pByte[i] == 0x10) {
				for (j = 1; j <= dwWidth; j++) {
					pTmpByte[count] = pByte[i + j];
					count++;					
				}
				i = i + dwWidth + 2;
				continue;
				h--;
				if (h == 0) break;				
			}
			i++;
		}

		// 获取雷区方块的坐标,然后模拟鼠标进行点击
		int x1 = 0, y1 = 0;
		int x = 0, y = 0;
		for (i = 0; i < dwHeight*dwWidth; i++){
			if (pTmpByte[i] != 0x8F) {
				x1 = i % dwWidth; 
				y1 = i / dwWidth;
				x = x1 * 16 + 16;
				y = y1 * 16 + 61;
				SendMessage(hWnd, WM_LBUTTONDOWN, MK_LBUTTON, MAKELONG(x, y));
				SendMessage(hWnd, WM_LBUTTONUP, MK_LBUTTON, MAKELONG(x, y));
			}
		}

		free(pByte);
		CloseHandle(hProcess);
	}
	else {
		printf("Get hWnd failed.");
	}
	return 0;
}

编译链接生成exe文件,我们首先运行扫雷程序,然后再运行这个辅助,不论是哪个级别的扫雷,或者是自定义的扫雷,都可以实现0秒一键扫雷的效果:

逆向工程第007篇:扫雷辅助的研究——0秒实现一键自动扫雷_第24张图片

 

小结

扫雷尽管是一款简单的游戏,其辅助程序也仅仅才一百行,但是整个分析的过程却也是综合采用了不少的逆向分析以及Windows编程知识,其中的很多思想其实可以应用在很多的方面,可以用于处理很多复杂的情况。而这些思想在我未来的研究中也会继续使用,去解决更多有趣的问题。

你可能感兴趣的:(逆向工程)