实验5-游戏外挂设计与防范

实验名称

实验 5 游戏外挂设计与防范

实验目的

本实验通过分析 Windows XP 附带的扫雷游戏,实现自动扫雷的外挂程序,一方面了解内存外挂的相关技术,另一方面分析防范此类外挂的方法。

实验原理

通过读写内存数据,确定游戏雷区,之后模拟鼠标操作,实现自动扫雷。

实验内容

加载游戏

运行扫雷游戏,然后打开 CheatEngine(CE),加载游戏进程

图1:打开CE加载扫雷进程

(2)熟悉 CE 工具的使用

为熟悉 CE 工具的使用,我们尝试搜索内存中固定的数值,定位存储地雷数量的变量地址,以及游戏计时器的内存地址。

(a)寻找地雷数量的内存地址

具体地,在 CE 界面选择数据类型为 4 字节,扫描类型为“精确数值”,数值内容填写扫雷游戏所显示的地雷数量,然后单击“初次扫描”,如图 5-2 所示。调整扫雷游戏难度等级,使地雷数量发生改变,然后在 CE 界面填写新的 地雷数量,单击“再次扫描”按钮。不断重复这样的操作,直至 CE 左侧找到的 地址数降为个位数,双击所找到的地址,将其添加到 CE 下方地址列表中。
image-02.png

图2:第一次使用CE找地雷数量的内存地址

image-03.png

图3:多次更改炸弹数量,使得留存下来的地址只有个位数

image-04.png

图4:在扫雷软件中标记炸弹后用CE查看

在多次改变初始的炸弹数量之后,CE扫出来的内存地址数一直保持在三个,没办法减少。这说明,这个程序在运行的时候就是有三个变量会存储开始时的炸弹个数。但正常来说只有一个会作为变量记录着现有的炸弹数,不然会出现值不一致等问题,而且三个变量都始终存相同的值实在没必要。所以这三个变量肯定有不同的含义。

首先我们尝试在扫雷程序中通过标记的方式来减少炸弹数量。如图4,我们可以看到,当炸弹数量减少之后,只有一个地址的值跟着变化了。说明这个地址的存放的实际上是剩余炸弹的数量。那接下来还有两个不会变的值,我们还得弄清楚到底哪个是真正的存放炸弹总数的地址。

剩下两个地址里,肯定有一个是存放我们定义好的炸弹的数量的地址。那另一个是用来干什么的呢?这时候根据我们的编程经验,我们不难得出另一个应该是扫雷窗口初始化时的窗口参数。毕竟我们定义的炸弹数量相当于存储在配置文件中的值,而扫雷软件想要真正的显示对应的窗口等,肯定是要读取配置文件中的值作为“窗口创建函数”的参数的。虽然我们不知道具体的“窗口创建函数”原型是什么,但是这点也不重要。

我们可以注意到,扫雷程序中有一个按钮是用来刷新界面的,这一步中,如果我们没有手动在程序中改变炸弹的数量,那么刷新完的界面中炸弹数量还是我们设置的值。而根据我们上面的分析可以知道,要刷新界面(等同于重新创建)其实是要读取真正的炸弹数量的值作为“窗口创建函数”的参数。那么理论上如果我们通过CE改变了对应内存处储存的炸弹数量,刷新后的界面中的炸弹数量也会跟着改变。即,如果我们改对了正确的炸弹数量存储地址的值,另外两个内存地址的值也会在刷新之后跟着改变。与之相对的,如果我们改的是“窗口创建函数”的参数,那么在刷新界面之后,我们的修改会被覆盖掉,换成“配置文件中”的炸弹数量值。接下来,我们按照这个猜想来尝试:
image-05.png

图5:修改第二处内存中的值

image-06.png

图6:刷新扫雷程序的界面

image-07.png

图7:修改第三处内存中的值

image-08.png

图8:刷新扫雷程序的界面

由上面几图可知,当修改第二处内存的值并刷新后,我们做的修改还是会被其它的值覆盖回原值。而修改第三处内存的值并刷新后,另外两个内存处的值也被修改成了我们之前修改的值。也就是说,我们上面的猜想是正确的,而第三处内存的地址,才是真正的我们自定义或者由扫雷程序定义的炸弹数量的内存地址。而第一个是窗口初始化后剩余炸弹数量的地址,第二个是窗口初始化时存储读取的炸弹数量的地址。

(b)寻找游戏计时的内存地址

单击“新的扫描”按钮,在 CE 界面选择数据类型为 4 字节,扫描类型为“精确数值”,数值内容填写 0,然后单击初次扫描按钮。

点开雷区任意一个格子,使游戏开始计时,在 CE 界面将扫描类型改为“增加的数值”,然后单击“再次扫描”。计时增加后,重复这样的操作,或者计时增加到较高数值后开始新的一局,然后将 CE 扫描类型改为“减少的数值”,再次扫描。直至找出计时地址,双击、将其添加到 CE 下方地址列表中。
image-09.png

图9:用CE查看扫雷软件中初始值为0的内存地址

image-10.png

图10:用CE查看扫雷软件中存储值不断增长的内存地址

由上图可知扫雷程序内存中值会不断增加的只有一个位置,并且这个位置的值和扫雷程序中显示的时间一摸一样,证明这个唯一地址就是扫雷的时间地址。

(3)寻找雷区行和列所对应的内存地址

单击“新的扫描”按钮,在 CE 界面选择数据类型为 4 字节,扫描类型为“精确数值”,数值内容填写雷区的行数,例如初级行和列均为 9,然后单击初次扫描按钮。在扫雷程序单击“游戏--自定义”设置新的高度和宽度值,然后在 CE 界面填写新行或列的数值,单击“再次扫描”按钮。不断重复这样的操作,直至 CE左侧找到的地址数减少为个位数,双击所找到的地址,将其添加到 CE 下方地址列表中。存储雷区行数的地址为:0x10056A8,存储雷区列数的地址为:0x10056AC。
image-11.png

图11:用CE查看扫雷软件中行数对应的内存地址

image-12.png

图12:自定义扫雷软件的行数将对应的内存地址缩短至个位数

image-13.png

图13:通过雷区行列变量之间的关联找真正的行列变量地址

由图12可知,即使不断变化雷区的列数,最后还是会剩下两个内存地址的值都和列数一样。那么可不可以用刚才寻找地雷数量存储地址的方法呢?答案是完全可以,因为列数等属性也是“窗口创建函数”的参数。那么剩下这两个地址里存的值分别是什么含义就很显然了。但是不用这个方法,有没有其它方法找到对应的正确地址呢?

这个时候,就需要我们发挥想象力和回顾经验了。由于雷区的行数和列数属性是高度关联的,而正常我们写程序的时候也不会说特地把他们给分开。一般来说都是同时定义的,或者更进一步,直接用一个结构体包含这两个变量。既然这样,我们就可以去内存里面看看是不是真的有这样的规律了。

由图13可知,当我多次变动雷区的行数之后,两个内存地址中的其中一个地址+4之后的位置,同样也一直在变动,且值刚好是我每次更改后的行数。而另一个地址-4之后的地址处也展现出这样的规律。既然这样的话,我们就没办法通过这个方法判断了。

上面的方法虽然失败了,但是,如果我们仔细观察可以发现,两组行列值的内存地址,有一组和之前我们观察到的存储炸弹数量的真实地址是相邻的,那根据变量存储的相关性,基本上也能判断出到底哪对行列值的内存地址是真正的地址了,如下图:
image-14.png

图14:通过扫雷程序变量存储地址之间的关联找真正的行列变量地址

虽然说通过上面的方法,我们基本上可以确定,这两个地址就是真正的行列变量地址。但是,为了严谨,我们还是按照寻找存储地雷数量的内存地址的方法再试探一下,如下图:
image-15.png

图15:修改第一处内存中的值

image-16.png

图16:刷新扫雷程序的界面

image-17.png

图17:修改第二处内存中的值

image-18.png

图18:刷新扫雷程序的界面

由上面4图可知,我们刚才通过变量存储地址相关性的方法找到的行列变量的内存地址确实是真实的正确地址。

(4)扫描雷区内存地址范围

单击 CE“新的扫描”按钮,选择数据类型为 Byte,扫描类型为“未知的初始数值”,然后进行初次扫描。

左键单击雷区左上角第一个格子,然后将 CE 扫描类型改为“变动的数值”,再单击 CE“再次扫描”按钮。开启新一局游戏,重复这样的操作,直至左 侧地址(被标注为绿色的地址)减少为个位数。

重复操作过程中要理解何为“变动的数值”,即只要左上角格子被翻开或者开始新局时被覆盖,雷区内存地址的第一个字节存储的内容都发生了改变,因此需要选择“变动的数值”进行一次扫描。

在扫描得到的多组地址中,依次通过右键“浏览相关内存区域”,打开对应的内存空间,然后通过开始新一局游戏和点击雷区左上角的操作,确定雷区真实的内存起始地址,以及地雷和非地雷的标识,内存地址为:0x1005361,地雷标识数据为:0x8F,非地雷标识数据为:0x0F。

最后通过创建新一局游戏,依次点击第 1、2、3 行最左侧格子的操作确定 雷区每行的起始地址,寻找起始地址与雷区行和列之间的对应关系,即确定每行格子的数量,为:当前雷区的列数,同时内存中不是连续存储着雷区的格子对应值的,而是以32字节为限,也就是说即使列数不足32列,下一行的第一个格子的内存地址还是本行第一个格子地址+32,而程序是通过0x10来分割的,当前列的最后一个格子后面是0x10,而下一行的第一个格子前面是0x10 。
image-19.png

图19:以无初始值开始第一次扫描

image-20.png

图20:点击雷区左上角后使用变化数值类型扫描

image-21.png

图21:反复刷新扫描至只有个位数目标地址

image-22.png

图22:查看最后一个内存处在左上角格子改变后的变化

在分别查看最后的两个地址中的值是否会随着左上角第一个格子的变化而变化时,由图22可以发现,倒数第二个地址的值其实就是右上角显示的游戏时间,而通过和我们上面找到的游戏计时的内存地址相比较,发现确实是同一个地址。

然后查看最后一个内存地址的值,发现在将雷区恢复成初始状态之后,它的值就是0,而点击任意一个格子之后,它的值就变成了被揭开的格子数,像是图22那种情况,该内存地区的值就改为159。
image-23.png

图23:查看第一个内存处在格子改变后的变化

image-24.png

图24:查看第二个内存处在格子改变后的变化

经过多次点击格子后又恢复并查看内存处对应位置的值之后,可以发现第一个内存地址处的值,在正常的一局游戏当中,最多会变化两次,第一次就是在我们点击任意格子开始游戏的时候,它的值会变成1,而当我们点到炸弹或者刷新界面的时候,它的值又会变成0。除此之外,不管左上角第一个格子是什么情况,揭开还是没揭开,是安全格子还是炸弹,它的值都不会因此而改变。所以我们完全可以推测,这个内存地址的值就是一个游戏开始或者结束的标志,也许右上角的时间函数在自增之前还会参考这里的值,但是我们不关心这个,可以去看第二个内存地址处的值了。

通过多次的测试,我们可以将第二个内存处以及之后的内存区域的值都搞清楚了。正常的安全格子在未被掀开之前都是0x0F。而炸弹格子在未被掀开之前都是0x8F。而安全格子被掀开之后,如果以它为中心的九宫格之内都没有炸弹的话,它的值就是0x40。如果有炸弹的话,有多少个炸弹,它的值就在0x40的基础上加多少。然后是炸弹,通过测试可以发现,如果我们刻意在第一个格子就点击炸弹的话,是不会触发炸弹的,而左上角第一个格子如果是安全格子的话,就会被改成炸弹,但是我没有测试如果左上角第一个格子本身就是炸弹的话,新生成的炸弹会在哪,不过我猜测应该会顺延到第二个格子,并以此类推。至于怎么判断这是不是第一次掀开格子点到了炸弹,我觉得应该和第一个内存处存储的游戏开始与否标志位有关。

然后是游戏中途点击到炸弹,被点击的格子对应的内存处的值会变成0xCC,而其它炸弹处的值会变成0x8A。这里猜测是为了显示炸弹,毕竟炸弹触发之后,所有的炸弹都会显示出来,而我们点击到的那个炸弹更特殊,有个红色背景。

而内存中,雷区的各行不是连续存储的,而是每一行稳定占据32字节。但是实际上由于列数不固定,而且最多只有30列,所以基本上每一行是占不满这32个字节的。而程序是怎么知道有没有遍历到最后一列的呢?答案是“分隔符”,在雷区的内存中,每一行的最后一列对应的地址 +1 处都是0x10,而下一行的第一列对应的地址 -1 处也是0x10。

(7)确定扫雷游戏格子大小

打开 Spy++,单击“监视--日志消息”,勾选“隐藏 Spy++选项”,拖动“查找程序工具”至扫雷游戏主界面,如图 5-4 所示,显示的内容包括扫雷窗口类

(WNCLASS)的名称以及标题名。

切换至 Spy++的“消息”窗口,先清除所有消息,然后勾选 “WM_LBUTTONDOWN 和 WM_LBUTTONUP”两个消息,如图 5-5 所示,单击确定后返回 Spy++主界面。

返回扫雷游戏界面,依次单击左上角、紧邻左上角右方、紧邻左上角下方的三个格子,查看 Spy++记录的鼠标单击时的 xPos 和 yPos 的值,以此计算每个格子的宽度和高度。

在 Spy++界面,右键--清除消息日志,然后重复上述操作,也可间隔数个格子单击,以此计算每个格子的平均值,作为每个格子的宽和高度值。格子的宽度为:16;格子的高度为:16。
image-25.png

图25:使用spy++查看扫雷程序

image-26.png

图26:监视鼠标点击和抬起消息

image-27.png

图27:使用spy++获得鼠标位置计算格子宽高

由图27可得到分别点击左上角格子,右上角格子以及左下角格子的鼠标坐标,根据这些数据和行数及列数就可以得到每个小格子的宽和高。
image-28.png

图28:使用作弊软件通关扫雷

到图27为止,我们所需的数据就全部获取完毕,可以开始补全代码了。首先按照实验提供的代码来看,第一步是要先找到扫雷程序的窗口,获取句柄。然后通过spy++拖动“查找程序工具”至扫雷游戏主界面,就能找到caption是扫雷两个字。那我们FindWindow函数的第二个参数就找到了。不过这里有一个问题,就是编译成程序之后,由于编码问题,程序一直找不到扫雷程序的窗口。一开始不知道是编码问题,疯狂检查代码和spy++的结果也没辙。知道是编码问题之后,尝试将函数改成宽字符函数,ANISC函数等。不过还没有全部尝试完,就改成使用vs的windows桌面应用程序项目编译,这回就可以正常检测到窗口了了。

然后是从内存中读取宽高等数据,这个要用到的内存地址通过前面的实验步骤,就已经全部获得了,现在分别填入即可。然后关于要用到的进程句柄,通过观察代码可知,程序是通过第一步获得的窗口句柄再找到进程ID,再通过进程ID找到的进程句柄。然后按照函数原型,填入相应的数据即可。

然后是data数组,通过扫雷程序的自定义设置,我们可以发现高度最多是24,而宽度最多是30。所以直接按照极限情况定义data数组即可。不过这样定义就有一个问题,那就是没办法按照源程序给的那样直接将雷区对应的内存全部读取到data数组了。前面分析过,雷区对应的内存,每一行是固定32个字节的,但是我们刚才那样是给每一行最多分配了30个字节的空间。所以,我们得改成逐行读取,也就是通过双重for循环的方式只将必要的每一行真正的内存值读入到data数组中。当然,这里有两种判断每一行读取终止的方法。第一种就是根据刚才读取的列数来作为边界条件,第二种就是根据是否读取到了0x10来判断。但是很明显,第一种方便一点,所以这里用的是第一种的方法。然后这里还有一个要注意的地方就是,每一行第一个格子内存地址的存储位置,也要按照每一行固定32个字节的规律来计算。

然后就是最后一部分,模拟鼠标点击,这里我把鼠标点击的部分单独用函数实现了一下。首先还是双重循环,遍历每一个格子,然后根据刚才在data数组中存入的值判断当前格子是应该左键还是右键点击。至于鼠标位置的确定,之前我们用spy++监控鼠标点击和释放的位置的时候,就能够得到左上角第一个格子的位置,以及格子的宽度了。这样的话,我们就能计算出每一个格子的位置。然后传给模拟鼠标点击函数。然后关于这个函数,最重要的一步就是将刚才计算得到的格子在扫雷程序中的位置,转换成在整个屏幕上的位置。知道了这点之后,就可以通过系统函数来进行转换了。转换完之后就是最后一步,根据之前data数组中元素的比较结果,决定当前位置是左键点击还是右键点击。自此,整个程序就完成了。

脚本代码

#include <windows.h>
#include <vector>
const DWORD ADDR_MINE_COUNT   = 0x10056a4;
const DWORD ADDR_BOARD_WIDTH  = 0x10056ac;
const DWORD ADDR_BOARD_HEIGHT = 0x10056a8;
const DWORD ADDR_BOARD_FIRST  = 0x1005361;
const DWORD ADDR_GAME_STATUS  = 0x1005794;
const int   BOARD_ROW_STRIDE  = 32;
const BYTE  CELL_NORMAL       = 0x0f;
const BYTE  CELL_MINE         = 0x8f;
const int   CELL_WIDTH        = 16;
const int   CELL_HEIGHT       = 16;
const int   FIRST_CELL_X      = 21;
const int   FIRST_CELL_Y      = 63;
void ClickClientPoint(HWND hwnd, int clientX, int clientY, BOOL rightButton)
{
POINT pos;
pos.x = clientX;
pos.y = clientY;
ClientToScreen(hwnd, &pos);
SetCursorPos(pos.x, pos.y);
Sleep(10);
if(rightButton)
{
mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
}
else
{
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
}
}
int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
HWND hwnd = FindWindow(NULL, TEXT("扫雷"));
if(hwnd == NULL)
{
MessageBox(NULL, TEXT("Winmine is not found!"), TEXT("AutoMineSweeper"), MB_ICONSTOP);
return 1;
}
DWORD pid;
GetWindowThreadProcessId(hwnd, &pid);
HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
int width, height;
ReadProcessMemory(handle, (LPCVOID)ADDR_BOARD_WIDTH, &width, sizeof(int), NULL);
ReadProcessMemory(handle, (LPCVOID)ADDR_BOARD_HEIGHT, &height, sizeof(int), NULL);
BYTE data[24][30] = {0};
for(int y = 0; y < height; y++)
{
for(int x = 0; x < width; x++)
{
DWORD addr = ADDR_BOARD_FIRST + y * 32 + x;
ReadProcessMemory(handle, (LPCVOID)addr, &data[y][x], sizeof(BYTE), NULL);
}
}
ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
Sleep(300);
for(int y = 0; y < height; y++)
{
for(int x = 0; x < width; x++)
{
int clientX = FIRST_CELL_X + x * CELL_WIDTH;
int clientY = FIRST_CELL_Y + y * CELL_HEIGHT;
if(data[y][x] == CELL_MINE)
{
ClickClientPoint(hwnd, clientX, clientY, TRUE);
}
else if(data[y][x] == CELL_NORMAL)
{
ClickClientPoint(hwnd, clientX, clientY, FALSE);
}
}
}
CloseHandle(handle);
return 0;
}