基于 ImGui+DLL 的动态修改器
前言
本项目(以《三国志14PK》为例)的目标就是实现这样一个系统。由三个核心部分组成:
- C++ DLL (
San14TrainerDll.dll): 运行在游戏进程中的“大脑”,负责挂钩(Hook)游戏函数和执行修改。 - C++ ImGui GUI (
TrainerLoader.exe): 一个完全独立的“脸面”,用户通过来控制“大脑”。 - IPC (命名管道): 连接“大脑”和“脸面”的“神经系统”。
核心原理
我们的起点是两个独立的进程:TrainerLoader.exe (我们的GUI) 和 SAN14PK.exe (游戏)。们各自拥有受保护的内存空间,无法直接通信。我们的目标,就是把我们自己的代码(San14TrainerDll.dll)“塞”进游戏进程里去运行。
这就是 DLL 注入。
1. 标准方式 (The “Easy Way”): CreateRemoteThread + LoadLibrary
这是最古老、最稳定、也是最容易被检测到的方法。
工作流程:
- [GUI]
OpenProcess(): 获取游戏进程的句柄 (HANDLE hProc)。 - [GUI]
VirtualAllocEx(): 在游戏进程的内存里申请一小块空间(比如 256 字节)。 - [GUI]
WriteProcessMemory(): 将的 DLL 的完整磁盘路径写入刚刚申请的那块内存。 - [GUI]
GetProcAddress(): 找到kernel32.dll中的LoadLibraryA函数的地址。(一个巧妙的技巧:kernel32.dll在所有进程中的加载基址都是一样的,所以在 GUI 进程中找到的地址在游戏进程中同样有效)。 - [GUI]
CreateRemoteThread(): 这是关键。命令游戏进程启动一个新线程,这个新线程的入口点就是LoadLibraryA的地址,线程的参数就是第3步写入的 DLL 路径字符串的地址。
结果:
游戏进程内部“凭空”出现了一个新线程,这个线程的任务就是执行 LoadLibraryA("D:\Trainer\San1tTrainerDll.dll")。
LoadLibrary 的优点:
当调用 LoadLibrary 时,Windows 操作系统加载器 (OS Loader) 会像“保姆”一样为处理好所有复杂工作:
- 依赖加载: 会检查 DLL 的导入表,如果的 DLL 依赖
PolyHook_2.dll,加载器会自动去查找并加载。 - 基址重定位 (Relocations): 如果 DLL 想要加载到
0x140000000,但那个地址已被占用,加载器会自动“修复” DLL 的所有内部地址,使其能在新地址(例如0x150000000)正常运行。 - CRT/TLS 初始化: 这是最重要的! 加载器会正确初始化 C/C++ 运行时(CRT),为
errno和rand()等函数设置线程本地存储(TLS),并调用所有 C++ 全局/静态对象的构造函数。
缺点:
- 极易被检测: 任何反作弊(AC)或杀毒软件(AV)都会挂钩(Hook)
LoadLibrary和CreateRemoteThread。 - 模块列表: DLL 会被登记在游戏进程的“已加载模块列表”中,一查便知。
- 磁盘文件: 的 DLL 必须真实地存在于磁盘上。
2. 手动映射 (The “Hard Way”): 我们的选择
这种方式更隐蔽,完全绕过了 LoadLibrary 和 Windows 加载器。我们不再“敲门”,而是“潜入”并手动模拟加载器所做的一切。
工作流程:
- [GUI]
OpenProcess(): 同样,获取游戏句柄。 - [GUI]
ReadFile(): 读取本地的San14TrainerDll.dll文件的全部字节,将其读入 GUI 进程的一个缓冲区 (BYTE* pSrcData)。 - [GUI]
VirtualAllocEx(): 在游戏进程中申请一大块内存,大小等于 DLL 的IMAGE_NT_HEADERS->OptionalHeader.SizeOfImage(例如 2MB)。 - [GUI] “拼图” (手动复制):
WriteProcessMemory(): 将 DLL 的 PE 头(文件的前 4KB)从的pSrcData缓冲区复制到游戏内存的起始位置。- 循环遍历 DLL 的所有“区段”(Sections,例如
.text.data)。 WriteProcessMemory(): 将每个区段(如代码区段.text)从pSrcData + SectionHeader.PointerToRawData(在文件中的位置)复制到pTargetBase + SectionHeader.VirtualAddress(在内存中“应该”在的位置)。
至此,DLL 的“尸体”已经被我们完整地“克隆”到了游戏内存中。但还不是一个“活”的 DLL。
5. 引导代码 (Shellcode)
我们不能直接调用 DLL 的 DllMain,因为还没有被“激活”。我们需要一段“引导代码” (Shellcode) 来完成 LoadLibrary 免费提供的那些“保姆”工作。
6. Shellcode 的“待办清单” (最难的部分)
我们的 injector.cpp 里的 Shellcode 函数,的任务就是在游戏进程内部执行:
- 修复基址重定位: 我们在第3步申请的内存地址(
pTargetBase)几乎不可能等于 DLL 期望的基址(ImageBase)。Shellcode 必须计算这个差值 (LocationDelta),然后遍历.reloc区段,修复 DLL 代码中所有的硬编码地址。 - 解析导入表 (IAT): 我们的 DLL(
PolyHook)需要调用kernel32.dll里的CreateFileW。Shellcode 必须遍历.idata区段,手动调用LoadLibraryA(加载kernel32.dll等依赖) 和GetProcAddress(获取CreateFileW的地址),然后把这个真实地址填入 DLL 的导入地址表 (IAT) 中。 - 初始化 TLS 和 CRT: 调用 TLS 回调函数,为 C++ 全局变量(如果需要)分配空间。
- 调用 DllMain: 最后,当一切准备就绪,Shellcode 才执行
_DllMain(pTargetBase, DLL_PROCESS_ATTACH, ...),正式“启动” DLL。
7. [GUI] CreateRemoteThread(): 我们在 GUI 中调用 CreateRemoteThread,让游戏进程执行我们写入的 Shellcode。
优点:
- 隐蔽: 完全绕过
LoadLibrary。 - 无模块: DLL 不会出现在进程模块列表中。
- 无文件: 游戏电脑上不需要 DLL 文件,通过内存传输。
缺点 (灾难的根源):
极度复杂: 我们等于在手动重写一个简化版的 Windows 加载器。
编译器配置问题:
Shellcode函数必须是纯粹的、可被复制的机器码。但我们的 C++ 编译器(Visual Studio)为了“安全”,默认会往所有函数(包括Shellcode)里注入自己的“私货”:/GS(安全检查): 插入__security_cookie检查代码(堆栈金丝雀)。/RTCs(运行时检查): 插入对_RTC_CheckEsp等函数的调用。/guard:cf(控制流保护): 插入额外的jmp验证。
当我们把这个“被污染”的
Shellcode复制到游戏进程中并运行时,一执行到这些“私货”代码(如_RTC_CheckEsp)就立刻崩溃,因为在游戏进程的内存中根本找不到这个函数!这也就是为什么我们必须在项目属性中强制禁用这些编译选项。
3. 进程间通信 (IPC)
现在,DLL(“大脑”)已经在游戏里了,但没有 UI。GUI(“脸面”)在外面。我们如何让 GUI 告诉 DLL “把速度设为2.0”?
我们使用 **命名管道 (Named Pipes)**。
- DLL (服务器):
DllMain启动后,会创建一个命名管道(例如\\.\pipe\San14TrainerPipe)并作为“服务器”开始监听。 - GUI (客户端): 启动时,会作为“客户端”去连接这个管道。
- 对话: 连接成功后,GUI 可以通过
WriteFile发送命令(如"SPEEDHACK_SET 2.0\n"),而 DLL 则通过ReadFile接收并执行命令。
4. 动态解耦 UI
这是本框架的灵魂。我们的 GUI 不“写死”任何功能。
- GUI 启动并连接到 IPC。
- GUI 向 DLL 发送一个命令:
"get_ui"。 - DLL 收到命令后,动态生成一个 JSON 字符串,这个 JSON 定义了修改器的所有功能(“我有一个叫‘变速’的滑块,范围1.0-5.0”、“我有一个叫‘上帝模式’的开关”…)。
- DLL 通过 IPC 把这个 JSON 发回给 GUI。
- GUI 收到 JSON 后,动态解析,并自动渲染出 ImGui 界面(滑块、开关等)。
好处: 当游戏更新,我们只需要更新 DLL(比如修改 Hook 地址),而 GUI 程序 (TrainerLoader.exe) 完全不需要重新编译。
具体实践与“踩坑实录”
原理讲完了,我们来看看代码。
阶段 1:“大脑” - DLL 与 IPC 服务器
这是 DLL 的核心,必须是一个健壮的、支持持久连接的 IPC 服务器。
GameUtility.cpp (DLL 内部的 PipeServerThread)
1 | // 这是 DLL 中运行在独立线程的 IPC 服务器 |
阶段 2:“加载器” - 手动映射注入器
我们的 ManualMapDll 逻辑(分配内存、写PE头、写区段、写 Shellcode、CreateRemoteThread)在 C++ 逻辑上是完美的,但就是一注入游戏就崩溃。
原因: GUI 项目默认开启了一些现代 C++ 安全功能,这些功能会“污染”我们的 Shellcode 函数。
当 WriteProcessMemory(..., Shellcode, ...) 时,以为复制的是纯机器码,实际上复制的是:
1 | [ 编译器自动插入的安全检查代码 (例如 _RTC_CheckEsp, __security_cookie) ] |
当远程线程在游戏进程中执行这段被“污染”的 Shellcode 时,试图调用的 _RTC_CheckEsp 函数在游戏进程中根本不存在,导致立即崩溃。
解决方案:
在的 ImGui GUI 项目属性中,必须禁用所有这些安全功能:
- 项目属性 -> C/C++ -> 代码生成 -> 基本运行时检查:设为
默认值(禁用/RTCs)。这是最终的罪魁祸首。 - 项目属性 -> C/C++ -> 代码生成 -> 安全检查:设为 **
禁用 (/GS-)**。 - 项目属性 -> C/C++ -> 代码生成 -> 控制流保护:设为
否(禁用/guard:cf-)。
阶段 3:“脸面” - ImGui GUI 与 IPC 客户端
当我们修复了注入器,GUI 终于能注入 DLL 了,但又遇到了新的问题:GUI 显示 “IPC连接失败”。
IpcClient.cpp (C++ 客户端)
我们对比了能正常工作的 C# 客户端和我们重构这个 C++ 客户端,发现了两个致命的 API 误用:
踩坑点: SetCommTimeouts 失败 (Error 1)
- 问题: 我们在
Connect()成功后调用SetCommTimeouts来设置超时,但返回了ERROR_INVALID_FUNCTION(错误 1),导致连接失败。 - 症状: 日志显示
SetCommTimeouts 失败。错误: 1。 - 原因:
SetCommTimeouts是一个古老的 API,用于COM 串行端口 (COM1,COM2…),根本不支持命名管道! - 修复: 直接删除对
SetCommTimeouts的所有调用。C# 客户端也从未使用。
阶段 4:GUI 渲染与崩溃修复
我们终于连上了!GUI 调用 "get_ui",收到了 JSON,然后……又崩了。
踩坑点: 标题栏中文乱码 (UTF-8 vs. UTF-16)
- 问题:
SetWindowText(hWnd, ...)(Win32 API) 只接受 UTF-16 (wchar_t*)。而我们的 JSON 是 UTF-8。 - 错误代码:
std::wstring wGameName(g_gameName.begin(), g_gameName.end()); - 修复: 必须使用
MultiByteToWideCharAPI (或 C++17 的codecvt) 来正确转换编码。
踩坑点: std::map 与 nlohmann::json 数组的“顺序”陷阱
- 问题: 在渲染 “数值修改” 列表时,我们以为
g_sliderValues(我们的std::map) 和g_featuresJson["features"](JSON 数组) 里的元素顺序是一致的。 - 错误代码:
auto feature = g_featuresJson["features"].at(slider_id++); - 原因:
std::map按键(”SPEEDHACK”)排序,而 JSON 数组按定义顺序。我们用map的索引去访问array,导致访问越界或访问了错误的类型(例如,试图从一个“开关”里获取min值),引发 C++ 异常并崩溃。 - 修复: 在
RenderUI循环中,必须通过功能的id(例如 “SPEEDHACK”),在 JSON 数组中搜索匹配的feature对象,而不是依赖脆弱的索引。