搞逆向和游戏 Mod 开发的朋友肯定都经历过写注入器(Injector)的痛苦:要提升权限、要 OpenProcess、要 CreateRemoteThread,还得防着杀软报毒。
有没有一种办法,不需要专门写个 exe 去注入,只要把 DLL 往游戏目录一丢,游戏启动时就能自动把我们的代码吃进去?
有的,这就是行业内著名的 DLL 劫持(Hijacking) 与 DLL 转发(Proxying) 技术。
DLL 搜索顺序劫持 (Search Order Hijacking)
为什么能“劫持”?
Windows 的 PE 加载器在加载 DLL 时,是有一个既定的搜索顺序(DLL Search Order)的。默认情况下,它的优先级是这样的:
- 应用程序加载的目录(当前目录) <— 重点在这里!
- 系统目录(System32)
- 16位系统目录
- Windows 目录
- 当前目录(Current Directory)
- 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
#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>
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 时,我修改一下它的返回值”,或者“记录一下它读取了哪个文件”,静态转发就不够用了。我们需要动态转发。
核心原理
- 我们自己实现一个与原函数签名完全一致的 C++ 函数。
- 在我们的函数内部,手动
LoadLibrary 加载原版 DLL。 - 使用
GetProcAddress 获取原函数地址。 - 调用原函数,并在调用前后插入我们的逻辑(Hook)。
- 配合
.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 BOOL(*VerQueryValueW_Type)(LPCVOID pBlock, LPCWSTR lpSubBlock, LPVOID * lplpBuffer, PUINT puLen);
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) { 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); }
|
关键配套:.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"
GameUtil::CommandHandlerMap g_commandHandlerMap;
void MainThread() { GameUtil::CreateConsole(); std::cout << "[Ra2Trainer] Proxy Loaded via version.dll!" << std::endl;
GameUtil::InitializeGameBase(); if (GameUtil::g_GameBaseAddr == 0) { std::cout << "[Error] Game Base not found!" << std::endl; return; }
g_autoRepairFeature.Initialize(); g_buildAnywhereFeature.Initialize();
InitializeCommandMap(); std::cout << "[Ra2Trainer] Starting IPC Server..." << std::endl; GameUtil::Ipc::StartServer(L"\\\\.\\pipe\\Ra2TrainerPipe", g_commandHandlerMap); }
HMODULE g_hOriginalDll = NULL;
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);
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { switch (ul_reason_for_call) { case DLL_PROCESS_ATTACH: DisableThreadLibraryCalls(hModule); CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)MainThread, hModule, 0, nullptr); break;
case DLL_PROCESS_DETACH: GameUtil::DestroyConsole(); break; } return TRUE; }
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); }
|
3. 别忘了 .def 文件
在 VS 项目中添加 version.def,确保游戏能通过标准名称找到我们的 Proxy 函数:
1 2 3 4 5 6
| LIBRARY version.dll EXPORTS GetFileVersionInfoA=GetFileVersionInfoA_Proxy GetFileVersionInfoW=GetFileVersionInfoW_Proxy ; 把剩下那十几个函数都补齐...
|
部署与测试
- 编译:生成 Release x86 (红警2是32位游戏) 的 DLL。
- 改名:把生成的 DLL 改名为
version.dll。 - 放置:找到红警2游戏目录(
gamemd.exe 所在目录),把我们的 version.dll 丢进去。 - 启动:直接双击
gamemd.exe。
效果:
你会发现游戏正常启动,没有任何报错。同时,你的控制台窗口弹出来了,提示 [Ra2Trainer] Proxy Loaded,IPC 管道也建立成功。
至此,我们成功干掉了注入器,实现了一个优雅的“寄生”外挂。
总结
DLL 劫持与转发是逆向工程中非常基础但也非常强大的技术。它不仅用于写外挂,在 API Monitor、补丁制作、老游戏兼容性修复(如 d3d8to9)中都有广泛应用。
核心要义就三句话:
- 抢占位置:利用 DLL 搜索顺序,放在 exe 旁边。
- 伪装身份:导出表必须和原版一致(靠 .def 或 #pragma)。
- 暗度陈仓:在
DllMain 里跑我们的逻辑,在 Proxy 函数里转发系统的逻辑。
代码洞注入 (Code Cave Injection) / 内联劫持
在上一节中,我们利用 Windows 的 DLL 搜索顺序,轻松拿下了没有保护的老游戏。但如果你现在面对的是搭载了 Denuvo、EAC (Easy Anti-Cheat) 或 BattlEye 的现代大作和电竞网游,传统的“代理 DLL”方法大概率会让你当场吃瘪。
为什么传统 DLL 劫持会失效?
现代反作弊系统引入了一个极度恶心的机制:模块信任链(Module Trust Chain)。
当游戏进程尝试加载 version.dll 或 dinput8.dll 时,反作弊引擎会在底层拦截这个操作,并强制校验两件事:
- 路径溯源:这个 DLL 是不是从
C:\Windows\System32 加载的? - 数字签名校验:这个文件有没有微软官方的 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 自动计算]
|
由于现代游戏通常开启了 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 初始化的第一时间进行两步操作:
- PEB 断链:遍历
PEB_LDR_DATA,将自身模块从加载顺序、内存顺序、初始化顺序三个链表中物理摘除。 - 抹除 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>
uintptr_t g_GameBaseAddr = 0;
#define UNLINK(x) \ (x).Blink->Flink = (x).Flink; \ (x).Flink->Blink = (x).Blink;
void HideModule(HMODULE hModule) { DWORD oldProtect; if (VirtualProtect(hModule, 0x1000, PAGE_EXECUTE_READWRITE, &oldProtect)) { memset(hModule, 0x00, 0x1000); VirtualProtect(hModule, 0x1000, oldProtect, &oldProtect); }
#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) { 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; } }
DWORD WINAPI CaveCleanerThread(LPVOID lpParam) { Sleep(150);
if (g_GameBaseAddr != 0) { DWORD oldProtectCave; void* caveAddress = (void*)(g_GameBaseAddr + 0x3E038D); 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;
HANDLE hCleaner = CreateThread(nullptr, 0, CaveCleanerThread, nullptr, 0, nullptr); if (hCleaner) { CloseHandle(hCleaner); }
return 0; }
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) { if (ul_reason_for_call == DLL_PROCESS_ATTACH) { DisableThreadLibraryCalls(hModule); g_GameBaseAddr = (uintptr_t)GetModuleHandle(NULL); if (g_GameBaseAddr != 0) { DWORD oldProtect; void* hookAddress = (void*)(g_GameBaseAddr + 0x2BB9D8); if (VirtualProtect(hookAddress, 5, PAGE_EXECUTE_READWRITE, &oldProtect)) { BYTE originalBytes[] = { 0xB8, 0xD8, 0xB9, 0x6B, 0x00 }; memcpy(hookAddress, originalBytes, 5); VirtualProtect(hookAddress, 5, oldProtect, &oldProtect); } }
HideModule(hModule); HANDLE hThread = CreateThread(nullptr, 0, MainThread, hModule, 0, nullptr); if (hThread) { CloseHandle(hThread); } } return TRUE; }
|
总结
相比于被动寄生的 Proxy DLL,内联劫持代表了底层黑客主动破局的思路。
- 优势:极其强横,执行时机 100% 可控。不再看操作系统加载器的脸色,直接在执行流层面接管游戏。配合“自我治愈”和“内存破壁”的 Shellcode,能够完美绕过绝大多数只在游戏启动初期扫描完整性的 T0 级反作弊,达到阅后即焚的效果。
- 代价:技术门槛陡增。你需要具备基础的 x86/x64 汇编能力、深刻理解 PE 结构(物理偏移与虚拟地址的换算),并且时刻小心堆栈平衡。