软件安全实验-游戏外挂设计与防范

软件安全实验-游戏外挂设计与防范

【实验目的】

本实验通过分析Windows XP附带的扫雷游戏,实现自动扫雷的外挂程序。一方面了解内存外挂的相关技术,包括通过CE工具定位关键内存地址、读写进程内存数据、模拟鼠标操作等;另一方面分析防范此类外挂的方法,增强对游戏安全防护机制的理解。

【实验原理】

内存读写。 Windows操作系统为每个进程分配独立的虚拟地址空间,扫雷游戏的雷区数据、地雷数量、行列数、计时器等关键变量都存储在进程的内存空间中。通过Windows API函数OpenProcess获取目标进程句柄后,可以使用ReadProcessMemory函数读取进程内存中的任意地址数据,从而获取雷区的完整信息。

CheatEngine工具。 CheatEngine(CE)是一款内存扫描工具,通过反复扫描目标进程中变化或固定的数值,可以定位到存储特定数据的内存地址。在本实验中,使用CE分别定位了地雷数量、游戏计时器、雷区行列数以及雷区首地址等关键内存位置。

雷区数据结构。 扫雷游戏的雷区在内存中以二维数组形式存储,每个格子用一个字节(Byte)标识。未点开的格子值为0x0F,未点开的地雷为0x8F,空格为0x40,地雷数量标识为0x41~0x46等。通过读取雷区内存数据,可以判断每个格子是否为地雷。

鼠标模拟。 Windows API提供了mouse_eventSetCursorPos函数,可以模拟鼠标的移动和点击操作。通过将鼠标移动到扫雷游戏界面中对应格子的位置,模拟左键或右键单击,即可实现自动翻开或标记地雷的操作。

坐标转换。 Spy++记录的鼠标坐标以扫雷游戏窗口左上角为原点,而SetCursorPos以桌面左上角为原点。因此需要使用GetClientRect获取客户区信息,并通过ClientToScreen函数进行坐标系转换,将客户坐标转换为屏幕坐标。

Step 1 使用CheatEngine定位内存地址

(1)加载游戏进程

运行扫雷游戏,打开CheatEngine(CE),在CE界面中点击"选择进程"按钮,找到扫雷游戏的进程并加载。此时CE可以对该进程的内存空间进行扫描和读取操作。

(2)定位地雷数量的内存地址

在CE界面选择数据类型为4字节,扫描类型为"精确数值",数值内容填写扫雷游戏所显示的地雷数量(如10),然后单击"初次扫描"。随后调整扫雷游戏难度等级,使地雷数量发生改变,在CE界面填写新的地雷数量,单击"再次扫描"。不断重复此操作,直至CE左侧找到的地址数降为个位数。

最终定位到的地雷数量内存地址为:0x1005194

(3)定位游戏计时器的内存地址

单击"新的扫描"按钮,选择数据类型为4字节,扫描类型为"精确数值",数值内容填写0,然后单击初次扫描。点开雷区任意一个格子使游戏开始计时,在CE界面将扫描类型改为"增加的数值",然后单击"再次扫描"。计时增加后重复操作,或计时增加到较高数值后开始新的一局,将扫描类型改为"减少的数值"再次扫描。

最终定位到的游戏计时器内存地址为:0x100579C

(4)定位雷区行列数的内存地址

单击"新的扫描"按钮,选择数据类型为4字节,扫描类型为"精确数值",数值内容填写雷区的行数(如初级为9),然后单击初次扫描。在扫雷程序中单击"游戏–自定义"设置新的高度和宽度值,在CE界面填写新行或列的数值,单击"再次扫描"。不断重复此操作,直至地址数减少为个位数。

最终定位到的内存地址为:

  • 存储雷区宽度的地址:0x1005334

  • 存储雷区高度的地址:0x1005338

CheatEngine打开游戏进程并定位关键内存地址

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

通过反复扫描定位雷区的内存地址

单击CE"新的扫描"按钮,选择数据类型为Byte,扫描类型为"未知的初始数值",然后进行初次扫描。左键单击雷区左上角第一个格子,然后将扫描类型改为"变动的数值",再单击"再次扫描"。开启新一局游戏,重复此操作,直至左侧被标注为绿色的地址减少为个位数。

在扫描得到的多组地址中,通过右键"浏览相关内存区域",打开对应的内存空间,通过开始新一局游戏和点击雷区左上角的操作,确定雷区真实的内存起始地址以及各类标识数据。

Step 2 确定内存标识数据

确认不同格子的数据含义

(1)地雷标识数据

通过浏览相关内存区域并观察不同操作后的数据变化,确定地雷相关的标识数据如下:

  • 未爆炸地雷:0x8F

  • 爆炸地雷:0x8A

  • 起爆点:0xCC

  • 标上旗子:0x8E

  • 标上问号:0x8D

(2)非地雷标识数据

非地雷格子的标识数据如下:

  • 未点开:0x0F

  • 空格(周围无地雷):0x40

  • 地雷个数(周围有1~8个地雷):0x410x420x430x440x450x46……

  • 标旗:0x0E

  • 标问号:0x0D

(3)雷区每行格子数

通过创建新一局游戏,依次点击第1、2、3行最左侧格子的操作,确定雷区每行的起始地址,寻找起始地址与雷区行列之间的对应关系。每行格子的内存偏移为32个字节(即每行占32个Byte),雷区首地址从0x1005340开始,第\(i\)行第\(j\)列的格子在内存数组中的索引为\((i+1) \times 32 + (j+1)\)

Step 3 使用Spy++确定格子大小

(1)获取窗口信息

打开Spy++,单击"监视–日志消息",勾选"隐藏Spy++选项",拖动"查找程序工具"至扫雷游戏主界面,获取扫雷窗口类(WNDCLASS)的名称以及标题名。

Spy++监视扫雷游戏窗口

(2)记录鼠标消息坐标

切换至Spy++的"消息"窗口,先清除所有消息,然后勾选"WM_LBUTTONDOWN和WM_LBUTTONUP"两个消息。返回扫雷游戏界面,依次单击左上角、紧邻左上角右方、紧邻左上角下方的三个格子,查看Spy++记录的鼠标单击时的xPos和yPos的值。

Spy++监视扫雷消息

(3)计算格子大小

根据Spy++记录的坐标数据,计算格子的平均宽和高度:

  • 左上方第一个格子坐标:\((x=18, y=62)\)

  • 左上方右一个格子坐标:\((x=36, y=61)\)

  • 左上方下一个格子坐标:\((x=20, y=79)\)

水平方向:\(36 - 18 = 18\)像素,取整后约为16像素(考虑点击位置偏移);垂直方向:\(79 - 62 = 17\)像素,取整后约为16像素。对于初级难度(\(9 \times 9\))的进一步验证:

  • 左上方坐标:\((x=19, y=61)\)

  • 右上方坐标:\((x=151, y=60)\)

  • 左下方坐标:\((x=20, y=191)\)

  • 右下方坐标:\((x=148, y=192)\)

水平范围:\(151 - 19 = 132\)像素,每格宽度约为\(132 \div (9-1) \approx 16\)像素;垂直范围:\(191 - 61 = 130\)像素,每格高度约为\(130 \div (9-1) \approx 16\)像素。

最终确定:格子宽度为16像素格子高度为16像素

Step 4 编程实现自动扫雷

(1)实现逻辑

自动扫雷程序的实现逻辑如下:

  1. 创建WIN32空项目,编写源代码;

  2. 通过FindWindow获取扫雷游戏窗口句柄,使用GetWindowThreadProcessId获取进程ID,再通过OpenProcess获取进程句柄;

  3. 根据雷区宽度(地址0x1005334)和高度(地址0x1005338)所在内存地址,使用ReadProcessMemory读取行和列的值;

  4. 根据雷区首地址0x1005340,读取完整的雷区数据;

  5. 将扫雷游戏界面置顶(SetForegroundWindow),遍历雷区第\(i\)行第\(j\)列的数值,判断是否为地雷:若为0x8F(未爆炸地雷)则模拟右键单击标记旗子;若为0x0F(未点开非地雷)则模拟左键单击翻开;

  6. 根据扫雷游戏界面的客户坐标系,计算第\(i\)行第\(j\)列的坐标位置(起始坐标\(x=18, y=62\),步长\(16\)像素),通过ClientToScreen转换为屏幕坐标,然后移动鼠标并执行鼠标操作;

  7. 重复上述过程,直至雷区数据全部被扫描完成。

(2)关键API函数

本实验使用的核心Windows API函数包括:

读写内存:

1
2
BOOL ReadProcessMemory(HANDLE hProcess, LPCVOID lpBaseAddress,
LPVOID lpBuffer, SIZE_T nSize, SIZE_T *lpNumberOfBytesRead);

获取进程句柄:

1
2
3
HANDLE OpenProcess(DWORD dwDesiredAccess, BOOL bInheritHandle,
DWORD dwProcessId);
DWORD GetWindowThreadProcessId(HWND hWnd, LPDWORD lpdwProcessId);

窗口操作:

1
2
HWND FindWindowA(LPCSTR lpClassName, LPCSTR lpWindowName);
BOOL SetForegroundWindow(HWND hWnd);

鼠标模拟:

1
2
3
void mouse_event(DWORD dwFlags, DWORD dx, DWORD dy,
DWORD dwData, ULONG_PTR dwExtraInfo);
BOOL SetCursorPos(int X, int Y);

其中mouse_event函数的dwFlags参数取值为:MOUSEEVENTF_LEFTDOWN(左键按下)、MOUSEEVENTF_LEFTUP(左键弹起)、MOUSEEVENTF_RIGHTDOWN(右键按下)、MOUSEEVENTF_RIGHTUP(右键弹起)。

Step 5 运行结果

编译并运行自动扫雷程序,程序首先获取扫雷游戏窗口并读取雷区数据,然后将扫雷窗口置顶,逐格遍历雷区。对于每个格子,程序根据内存中读取的标识值判断其类型:若为未爆炸地雷(0x8F),则模拟右键单击标记旗子;若为未点开的非地雷格子(0x0F),则模拟左键单击翻开。程序能够在极短时间内完成整个雷区的扫描和操作,实现了自动扫雷功能。

自动扫雷外挂成功通关高级难度

自动扫雷外挂成功通关最高难度

【思考与总结】

(1)除了使用CE得到各项地址外,还有哪些方法可以得到这些地址?

除了CheatEngine之外,获取内存地址的方法还包括:

**调试器。**使用OllyDbg、x64dbg等调试工具附加到扫雷进程,通过反汇编和断点跟踪,可以找到读写相关内存位置的指令,从而逆向出关键变量的地址。例如在反汇编窗口中搜索访问雷区数据的mov指令,即可定位雷区首地址。

**内存特征码扫描。**通过分析扫雷程序的可执行文件,提取其中的特征码,可以在运行时通过模式匹配定位关键数据结构的地址。

**进程内存转储。**使用工具(如Process Hacker、VMMap等)将扫雷进程的内存空间导出为文件,然后在文件中搜索特定的数据模式,找到雷区等数据结构的位置。

**API Hook。**拦截扫雷程序对Windows API的调用(如GetDlgItemInt等),可以观察程序读取了哪些内存地址的数据。

(2)除了通过模拟鼠标按键操作之外,还有哪些方法能够实现自动扫雷?

**直接修改内存。**使用WriteProcessMemory直接修改雷区内存数据,将所有未翻开的非地雷格子的值从0x0F改为空格值0x40,游戏会自动显示翻开后的效果,无需模拟鼠标操作。如下图所示,成功修改了游戏的内存区域,代码见附录。

直接修改雷区内存空间

**修改游戏逻辑。**通过修改扫雷程序的代码段(Code Patching),改变游戏的判断逻辑。例如将判断是否为地雷的跳转指令修改为永远不跳转,这样即使点击到地雷也不会爆炸。

**注入DLL。**将自定义的DLL注入到扫雷进程的地址空间中,直接在进程内部调用游戏自身的函数来实现翻格操作,这样可以绕过一些基于外部进程检测的防护机制。

**发送窗口消息。**使用SendMessagePostMessage向扫雷窗口发送WM_LBUTTONDOWNWM_LBUTTONUP消息,可以模拟鼠标点击而无需实际移动鼠标光标。

(3)如何防范内存型外挂?

内存型外挂的核心在于通过读写进程内存来获取或篡改游戏数据,防范此类外挂可以从以下几个方面入手:

**数据加密。**将游戏中的关键数据(如雷区信息、角色属性等)在内存中进行加密存储。游戏运行时使用自定义的解密算法在寄存器中临时解密使用,使用完毕后立即重新加密。这样即使外挂程序读取了内存数据,得到的也是加密后的无效数据。可以采用异或加密、RC4流加密等轻量级加密方案。

**内存校验。**在游戏的关键数据区域设置校验和,游戏运行过程中定期对内存数据进行完整性校验。一旦检测到内存被非法修改(如通过CE直接修改),立即终止游戏或触发反作弊机制。

**反调试检测。**检测是否有调试器附加到游戏进程。常用的检测方法包括:调用IsDebuggerPresent函数、检查PEB中的调试标志位、检测硬件断点(检查DR寄存器)、检测调试端口等。

**代码混淆与反逆向。**对游戏的可执行代码进行混淆处理,增加逆向分析的难度。例如增加虚假分支、内联函数消除函数调用特征、使用花指令干扰反汇编器等。

**进程保护。**通过驱动级别的保护(如内核态反作弊驱动),限制外部进程对游戏进程的内存读写权限。例如BattlEye、Easy Anti-Cheat等商业反作弊系统就采用了驱动级别的进程保护机制,防止外部工具调用ReadProcessMemoryWriteProcessMemory

**服务器端验证。**对于网络游戏,将关键逻辑放在服务器端验证。客户端仅负责显示和输入,所有游戏状态的变更都由服务器判定和同步。这样即使客户端内存被篡改,也无法影响服务器端的游戏逻辑,从根本上杜绝了内存型外挂的威胁。

【附录-完整源代码】

自动扫雷程序(sweeper.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
#include <windows.h>

int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
HWND hwnd = FindWindow(TEXT("扫雷"), 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)0x1005334, &width, sizeof(int), NULL);
ReadProcessMemory(handle, (LPCVOID)0x1005338, &height, sizeof(int), NULL);

BYTE data[32 * 32] = { 0 };
ReadProcessMemory(handle, (LPCVOID)0x1005340, &data, sizeof(data), NULL);

ShowWindow(hwnd, SW_RESTORE);
SetForegroundWindow(hwnd);
Sleep(300);

int startX = 18;
int startY = 62;
int stepX = 16;
int stepY = 16;

for (int y = 0; y < height; y++)
for (int x = 0; x < width; x++)
{
int index = (y + 1) * 32 + (x + 1);
BYTE cellValue = data[index];

RECT rect;
GetClientRect(hwnd, &rect);
POINT curPos = { rect.left + startX + x * stepX, rect.top + startY + y * stepY };
ClientToScreen(hwnd, &curPos);

SetCursorPos(curPos.x, curPos.y);
Sleep(10);

if (cellValue == 0x8F)
{
mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0);
}
else if (cellValue == 0x0F)
{
mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0);
mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0);
}

}

CloseHandle(handle);
return 0;
}

直接修改内存版本(sweeper_write.cpp)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <windows.h>

// 判断一个格子是否为地雷(未爆炸地雷0x8F 或 标旗地雷0x8E)
BOOL IsMine(BYTE cell)
{
return (cell == 0x8F || cell == 0x8E);
}

// 计算雷区中第row行第col列格子周围的地雷数量
int CountAdjacentMines(BYTE data[32 * 32], int row, int col)
{
int count = 0;
for (int dy = -1; dy <= 1; dy++)
for (int dx = -1; dx <= 1; dx++)
{
if (dy == 0 && dx == 0) continue;
int ny = row + dy;
int nx = col + dx;
int idx = (ny + 1) * 32 + (nx + 1);
if (IsMine(data[idx]))
count++;
}
return count;
}

int WinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, LPSTR lpCmdLine, int nShowCmd)
{
HWND hwnd = FindWindow(TEXT("扫雷"), TEXT("扫雷"));

if (hwnd == NULL)
{
MessageBox(NULL, TEXT("Winmine is not found!"), TEXT("AutoMineSweeper_Write"), MB_ICONSTOP);
return 1;
}

DWORD pid;
GetWindowThreadProcessId(hwnd, &pid);
HANDLE handle = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);

// 读取雷区宽度和高度
int width, height;
ReadProcessMemory(handle, (LPCVOID)0x1005334, &width, sizeof(int), NULL);
ReadProcessMemory(handle, (LPCVOID)0x1005338, &height, sizeof(int), NULL);

// 读取雷区数据
BYTE data[32 * 32] = { 0 };
ReadProcessMemory(handle, (LPCVOID)0x1005340, &data, sizeof(data), NULL);

// 直接修改内存:遍历雷区
for (int y = 0; y < height; y++)
{
for (int x = 0; x < width; x++)
{
int index = (y + 1) * 32 + (x + 1);
BYTE cellValue = data[index];
BYTE newValue = cellValue;

if (cellValue == 0x8F)
{
// 未爆炸地雷 → 标旗
newValue = 0x8E;
}
else if (cellValue == 0x0F)
{
// 未翻开的非地雷 → 计算周围地雷数,写入对应的已翻开值
// 0x40 = 空格(0颗雷),0x41~0x48 = 数字1~8
int mines = CountAdjacentMines(data, y, x);
newValue = 0x40 + mines;
}

if (newValue != cellValue)
{
DWORD addr = 0x1005340 + index;
WriteProcessMemory(handle, (LPVOID)addr, &newValue, sizeof(BYTE), NULL);
}
}
}

// 修改剩余雷数计数器为0(所有地雷已标旗,游戏判定胜利)
int zero = 0;
WriteProcessMemory(handle, (LPVOID)0x1005194, &zero, sizeof(int), NULL);

// 刷新窗口显示
InvalidateRect(hwnd, NULL, TRUE);
UpdateWindow(hwnd);

CloseHandle(handle);
return 0;
}

软件安全实验-游戏外挂设计与防范
https://eleco.top/2026/06/18/软件安全实验-游戏外挂设计与防范/
作者
Eleco
发布于
2026年6月18日
许可协议