搞逆向和游戏 Mod 开发的朋友肯定都经历过写注入器(Injector)的痛苦:要提升权限、要 OpenProcess、要 CreateRemoteThread,还得防着杀软报毒。

有没有一种办法,不需要专门写个 exe 去注入,只要把 DLL 往游戏目录一丢,游戏启动时就能自动把我们的代码吃进去?

有的,这就是行业内著名的 DLL 劫持(Hijacking)DLL 转发(Proxying) 技术。

DLL 搜索顺序劫持 (Search Order Hijacking)

为什么能“劫持”?

Windows 的 PE 加载器在加载 DLL 时,是有一个既定的搜索顺序(DLL Search Order)的。默认情况下,它的优先级是这样的:

  1. 应用程序加载的目录(当前目录) <— 重点在这里!
  2. 系统目录(System32)
  3. 16位系统目录
  4. Windows 目录
  5. 当前目录(Current Directory)
  6. PATH 环境变量

绝大多数程序在加载系统库(比如 version.dll, winmm.dll, dinput8.dll)时,并不会指定绝对路径 C:\Windows\System32\version.dll,而是只写一个 version.dll

这就给了我们可乘之机:只要我们在exe 同级目录放一个我自己编译的 version.dll,exe就会优先加载我们的文件,而不是系统的那个。

遇到的问题:函数导出

如果你直接放一个空的 version.dll,游戏一启动就会崩溃。因为游戏依赖原版 DLL 里的函数(比如 GetFileVersionInfoA),你的假 DLL 里没有,游戏找不到入口点,自然就崩了。

所以,我们需要做 DLL 转发(Proxying)

既要让游戏加载我们的 DLL(执行我们的 Payload),又要保证游戏原本想调用的系统函数能正确转发给真正的系统 DLL。

实现转发主要有两种流派:静态转发动态转发


静态转发 (Static Forwarding)

这是最简单、最“懒”的方式。我们不需要在 C++ 代码里写任何逻辑,而是利用链接器(Linker)的特性,直接告诉操作系统:“这个函数不在我这,你去系统目录找吧”。

核心原理

利用 #pragma comment(linker, ...) 指令。

代码示例

这是一个典型的静态转发代码模板,针对 version.dll

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
#pragma once

// 告诉链接器:如果有人找 GetFileVersionInfoA,直接把请求转给 c:\windows\system32\version.dll 里的 GetFileVersionInfoA
#pragma comment(linker,"/export:GetFileVersionInfoA=c:\\windows\\system32\\version.GetFileVersionInfoA,@1")
#pragma comment(linker,"/export:GetFileVersionInfoByHandle=c:\\windows\\system32\\version.GetFileVersionInfoByHandle,@2")
#pragma comment(linker,"/export:GetFileVersionInfoExA=c:\\windows\\system32\\version.GetFileVersionInfoExA,@3")
#pragma comment(linker,"/export:GetFileVersionInfoExW=c:\\windows\\system32\\version.GetFileVersionInfoExW,@4")
#pragma comment(linker,"/export:GetFileVersionInfoSizeA=c:\\windows\\system32\\version.GetFileVersionInfoSizeA,@5")
#pragma comment(linker,"/export:GetFileVersionInfoSizeExA=c:\\windows\\system32\\version.GetFileVersionInfoSizeExA,@6")
#pragma comment(linker,"/export:GetFileVersionInfoSizeExW=c:\\windows\\system32\\version.GetFileVersionInfoSizeExW,@7")
#pragma comment(linker,"/export:GetFileVersionInfoSizeW=c:\\windows\\system32\\version.GetFileVersionInfoSizeW,@8")
#pragma comment(linker,"/export:GetFileVersionInfoW=c:\\windows\\system32\\version.GetFileVersionInfoW,@9")
#pragma comment(linker,"/export:VerFindFileA=c:\\windows\\system32\\version.VerFindFileA,@10")
#pragma comment(linker,"/export:VerFindFileW=c:\\windows\\system32\\version.VerFindFileW,@11")
#pragma comment(linker,"/export:VerInstallFileA=c:\\windows\\system32\\version.VerInstallFileA,@12")
#pragma comment(linker,"/export:VerInstallFileW=c:\\windows\\system32\\version.VerInstallFileW,@13")
#pragma comment(linker,"/export:VerLanguageNameA=c:\\windows\\system32\\version.VerLanguageNameA,@14")
#pragma comment(linker,"/export:VerLanguageNameW=c:\\windows\\system32\\version.VerLanguageNameW,@15")
#pragma comment(linker,"/export:VerQueryValueA=c:\\windows\\system32\\version.VerQueryValueA,@16")
#pragma comment(linker,"/export:VerQueryValueW=c:\\windows\\system32\\version.VerQueryValueW,@17")

#include "windows.h"
#include <fstream>

// 这里不需要手动 LoadLibrary,因为链接器已经处理了转发
// 但为了演示,我们可以写个日志

VOID DebugToFile(LPCSTR szInput)
{
std::ofstream log("spartacus-proxy-version.log", std::ios_base::app | std::ios_base::out);
log << szInput << "\n";
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
// 在这里启动你的外挂线程
DebugToFile("Static Proxy Loaded!");
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}

优缺点分析

  • 优点:代码量极少,不需要定义函数指针,不需要处理参数压栈,性能损耗几乎为零。
  • 缺点无法拦截参数。你只是个路牌,流量直接绕过你走了。你只能在 DllMain 里做一些全局的 Hook,无法针对特定导出函数做手脚。

动态转发 (Dynamic Forwarding)

“当游戏调用 GetFileVersionInfo 时,我修改一下它的返回值”,或者“记录一下它读取了哪个文件”,静态转发就不够用了。我们需要动态转发

核心原理

  1. 我们自己实现一个与原函数签名完全一致的 C++ 函数。
  2. 在我们的函数内部,手动 LoadLibrary 加载原版 DLL。
  3. 使用 GetProcAddress 获取原函数地址。
  4. 调用原函数,并在调用前后插入我们的逻辑(Hook)。
  5. 配合 .def 文件解决 C++ 名字修饰(Name Mangling)问题。

代码示例

这里展示一个“混合模式”:部分函数静态转发(不关心的),部分函数动态转发(想要拦截的)。

注意:下面的代码需要配合 .def 模块定义文件使用,否则游戏找不到导出函数。

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
#pragma once

// 对于不关心的函数,依然使用静态转发,省事
#pragma comment(linker,"/export:GetFileVersionInfoByHandle=c:\\windows\\system32\\version.GetFileVersionInfoByHandle,@2")
#pragma comment(linker,"/export:VerLanguageNameA=c:\\windows\\system32\\version.VerLanguageNameA,@14")
#pragma comment(linker,"/export:VerLanguageNameW=c:\\windows\\system32\\version.VerLanguageNameW,@15")

#include "windows.h"
#include <fstream>

// 定义函数指针,必须和原函数签名一致
typedef BOOL(*GetFileVersionInfoA_Type)(LPCSTR lptstrFilename, DWORD dwHandle, DWORD dwLen, LPVOID lpData);
// ... (其他 typedef 省略,参考完整代码) ...
typedef BOOL(*VerQueryValueW_Type)(LPCVOID pBlock, LPCWSTR lpSubBlock, LPVOID * lplpBuffer, PUINT puLen);

// 全局变量保存句柄
// 注意:全局 LoadLibrary 可能导致 Loader Lock,工程实践中建议在函数内懒加载
HMODULE hModule = LoadLibrary(L"c:\\windows\\system32\\version.dll");

VOID DebugToFile(LPCSTR szInput)
{
std::ofstream log("spartacus-proxy-version.log", std::ios_base::app | std::ios_base::out);
log << szInput << "\n";
}

BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
// DllMain 逻辑保持不变
return TRUE;
}

// 我们的代理函数
BOOL GetFileVersionInfoA_Proxy(LPCSTR lptstrFilename, DWORD dwHandle, DWORD dwLen, LPVOID lpData)
{
// 【拦截点】我们可以在这里记录日志,看看游戏读取了什么
DebugToFile("GetFileVersionInfoA called!");

// 获取原函数地址
GetFileVersionInfoA_Type original = (GetFileVersionInfoA_Type)GetProcAddress(hModule, "GetFileVersionInfoA");

// 调用原函数并返回
return original(lptstrFilename, dwHandle, dwLen, lpData);
}

// ... (其他 Proxy 函数实现) ...

关键配套:.def 文件

C++ 编译后的函数名会变成 ?GetFileVersionInfoA_Proxy@@YAH... 这种鬼样子。游戏是不认的。我们需要用 version.def 文件把名字“掰”回来:

1
2
3
4
5
LIBRARY version.dll
EXPORTS
GetFileVersionInfoA=GetFileVersionInfoA_Proxy
; ... 其他映射

劫持工具包

Spartacus

ghidra

实战:红警2 (Red Alert 2) 辅助改造

好了,理论讲完了。现在我们手头有一个写好的红警2辅助(Trainer),原本是需要注入器运行的,现在我们要把它改造成只需把 version.dll 丢进去就能用的版本。

1. 场景分析

  • 目标游戏:红警2尤里的复仇 (gamemd.exe)。
  • 原有功能:包含 IPC 通信、自动维修、全图挂等功能,由 MainThread 统一管理。
  • 改造目标:融合动态转发代码,让游戏启动时自动拉起 MainThread

2. 代码融合

我们将原有业务逻辑放入中间层,底层使用 Proxy 框架支撑。

完整代码实现 (dllmain.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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
#include "pch.h"
#include <windows.h>
#include <iostream>
#include <string>
#include <vector>
#include <fstream>
#include "utils/GameUtility.h"
#include "modules/Features.h"

// ==========================================================
// Part 1: 原有业务逻辑 (Trainer Logic)
// ==========================================================

GameUtil::CommandHandlerMap g_commandHandlerMap;

// ... (此处省略具体的 BuildUIDefinitionJson 实现,与原项目保持一致) ...
// ... (此处省略 InitializeCommandMap 实现) ...

// 核心业务线程:以前是注入器远程创建的,现在由 DllMain 内部创建
void MainThread()
{
// 1. 创建控制台方便调试
GameUtil::CreateConsole();
std::cout << "[Ra2Trainer] Proxy Loaded via version.dll!" << std::endl;

// 2. 初始化基址
GameUtil::InitializeGameBase();
if (GameUtil::g_GameBaseAddr == 0) {
std::cout << "[Error] Game Base not found!" << std::endl;
return;
}

// 3. 初始化功能模块
g_autoRepairFeature.Initialize();
g_buildAnywhereFeature.Initialize();
// ... 其他功能初始化 ...

// 4. 启动 IPC 服务端,等待 UI 控制端连接
InitializeCommandMap();
std::cout << "[Ra2Trainer] Starting IPC Server..." << std::endl;
GameUtil::Ipc::StartServer(L"\\\\.\\pipe\\Ra2TrainerPipe", g_commandHandlerMap);
}

// ==========================================================
// Part 2: 动态代理层 (Proxy Layer)
// ==========================================================

// 保存系统原版 DLL 句柄
HMODULE g_hOriginalDll = NULL;

// 懒加载:防止全局 LoadLibrary 导致的死锁风险
void LoadOriginalDll() {
if (g_hOriginalDll == NULL) {
// 必须使用绝对路径!否则会死循环加载自己
g_hOriginalDll = LoadLibrary(L"C:\\Windows\\System32\\version.dll");
if (!g_hOriginalDll) {
MessageBoxA(NULL, "系统 version.dll 加载失败!", "Error", MB_ICONERROR);
ExitProcess(1);
}
}
}

// 定义函数指针类型
typedef BOOL(WINAPI *GetFileVersionInfoA_Type)(LPCSTR, DWORD, DWORD, LPVOID);
typedef BOOL(WINAPI *GetFileVersionInfoW_Type)(LPCWSTR, DWORD, DWORD, LPVOID);
// ... 其他 typedef ...

// DllMain 入口
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved)
{
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
DisableThreadLibraryCalls(hModule);

// 【关键点】在这里拉起我们的外挂主线程
// 游戏以为它只是加载了一个普通的 version.dll,实际上我们的线程已经跑起来了
CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)MainThread, hModule, 0, nullptr);
break;

case DLL_PROCESS_DETACH:
GameUtil::DestroyConsole();
break;
}
return TRUE;
}

// ==========================================================
// Part 3: 导出函数包装 (Wrappers)
// ==========================================================

// 这些函数就是给游戏调用的“诱饵”
BOOL WINAPI GetFileVersionInfoA_Proxy(LPCSTR a, DWORD b, DWORD c, LPVOID d) {
LoadOriginalDll(); // 确保原版已加载
static auto original = (GetFileVersionInfoA_Type)GetProcAddress(g_hOriginalDll, "GetFileVersionInfoA");
return original(a, b, c, d); // 转发请求
}

BOOL WINAPI GetFileVersionInfoW_Proxy(LPCWSTR a, DWORD b, DWORD c, LPVOID d) {
LoadOriginalDll();
static auto original = (GetFileVersionInfoW_Type)GetProcAddress(g_hOriginalDll, "GetFileVersionInfoW");
return original(a, b, c, d);
}

// ... 其他 VerQueryValue 等函数的 Proxy 实现 ...

3. 别忘了 .def 文件

在 VS 项目中添加 version.def,确保游戏能通过标准名称找到我们的 Proxy 函数:

1
2
3
4
5
6
LIBRARY version.dll
EXPORTS
GetFileVersionInfoA=GetFileVersionInfoA_Proxy
GetFileVersionInfoW=GetFileVersionInfoW_Proxy
; 把剩下那十几个函数都补齐...

部署与测试

  1. 编译:生成 Release x86 (红警2是32位游戏) 的 DLL。
  2. 改名:把生成的 DLL 改名为 version.dll
  3. 放置:找到红警2游戏目录(gamemd.exe 所在目录),把我们的 version.dll 丢进去。
  4. 启动:直接双击 gamemd.exe

效果
你会发现游戏正常启动,没有任何报错。同时,你的控制台窗口弹出来了,提示 [Ra2Trainer] Proxy Loaded,IPC 管道也建立成功。

至此,我们成功干掉了注入器,实现了一个优雅的“寄生”外挂。

总结

DLL 劫持与转发是逆向工程中非常基础但也非常强大的技术。它不仅用于写外挂,在 API Monitor、补丁制作、老游戏兼容性修复(如 d3d8to9)中都有广泛应用。

核心要义就三句话:

  1. 抢占位置:利用 DLL 搜索顺序,放在 exe 旁边。
  2. 伪装身份:导出表必须和原版一致(靠 .def 或 #pragma)。
  3. 暗度陈仓:在 DllMain 里跑我们的逻辑,在 Proxy 函数里转发系统的逻辑。

代码洞注入 (Code Cave Injection) / 内联劫持

在上一节中,我们利用 Windows 的 DLL 搜索顺序,轻松拿下了没有保护的老游戏。但如果你现在面对的是搭载了 Denuvo、EAC (Easy Anti-Cheat) 或 BattlEye 的现代大作和电竞网游,传统的“代理 DLL”方法大概率会让你当场吃瘪。

为什么传统 DLL 劫持会失效?

现代反作弊系统引入了一个极度恶心的机制:模块信任链(Module Trust Chain)

当游戏进程尝试加载 version.dlldinput8.dll 时,反作弊引擎会在底层拦截这个操作,并强制校验两件事:

  1. 路径溯源:这个 DLL 是不是从 C:\Windows\System32 加载的?
  2. 数字签名校验:这个文件有没有微软官方的 Authenticode 签名?甚至还要算一遍 SHA-256 哈希比对。

一旦发现你把它放在了游戏同级目录下,或者签名不对,反作弊引擎会立刻判定环境异常,直接弹窗报错或者默默闪退。既然“骗”不过操作系统加载器,我们就只能换一种更暴力的思路:直接给游戏的 .exe 主程序做外科手术,让游戏在不知不觉中,亲手把我们的外挂 DLL 拉进内存。

核心思路:放弃治疗,直接开刀

这就是底层黑客最爱用的重武器:代码洞(Code Cave)与内联劫持(Inline Hook)。

什么是代码洞 (Code Cave)?

编译器在生成 PE 文件(.exe)时,为了内存对齐(通常是 0x1000 字节,也就是 4KB 一个内存页),会在各个节区(如 .text 代码段、.data 数据段)的末尾填充大量的 00 00 空白字节。
在动辄几十上百兆的游戏中,这种“无用”的空白区域随处可见。这就是我们要找的“代码洞”——它是我们藏匿恶意代码(Shellcode)的绝佳老巢。

5 字节的物理极限:±2GB 边界

找到洞之后,怎么让游戏跑过去执行呢?我们需要在游戏正常的执行流上“挖坑”。

在 x86/x64 汇编中,最经典的劫持指令是无条件相对跳转:**JMP(机器码 E9)**。
这条指令总共占用 5 个字节E9 加上 4 个字节的偏移量(E9 XX XX XX XX)。

这里有一个必须知道的硬核物理极限:因为后面的偏移量是一个 32位有符号整数,这就意味着 JMP 指令最远只能往前或往后跳 2GB 的内存距离。幸运的是,不管多大的单机游戏,装载进内存后的模块跨度绝不会超过 2GB,所以这 5 个字节的 E9 在模块内部绝对够用。

实战剖析

真正的内联注入,绝不仅仅是跳过去执行那么简单。由于 .text 代码段在内存中通常是只读/可执行 (R/X) 的,我们的跳板和执行逻辑在跑完之后,必须完美还原原始的指令,并恢复内存权限。否则,极易触发访问违规导致游戏闪退,或者被反作弊的内存页扫描扫出异常。

要实现这种“无痕自愈”,有两种方案:一种是在 EXE 的 Code Cave 中用纯汇编完成恢复,另一种是将恢复逻辑交由注入的 DLL 用 C++ 来处理。

方案一:纯汇编层面的自我治愈

这种方案不依赖外部高级语言的配合,所有的权限解除、内存复原、权限恢复全部在注入的 Shellcode 中一口气闭环完成。

下面这段以《红警2》(32位) 为例的 90 字节实战 Shellcode,展示了如何利用“栈魔术”徒手调用两次 VirtualProtect,实现原汁原味的汇编级自愈:

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
; 1. 保护现场
60 pushad
9C pushfd

; 2. Call-Pop 字符串传参大法,巧妙拉起 DLL
E8 12 00 00 00 call string_end
52 61 32 54 72 61 69 6E 65 72 44 6C 6C 2E 64 6C 6C 00 ; "Ra2TrainerDll.dll\0"
string_end:
FF 15 20 12 7E 00 call dword ptr [0x007E1220] ; 调用 LoadLibraryA

; =======================================================
; 3. 破壁行动:徒手构建 VirtualProtect 解除写保护
; =======================================================
6A 00 push 0 ; 【神级微操 1】往栈里塞个 0,作为 oldProtect 变量!
54 push esp ; 参数 4: 压入该变量的地址 (&oldProtect)
6A 40 push 0x40 ; 参数 3: PAGE_EXECUTE_READWRITE (0x40)
6A 05 push 5 ; 参数 2: 修改大小 5 字节
68 D8 B9 6B 00 push 0x006BB9D8 ; 参数 1: Hook点基址
FF 15 D0 10 7E 00 call dword ptr [0x007E10D0] ; 调用 VirtualProtect
; 【注意】此时 API 已经将原来的权限 (如 0x20) 写到了我们第一步 push 0 的那个栈位置上!我们故意不 pop,把它留着。

; =======================================================
; 4. 暴力写回原始 5 字节,实现自我治愈
; =======================================================
B8 D8 B9 6B 00 mov eax, 0x006BB9D8
C7 00 A3 E8 30 B7 mov dword ptr [eax], 0xB730E8A3
C6 40 04 00 mov byte ptr [eax+4], 0

; =======================================================
; 5. 掩盖痕迹:再次调用 VirtualProtect 恢复原始权限
; =======================================================
54 push esp ; 参数 4: 再次提供这个栈地址接收废弃返回值
FF 74 24 04 push dword ptr [esp+4] ; 【神级微操 2】提取刚才留存在栈上的原权限,作为参数 3 压入!
6A 05 push 5 ; 参数 2: 5 字节
68 D8 B9 6B 00 push 0x006BB9D8 ; 参数 1: Hook点基址
FF 15 D0 10 7E 00 call dword ptr [0x007E10D0] ; 第二次调用!
58 pop eax ; 【神级微操 3】终于可以把一开始造变量的 4 字节弹掉了,维持堆栈完美平衡!

; 6. 恢复现场,此时 EAX 被完美复原为原始数据
9D popfd
61 popad

; 7. 完美闭环:跳回原址 (注意末尾的 CC 占位符)
E9 CC CC CC CC jmp [占位符,由 Patcher 自动计算]

方案二:DLL 内部自愈与痕迹擦除 (Zero Footprint)

由于现代游戏通常开启了 ASLR(动态基址),且反作弊系统会严格扫描内存异常,常规的做法是将复杂的内存修补工作剥离出汇编层,移交至 C++ DLL 内部处理。

这种方案的优势在于可以利用 GetModuleHandle 动态获取基址,彻底无视 ASLR;同时能做到真正的“阅后即焚”与模块隐身,将注入痕迹彻底抹除。

1. 瘦身后的 Shellcode

在这个方案中,Code Cave 里的汇编只承担最纯粹的加载任务,剥离了所有 VirtualProtect 的逻辑,体积大幅缩小。

1
2
3
4
5
6
7
8
9
10
11
12
; 瘦身版 Shellcode (无视 ASLR,仅作跳板)
60 pushad
9C pushfd

E8 12 00 00 00 call string_end
52 61 32 54 72 61 69 6E 65 72 44 6C 6C 2E 64 6C 6C 00 ; "TrainerDll.dll\0"
string_end:
FF 15 20 12 7E 00 call dword ptr [0x007E1220] ; 调用 LoadLibraryA

9D popfd
61 popad
E9 CC CC CC CC jmp [占位符,由 Patcher 自动计算]

2. 内存模块隐藏 (Ring 3 级隐身)

仅抹除跳板代码并不彻底,LoadLibraryA 在加载 DLL 时,会将其注册到进程环境块(PEB)的三个双向链表中。此时使用常规的内存查看工具(如 Cheat Engine)依然能在模块列表中看到该 DLL。

为了实现真正的无痕驻留,必须在 DLL 初始化的第一时间进行两步操作:

  1. PEB 断链:遍历 PEB_LDR_DATA,将自身模块从加载顺序、内存顺序、初始化顺序三个链表中物理摘除。
  2. 抹除 PE 头:将内存中前 4096 字节(包含 MZ 标志与 PE 结构)覆写为 0x00,使内存扫描工具将其误判为普通的数据段。

3. 严格的执行流时序控制 (完整 C++ 实现)

在 DLL 中执行自愈与隐身时,必须严格遵守“同步抢修,异步抹零”的并发时序,否则极易引发执行流死循环或内存崩溃:

  • 同步执行:还原 Hook 跳板代码、PEB 断链必须在 DllMain 中同步完成,以阻断游戏线程发生二次跳转引发的重复注入。
  • 异步执行:抹除 Code Cave 必须在子线程中延时执行(如 Sleep(100)),确保游戏主线程已执行完剩余的弹栈与跳转指令,安全回归原生代码段。

以下是整合了自愈与隐身逻辑的完整 C++ 架构:

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
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
#include <windows.h>
#include <intrin.h>

// 动态获取的基址,通杀 ASLR
uintptr_t g_GameBaseAddr = 0;

// 断开双向链表节点的标准宏
#define UNLINK(x) \
(x).Blink->Flink = (x).Flink; \
(x).Flink->Blink = (x).Blink;

// =======================================================
// 模块隐身逻辑:抹除 PE 头与 PEB 断链
// =======================================================
void HideModule(HMODULE hModule) {
// 1. 抹除 PE 头 (4096 字节)
DWORD oldProtect;
if (VirtualProtect(hModule, 0x1000, PAGE_EXECUTE_READWRITE, &oldProtect)) {
memset(hModule, 0x00, 0x1000);
VirtualProtect(hModule, 0x1000, oldProtect, &oldProtect);
}

// 2. PEB 双向断链
#ifdef _WIN64
PPEB pPeb = (PPEB)__readgsqword(0x60);
#else
PPEB pPeb = (PPEB)__readfsdword(0x30);
#endif

PPEB_LDR_DATA pLdr = pPeb->Ldr;
PLIST_ENTRY pCurrentList = pLdr->InMemoryOrderModuleList.Flink;
PLIST_ENTRY pStartList = &pLdr->InMemoryOrderModuleList;

while (pCurrentList != pStartList) {
// 注:此处使用第三方 Hook 库(如 PolyHook)或自定义的 LDR_DATA_TABLE_ENTRY 结构体
PLDR_DATA_TABLE_ENTRY pEntry = (PLDR_DATA_TABLE_ENTRY)((BYTE*)pCurrentList - offsetof(LDR_DATA_TABLE_ENTRY, InMemoryOrderLinks));

if (pEntry->DllBase == (PVOID)hModule) {
UNLINK(pEntry->InLoadOrderLinks);
UNLINK(pEntry->InMemoryOrderLinks);
UNLINK(pEntry->InInitializationOrderLinks);

// 清理字符串痕迹
if (pEntry->BaseDllName.Buffer) {
memset(pEntry->BaseDllName.Buffer, 0, pEntry->BaseDllName.Length);
}
if (pEntry->FullDllName.Buffer) {
memset(pEntry->FullDllName.Buffer, 0, pEntry->FullDllName.Length);
}
break;
}
pCurrentList = pCurrentList->Flink;
}
}

// =======================================================
// 纯净的底层异步清理线程 (不触发任何 C++ 运行时初始化)
// =======================================================
DWORD WINAPI CaveCleanerThread(LPVOID lpParam) {
// 延时 150ms,确保主线程安全回到原生代码段
Sleep(150);

if (g_GameBaseAddr != 0) {
DWORD oldProtectCave;
void* caveAddress = (void*)(g_GameBaseAddr + 0x3E038D); // Cave 相对偏移

// 物理超度占位代码
if (VirtualProtect(caveAddress, 40, PAGE_EXECUTE_READWRITE, &oldProtectCave)) {
memset(caveAddress, 0x00, 40);
VirtualProtect(caveAddress, 40, oldProtectCave, &oldProtectCave);
}
}
return 0;
}

// =======================================================
// 主工作线程
// =======================================================
DWORD WINAPI MainThread(LPVOID lpParam) {
if (g_GameBaseAddr == 0) return 0;

// 【异步执行】使用原生 API 延时抹除 Code Cave 痕迹
// 绝对不能同步抹零,需等待主线程执行完防空洞内的 pop 和 jmp
HANDLE hCleaner = CreateThread(nullptr, 0, CaveCleanerThread, nullptr, 0, nullptr);
if (hCleaner) {
CloseHandle(hCleaner); // 仅关闭句柄,让线程跑完自动释放
}

// 初始化外部业务逻辑 (Hook / IPC 管道等)
// ...

return 0;
}

// =======================================================
// DLL 入口点
// =======================================================
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
if (ul_reason_for_call == DLL_PROCESS_ATTACH) {
// 禁用线程库调用通知,减少锁竞争
DisableThreadLibraryCalls(hModule);

// 1. 获取真实基址
g_GameBaseAddr = (uintptr_t)GetModuleHandle(NULL);

if (g_GameBaseAddr != 0) {
// 2. 【同步执行】立刻治愈 Hook 点
// 必须在 LoadLibraryA 返回前抢修完毕,阻断二次注入循环
DWORD oldProtect;
void* hookAddress = (void*)(g_GameBaseAddr + 0x2BB9D8); // Hook 点相对偏移

if (VirtualProtect(hookAddress, 5, PAGE_EXECUTE_READWRITE, &oldProtect)) {
BYTE originalBytes[] = { 0xB8, 0xD8, 0xB9, 0x6B, 0x00 }; // 原版指令
memcpy(hookAddress, originalBytes, 5);
VirtualProtect(hookAddress, 5, oldProtect, &oldProtect);
}
}

// 3. 执行 Ring 3 级隐身,从进程列表中抹去自身
HideModule(hModule);

// 4. 开启独立线程处理业务,避免阻塞 DllMain
HANDLE hThread = CreateThread(nullptr, 0, MainThread, hModule, 0, nullptr);
if (hThread) {
CloseHandle(hThread);
}
}
return TRUE;
}

总结

相比于被动寄生的 Proxy DLL,内联劫持代表了底层黑客主动破局的思路。

  • 优势:极其强横,执行时机 100% 可控。不再看操作系统加载器的脸色,直接在执行流层面接管游戏。配合“自我治愈”和“内存破壁”的 Shellcode,能够完美绕过绝大多数只在游戏启动初期扫描完整性的 T0 级反作弊,达到阅后即焚的效果。
  • 代价:技术门槛陡增。你需要具备基础的 x86/x64 汇编能力、深刻理解 PE 结构(物理偏移与虚拟地址的换算),并且时刻小心堆栈平衡。