声明:本文仅限于技术讨论与分享,严禁用于非法途径。若读者因此作出任何危害网络安全行为后果自负,与本号及原作者无关。
前言
学习任何技术,设定一个清晰的目标至关重要。对于 Windows 游戏安全入门而言,经典的扫雷游戏无疑是一个绝佳的起点。尽管它已经被无数人分析透彻,但亲自动手探索一番,仍然是深入理解内存修改与注入技术的最佳实践。我们可以将目标定为编写一个具备“时间暂停”、“透视”、“一键扫雷”等功能的辅助工具。
本文将使用 Cheat Engine、x32dbg(或 ollydbg)以及 Visual Studio 2019 作为主要工具,通过分析扫雷游戏,一步步完成这个“妙妙小工具”。若你在学习过程中遇到难题,欢迎到 云栈社区 与众多开发者一同交流探讨。
扫雷游戏分析
游戏数据在内存中表现为具体的地址,因此我们的首要任务是定位这些关键地址。首先打开 Cheat Engine 修改器。
修改时间 -> 时间暂停
扫雷计时器显示的是一个精确数值,因此我们可以使用“精确数值”扫描。游戏开始前计时器为0,所以我们首先扫描0。

让时间变化后,选择“值介于...两者之间”类型再次扫描,并输入一个大概范围(例如10到20)。

最终可以定位到时间的内存地址为 0x100579c,在CE中显示为 winmine.exe+579C。

我们发现这个数据可以直接通过基址加固定偏移访问。接着,我们对该地址使用“找出是什么改写了这个地址”功能,可以得到一条具体的汇编指令及其地址。

时间地址:0x100579c
修改表情 - 状态分析
修改游戏中央的笑脸表情,实际上是控制游戏状态(重新开始、进行中、胜利、失败)。分析发现,表情状态由两个变量(4字节)控制,它们的状态值在0和1之间切换。
首先,在游戏进行时,扫描数值 1。

然后点击表情按钮重新开始游戏,此时状态改变,扫描数值 0。

重复此过程,可以定位到其中一个表情控制地址:0x1005164 (winmine.exe+5164)。

但将其修改为2或3,表情并无新变化。这说明控制胜利和失败状态的可能是其他地址。通常,相关功能的代码和变量在内存中是连续存放的。因此,我们可以浏览该地址附近的内存区域。

观察内存数据,尝试修改 0x1005164 - 4 = 0x1005160 处的值为3,发现表情变成了戴墨镜的胜利状态。

但请注意,这只是改变了显示状态,并不意味着游戏真正通关。 我们可以通过 Cheat Engine 查看该地址的各种数值解释。

表情控制地址:0x1005160 与 0x1005164
透视 - 显示雷区
思考:游戏结束时(踩雷或胜利)会自动显示所有雷的位置。因此,我们可以通过动态调试,观察是哪个函数调用后触发了这一显示逻辑。
首先在程序入口点 (EntryPoint) 下断点,然后单步跟踪。

通过多次调试和观察程序流程(例如跟踪消息循环)。

最终发现,函数 0x2F80 是负责处理“踩雷”并显示所有地雷的关键函数。

一键扫雷
在拥有“透视”功能后,手动完成一局游戏直至胜利(点开最后一个非雷格子),并在此过程中通过调试器观察胜利时的函数调用链。
首先,在游戏即将胜利时,观察函数调用。

继续跟踪,会发现后续调用了判断和记录相关的函数。

后续的函数调用与刷新英雄榜和显示记录有关。


最终,函数返回到调用点。通过分析可以确定,函数 0x347c 是判断游戏输赢的核心函数。调试发现它由一个参数(0或1)控制结果,这与“透视”功能的实现思路类似,可通过创建带参数的远程线程来调用。

编写妙妙小工具
如何实现这个工具?我们选择使用 DLL注入 的方式。而注入方法,这里采用经典的 远程线程注入。
前置知识-动态调用DLL与远程线程注入
主要涉及以下几个 Windows API:
LoadLibraryA
加载指定的 DLL 文件并返回其模块句柄。参数是表示 DLL 路径的字符串。
GetProcAddress
获取指定 DLL 中导出函数的地址。第一个参数是模块句柄,第二个参数是函数名,返回值为函数地址。
通过这两个函数,我们可以动态获取并调用任何导出函数。
CreateRemoteThread - 远程线程注入核心
CreateRemoteThread 函数可以在另一个进程(目标进程)的虚拟地址空间中创建一个线程。我们可以让这个新线程执行目标进程中的代码,例如执行 LoadLibraryA 来加载我们的 DLL。
DLL 的主入口点 DllMain 会在特定事件时被调用:
// dllmain.cpp : 定义 DLL 应用程序的入口点。
#include "pch.h"
BOOL APIENTRY DllMain( HMODULE hModule,//指向自身的句柄
DWORD ul_reason_for_call,//调用原因
LPVOID lpReserved//隐式加载or显式加载
)
{
switch(ul_reason_for_call)
{
case DLL_PROCESS_ATTACH://附加到进程上时执行
case DLL_THREAD_ATTACH://附加到线程上时执行
case DLL_THREAD_DETACH://从线程上剥离时执行
case DLL_PROCESS_DETACH://从进程上剥离时执行
break;
}
return TRUE;
}
我们可以在 DLL_PROCESS_ATTACH 分支中编写代码,当 DLL 被加载到目标进程时,这些代码就会自动执行。
远程线程注入的基本步骤是:
- 打开目标进程,获取进程句柄。
- 在目标进程内分配一块可读可写的内存。
- 将我们的 DLL 的完整路径字符串写入这块内存。
- 使用
CreateRemoteThread 创建远程线程,线程函数指向 LoadLibraryA,参数指向刚才写入的 DLL 路径地址。
- 等待远程线程结束(即
LoadLibrary 返回)。
- 释放目标进程中申请的内存。
- 关闭打开的句柄。
注入Demo示例:
void Inject(DWORD ProcessId, const char* szPath)
{
//1.打开目标进程获取句柄
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessId);
printf("进程句柄:%p\n", hProcess);
//2.在目标进程体内申请空间
LPVOID lpAddress = VirtualAllocEx(hProcess, NULL, 0x100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
//3.写入DLL路径
SIZE_T dwWriteLength = 0;
WriteProcessMemory(hProcess, lpAddress, szPath, strlen(szPath), &dwWriteLength);
//4.创建远程线程,回调函数使用 LoadLibrary 加载指定 dll
HANDLE hThread = CreateRemoteThread(hProcess, NULL, NULL, (LPTHREAD_START_ROUTINE)LoadLibraryA, lpAddress, NULL, NULL);
//5.等待返回(loadLibrary返回)
WaitForSingleObject(hThread, -1);
//6.释放空间
VirtualFreeEx(hProcess, lpAddress, 0, MEM_RELEASE);
//7.释放句柄
CloseHandle(hProcess);
CloseHandle(hThread);
//返回结果
AfxMessageBox(L"完成");
}
编写DLL注入器(Loader)
注入器是一个独立的控制台程序,负责查找扫雷进程并将我们的 DLL 注入进去。
#include<windows.h>
#include<stdio.h>
#include<time.h>
#include<stdlib.h>
#include<TlHelp32.h>
DWORD FindProcess()
{
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe32;
pe32 = { sizeof(pe32) };
BOOL ret = Process32First(hSnap, &pe32);
while (ret)
{
if (!wcsncmp(pe32.szExeFile, L"winmine.exe", 11)) {
printf("Find winmine.exe Process %d\n", pe32.th32ProcessID);
return pe32.th32ProcessID;
}
ret = Process32Next(hSnap, &pe32);
}
return 0;
}
void Inject(DWORD ProcessId, const char* szPath)
{
//1.打开目标进程获取句柄
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, ProcessId);
printf("进程句柄:%p\n", hProcess);
//2.在目标进程体内申请空间
LPVOID lpAddress = VirtualAllocEx(hProcess, NULL, 0x100, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
//3.写入DLL路径
SIZE_T dwWriteLength = 0;
WriteProcessMemory(hProcess, lpAddress, szPath, strlen(szPath), &dwWriteLength);
//4.创建远程线程,回调函数使用 LoadLibrary 加载指定 dll
HANDLE hThread = CreateRemoteThread(hProcess, NULL, NULL, (LPTHREAD_START_ROUTINE)LoadLibraryA, lpAddress, NULL, NULL);
//5.等待返回(loadLibrary返回)
WaitForSingleObject(hThread, -1);
//6.释放空间
VirtualFreeEx(hProcess, lpAddress, 0, MEM_RELEASE);
//7.释放句柄
CloseHandle(hProcess);
CloseHandle(hThread);
}
int main()
{
DWORD ProcessId = FindProcess();
while (!ProcessId) {
printf("未找到扫雷程序,等待两秒再试\n");
Sleep(2000);
ProcessId = FindProcess();
}
printf("开始注入进程...\n");
Inject(ProcessId, "E:\\CODE\\wimine\\Mine\\release\\Mine.dll");
printf("注入完毕\n");
}
编写功能DLL
我们使用 MFC DLL 基于对话框(Dialog)的方式编写,并采用静态编译,便于分发。


在资源视图中新建一个 Dialog,设计简单的工具界面。

我们需要在DLL被加载时创建并显示这个对话框。为了避免阻塞 DllMain,我们创建一个新线程来负责界面。
DWORD WINAPI DlgThreadCallBack(LPVOID lp) {
MineDlg* Dlg;
Dlg = new MineDlg();
Dlg->DoModal();
delete Dlg;
FreeLibraryAndExitThread(theApp.m_hInstance, 1);
return 0;
}
// CMineApp 初始化
BOOL CMineApp::InitInstance()
{
CWinApp::InitInstance();
::CreateThread(NULL, NULL, DlgThreadCallBack, NULL, NULL, NULL);
return TRUE;
}
时间暂停
之前我们找到了使时间增加的汇编指令 inc [winmine.exe+579C]。通过将该指令替换为 nop(空指令),即可实现时间暂停。

编写“时间暂停”和“取消暂停”按钮的事件处理函数:
DWORD GetBaseAddr() {
HMODULE hMode = GetModuleHandle(nullptr);
return (DWORD)hMode;
}
void MineDlg::OnBnClickedButton1() // 时间暂停
{
// TODO: 在此添加控件通知处理程序代码
auto BaseAddr = GetBaseAddr();
DWORD TimeOffset = 0x579C;
DWORD TimeInsOffset = 0x2FF5;
DWORD InsLen = 6;
DWORD old;
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, PAGE_EXECUTE_READWRITE, &old);
BYTE INS[] = { 0x90, 0x90, 0x90, 0x90, 0x90, 0x90 }; // nop nop nop nop nop nop
memcpy((void*)(BaseAddr + TimeInsOffset), INS, InsLen);
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, old, &old);
}
void MineDlg::OnBnClickedButton2() // 恢复字节即可取消时间暂停
{
// TODO: 在此添加控件通知处理程序代码
auto BaseAddr = GetBaseAddr();
DWORD TimeOffset = 0x579C;
DWORD TimeInsOffset = 0x2FF5;
DWORD InsLen = 6;
DWORD old;
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, PAGE_EXECUTE_READWRITE, &old);
BYTE INS[] = { 0xFF, 0x05, 0x9C, 0x57, 0x00, 0x01 }; // 原指令 inc [winmine.exe+579C]
memcpy((void*)(BaseAddr + TimeInsOffset), INS, 6);
VirtualProtect((void*)(BaseAddr + TimeInsOffset), InsLen, old, &old);
}
测试效果:

透视
根据之前的动态调试,函数 0x2F80 是踩雷后显示所有地雷的函数。我们在线程中直接调用这个函数即可实现“透视”效果。这正是 逆向工程 中常用的直接调用内部函数的方法。
void MineDlg::OnBnClickedButton3()
{
// TODO: 在此添加控件通知处理程序代码
DWORD ESPOffset = 0x2f80;
DWORD FuncAddr = GetBaseAddr() + ESPOffset;
// 创建不带参数的线程
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)FuncAddr, NULL, 0, NULL);
}
测试效果(红框内为透视出的地雷):

一键扫雷
与“透视”类似,调用判断输赢的函数 0x347c,并通过参数控制其为“胜利”状态。这里涉及到创建带参数的 远程线程注入。
void MineDlg::OnBnClickedButton4()
{
// TODO: 在此添加控件通知处理程序代码
DWORD ESPOffset = 0x347C;
DWORD FuncAddr = GetBaseAddr() + ESPOffset;
//创建带参数的线程
struct { int a; } s = { 0 }; // 参数为0可能表示胜利
CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)FuncAddr, &s, NULL, NULL);
}
测试效果(触发游戏胜利并破纪录):

总结
通过这个完整的“扫雷妙妙工具”项目,我们对 Windows 游戏安全有了初步的实战认识。整个过程强化了软件的逆向思维和动态调试能力:从使用 Cheat Engine 定位关键内存地址和分析数据变化,到使用调试器跟踪程序执行流程、定位关键函数;从理解游戏状态的内存表示,到最终通过远程线程注入技术,将我们的代码植入目标进程并修改其行为。
我们实践了几个关键的 Windows API:OpenProcess、VirtualAllocEx/WriteProcessMemory、CreateRemoteThread、LoadLibrary 和 GetProcAddress 等。这些是 Windows 系统编程和 安全 领域的核心知识。学习之路,贵在实践。多写代码,多动手调试,多尝试分析,方能深入理解。共勉。
本文首发于微信公众号,版权归原作者所有。
原文链接:https://mp.weixin.qq.com/s?__biz=MzkxNTIwNTkyNg==&mid=2247549615&idx=1&sn=5de0fec4a85adc4c45c6864eec2c5c56&scene=21#wechat_redirect