前言

本项目(以《三国志14PK》为例)的目标就是实现这样一个系统。由三个核心部分组成:

  1. C++ DLL (San14TrainerDll.dll): 运行在游戏进程中的“大脑”,负责挂钩(Hook)游戏函数和执行修改。
  2. C++ ImGui GUI (TrainerLoader.exe): 一个完全独立的“脸面”,用户通过来控制“大脑”。
  3. IPC (命名管道): 连接“大脑”和“脸面”的“神经系统”。

核心原理

我们的起点是两个独立的进程:TrainerLoader.exe (我们的GUI) 和 SAN14PK.exe (游戏)。们各自拥有受保护的内存空间,无法直接通信。我们的目标,就是把我们自己的代码(San14TrainerDll.dll)“塞”进游戏进程里去运行。

这就是 DLL 注入

1. 标准方式 (The “Easy Way”): CreateRemoteThread + LoadLibrary

这是最古老、最稳定、也是最容易被检测到的方法。

工作流程:

  1. [GUI] OpenProcess(): 获取游戏进程的句柄 (HANDLE hProc)。
  2. [GUI] VirtualAllocEx(): 在游戏进程的内存里申请一小块空间(比如 256 字节)。
  3. [GUI] WriteProcessMemory(): 将的 DLL 的完整磁盘路径写入刚刚申请的那块内存。
  4. [GUI] GetProcAddress(): 找到 kernel32.dll 中的 LoadLibraryA 函数的地址。(一个巧妙的技巧:kernel32.dll 在所有进程中的加载基址都是一样的,所以在 GUI 进程中找到的地址在游戏进程中同样有效)。
  5. [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),为 errnorand() 等函数设置线程本地存储(TLS),并调用所有 C++ 全局/静态对象的构造函数

缺点:

  • 极易被检测: 任何反作弊(AC)或杀毒软件(AV)都会挂钩(Hook)LoadLibraryCreateRemoteThread
  • 模块列表: DLL 会被登记在游戏进程的“已加载模块列表”中,一查便知。
  • 磁盘文件: 的 DLL 必须真实地存在于磁盘上。

2. 手动映射 (The “Hard Way”): 我们的选择

这种方式更隐蔽,完全绕过了 LoadLibrary 和 Windows 加载器。我们不再“敲门”,而是“潜入”并手动模拟加载器所做的一切。

工作流程:

  1. [GUI] OpenProcess(): 同样,获取游戏句柄。
  2. [GUI] ReadFile(): 读取本地的 San14TrainerDll.dll 文件的全部字节,将其读入 GUI 进程的一个缓冲区 (BYTE* pSrcData)。
  3. [GUI] VirtualAllocEx(): 在游戏进程中申请一大块内存,大小等于 DLL 的 IMAGE_NT_HEADERS->OptionalHeader.SizeOfImage(例如 2MB)。
  4. [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 不“写死”任何功能。

  1. GUI 启动并连接到 IPC。
  2. GUI 向 DLL 发送一个命令: "get_ui"
  3. DLL 收到命令后,动态生成一个 JSON 字符串,这个 JSON 定义了修改器的所有功能(“我有一个叫‘变速’的滑块,范围1.0-5.0”、“我有一个叫‘上帝模式’的开关”…)。
  4. DLL 通过 IPC 把这个 JSON 发回给 GUI。
  5. GUI 收到 JSON 后,动态解析,并自动渲染出 ImGui 界面(滑块、开关等)。

好处: 当游戏更新,我们只需要更新 DLL(比如修改 Hook 地址),而 GUI 程序 (TrainerLoader.exe) 完全不需要重新编译


具体实践与“踩坑实录”

原理讲完了,我们来看看代码。

阶段 1:“大脑” - DLL 与 IPC 服务器

这是 DLL 的核心,必须是一个健壮的、支持持久连接的 IPC 服务器。

GameUtility.cpp (DLL 内部的 PipeServerThread)

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
// 这是 DLL 中运行在独立线程的 IPC 服务器
void PipeServerThread(const std.wstring pipeName, const CommandHandlerMap commandHandlers)
{
// ... (CreateNamedPipeW 在这里) ...

// 外层循环:保持服务器存活
while (true)
{
// 1. 阻塞,直到一个客户端 (GUI) 连接进来
BOOL bConnected = ConnectNamedPipe(hPipe, NULL) ?
TRUE : (GetLastError() == ERROR_PIPE_CONNECTED);

if (bConnected)
{
Logger::GetInstance().Log(LogLevel::Info, "[IPC Server] 客户端已连接。");

// 2.【关键】内层循环:保持此客户端的持久连接
while (ReadFile(hPipe, buffer, ..., &bytesRead, NULL) != FALSE)
{
// 3. 成功读取到命令 (例如 "get_ui" 或 "SPEEDHACK_SET 2.0")
std::string commandStr = ... // (解析 buffer)

// 4. 在命令映射表(commandHandlers)中查找并执行
auto it = commandHandlers.find(commandName);
if (it != commandHandlers.end())
{
it->second(hPipe, args); // 执行命令,hPipe 可用于回写响应
}
}

// 5. ReadFile 失败 (客户端断开),跳出内循环
Logger::GetInstance().Log(LogLevel::Info, "[IPC Server] 客户端已断开。");
DisconnectNamedPipe(hPipe);
CloseHandle(hPipe);
}

// 6. 返回外层循环,创建下一个管道实例,等待新连接
}
}

阶段 2:“加载器” - 手动映射注入器

我们的 ManualMapDll 逻辑(分配内存、写PE头、写区段、写 Shellcode、CreateRemoteThread)在 C++ 逻辑上是完美的,但就是一注入游戏就崩溃。

原因: GUI 项目默认开启了一些现代 C++ 安全功能,这些功能会“污染”我们的 Shellcode 函数。

WriteProcessMemory(..., Shellcode, ...) 时,以为复制的是纯机器码,实际上复制的是:

1
2
3
[ 编译器自动插入的安全检查代码 (例如 _RTC_CheckEsp, __security_cookie) ]
[ 真正的 Shellcode 机器码 ]
[ 编译器自动插入的另一段检查代码 ]

当远程线程在游戏进程中执行这段被“污染”的 Shellcode 时,试图调用的 _RTC_CheckEsp 函数在游戏进程中根本不存在,导致立即崩溃

解决方案:

在的 ImGui GUI 项目属性中,必须禁用所有这些安全功能:

  1. 项目属性 -> C/C++ -> 代码生成 -> 基本运行时检查:设为 默认值 (禁用 /RTCs)。这是最终的罪魁祸首。
  2. 项目属性 -> C/C++ -> 代码生成 -> 安全检查:设为 **禁用 (/GS-)**。
  3. 项目属性 -> 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());
  • 修复: 必须使用 MultiByteToWideChar API (或 C++17 的 codecvt) 来正确转换编码。

踩坑点: std::mapnlohmann::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 对象,而不是依赖脆弱的索引。