从了解 Hook 到全方面会用 Hook
逆向进阶篇 — 从 Ring 3 到 Ring -2 的 Hook 技术全景
Hook 技术演进时间线
1990s 起步期
- IAT/EAT Hook 出现(PE 标准化后)
- Windows 9x VxD Hook
- API Monitor 跟踪
2000s 爆发期
- SSDT Hook 成 Rootkit 标配
- Inline Hook(Detours)
- DKOM 隐藏进程/驱动
- 用户态 Rootkit 组合拳
2010s 对抗期
- PatchGuard 封杀直接修改
- Callback/Minifilter 成 EDR 主流
- EPT/Hypervisor 技术出现
- BYOVD 成为新入口
2020s 隐蔽期
- PTE/VMFUNC 走向实用
- Stealth Hypervisor 精细化
- DMA + FPGA 组合
- 固件级威胁成焦点
目录
第一层:用户态 Hook(Ring 3) 1.1 IAT Hook 1.2 EAT Hook 1.3 Inline Hook 1.4 VTable Hook 1.5 Message Hook 1.6 API Monitor 1.7 Instrumentation 1.8 DLL 注入 1.9 用户态检测 第二层:内核态 Hook(Ring 0)— 传统手段 2.1 SSDT Hook 2.2 IDT Hook 2.3 GDT/LDT 2.4 DKOM 2.5 IRP Hook 2.6 MSR Hook 第三层:内核态 Hook(Ring 0)— 高级手段 3.1 内核回调 3.2 Minifilter 3.3 NDIS Hook 3.4 Object Hook 3.5 BYOVD 3.6 PTE Hook 第四层:Hypervisor Hook(Ring -1) 4.1 EPT Hook 4.2 VMFUNC Hook 4.3 隐形 Hypervisor 第五层:固件层 Hook(Ring -2) 5.1 SMM Hook 5.2 DMA 攻击 5.3 UEFI Hook 结语第一层:用户态 Hook(Ring 3)
上手成本最低、用得最多的 Hook 方式。不用写驱动,不用跟内核的内存管理较劲,改指针或者改字节就能干活。
OD 和 x64dbg 里下的每个断点,本质也是一种 Hook——把指令第一个字节改成 0xCC。道理是一样的,只是实现精细度不一样。
1.1 IAT Hook(导入地址表 Hook)
原理
PE 文件的导入表(IAT)里存着程序从外部 DLL 导入的函数地址。程序调 MessageBoxA,实际上是 call 到 IAT 表项指向的地址去执行。改掉这个表项,调用就跑到了自己的函数里。
操作上就几步:找到目标 DLL 在 IAT 里的位置 → 找到目标函数条目 → 把地址换成自己的 Hook 函数 → 完事。
GetProcAddress 动态取地址再去 call,IAT Hook 管不着。实现
#include <windows.h>
#include <stdint.h>
// 原始函数类型定义
typedef int (WINAPI* fnMessageBoxA)(HWND, LPCSTR, LPCSTR, UINT);
fnMessageBoxA OriginalMessageBoxA = NULL;
// 我们自定义的 Hook 函数
int WINAPI HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {
// 先执行自定义逻辑
MessageBoxA(NULL, "你的 MessageBox 被 Hook 了!", "Hook Alert", MB_OK);
// 调用原始函数
return OriginalMessageBoxA(hWnd, lpText, lpCaption, uType);
}
// 核心:替换 IAT 条目
void InstallIatHook() {
ULONG_PTR imageBase = (ULONG_PTR)GetModuleHandleA(NULL);
PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)(imageBase + dosHeader->e_lfanew);
PIMAGE_IMPORT_DESCRIPTOR importDesc = (PIMAGE_IMPORT_DESCRIPTOR)(
imageBase + ntHeaders->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_IMPORT].VirtualAddress
);
while (importDesc->Name != 0) {
char* dllName = (char*)(imageBase + importDesc->Name);
if (lstrcmpiA(dllName, "user32.dll") == 0) {
PIMAGE_THUNK_DATA thunk = (PIMAGE_THUNK_DATA)(imageBase + importDesc->FirstThunk);
PIMAGE_THUNK_DATA origThunk = (PIMAGE_THUNK_DATA)(imageBase + importDesc->OriginalFirstThunk);
while (thunk->u1.Function != 0) {
PIMAGE_IMPORT_BY_NAME importByName = (PIMAGE_IMPORT_BY_NAME)(imageBase + origThunk->u1.AddressOfData);
if (strcmp((char*)importByName->Name, "MessageBoxA") == 0) {
OriginalMessageBoxA = (fnMessageBoxA)thunk->u1.Function;
DWORD oldProtect;
VirtualProtect(&thunk->u1.Function, sizeof(PVOID), PAGE_READWRITE, &oldProtect);
thunk->u1.Function = (ULONG_PTR)HookedMessageBoxA;
VirtualProtect(&thunk->u1.Function, sizeof(PVOID), oldProtect, &oldProtect);
break;
}
thunk++; origThunk++;
}
break;
}
importDesc++;
}
}
int main() {
InstallIatHook();
MessageBoxA(NULL, "这是一个测试", "原消息", MB_OK);
return 0;
}
注意点
- OriginalFirstThunk vs FirstThunk:INT 存函数名/序号,一般不修改;IAT 存解析后的地址,Hook 改的就是这个。PE 加载器解析完 DLL 后把地址填进 IAT,所以动手脚也是在 IAT 上。
- VirtualProtect:IAT 所在页经常是只读的(ASLR 开启时尤为常见),写之前要改成
PAGE_READWRITE。 - 局限:只管 IAT 路径的调用。Delay-Load DLL 和
GetProcAddress动态解析都管不了。
实战里遇到 IAT Hook 的情况其实不多——因为太容易被发现了。不过外挂和某些杀软早期版本用过,现在更多是作为理解 Hook 基础概念的切入点。
检测
- 拿内存里的 IAT 和磁盘文件里的 IAT 逐项对比,不一样就是被钩了
- 拿
GetProcAddress取到真实地址,跟 IAT 表项里的值对一下 - 正常的反作弊和 EDR 都有现成的 IAT 扫描模块,基本是秒级检测
1.2 EAT Hook(导出地址表 Hook)
原理
IAT 是改调用方的表,EAT 是改被调用 DLL 的表。DLL 的导出地址表里存着所有对外暴露的函数地址,GetProcAddress 就是从这儿查的。把这个表里的地址改了,所有从该 DLL 拿地址的人都会中招。
实际中 EAT Hook 比 IAT Hook 少见——因为大部分场景下改 IAT 就够了,改 EAT 的影响面太大,容易搞崩系统。但反过来讲,如果恶意软件想全局拦截某个 API(比如杀软的扫描函数),改 EAT 确实更彻底。
实现
void InstallEatHook() {
HMODULE hKernel32 = GetModuleHandleA("kernel32.dll");
if (!hKernel32) return;
ULONG_PTR imageBase = (ULONG_PTR)hKernel32;
PIMAGE_DOS_HEADER dos = (PIMAGE_DOS_HEADER)imageBase;
PIMAGE_NT_HEADERS nt = (PIMAGE_NT_HEADERS)(imageBase + dos->e_lfanew);
PIMAGE_EXPORT_DIRECTORY exports = (PIMAGE_EXPORT_DIRECTORY)(
imageBase + nt->OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT].VirtualAddress
);
DWORD* addressOfFunctions = (DWORD*)(imageBase + exports->AddressOfFunctions);
DWORD* addressOfNames = (DWORD*)(imageBase + exports->AddressOfNames);
WORD* addressOfOrdinals = (WORD*)(imageBase + exports->AddressOfNameOrdinals);
for (DWORD i = 0; i < exports->NumberOfNames; i++) {
char* funcName = (char*)(imageBase + addressOfNames[i]);
if (strcmp(funcName, "GetProcAddress") == 0) {
WORD ordinal = addressOfOrdinals[i];
DWORD* targetEntry = &addressOfFunctions[ordinal];
DWORD oldProtect;
VirtualProtect(targetEntry, sizeof(DWORD), PAGE_READWRITE, &oldProtect);
*targetEntry = (DWORD)((ULONG_PTR)HookedGetProcAddress - imageBase);
VirtualProtect(targetEntry, sizeof(DWORD), oldProtect, &oldProtect);
break;
}
}
}
注意
- EAT 的地址是 RVA,不是绝对地址
- 改 EAT 全局生效,所有进程都会被影响
检测
- 内存 EAT 对比磁盘文件 EAT,不一致就有问题
GetProcAddress拿到的地址指向哪个模块一查便知
1.3 Inline Hook / Detour 钩子
原理
改表始终有局限——IAT Hook 管不着动态解析的调用。Inline Hook 的思路更暴力:不管你怎么调用、从哪调用,我直接改目标函数的指令。在函数开头写一个 JMP,跳转到我的 handler。
实现(x64)
#include <windows.h>
#include <stdint.h>
#pragma pack(push, 1)
typedef struct _JMP_HOOK_64 {
// mov rax, addr
BYTE op_mov_rax; // 0x48
BYTE op_mov_rax2; // 0xB8
ULONG64 targetAddr; // 目标绝对地址
// jmp rax
BYTE op_jmp_rax; // 0xFF
BYTE op_jmp_rax2; // 0xE0
} JMP_HOOK_64;
#pragma pack(pop)
// Trampoline 结构:保存原始指令 + 跳回
typedef struct _TRAMPOLINE {
BYTE originalBytes[14]; // 保存被覆盖的原始指令
JMP_HOOK_64 jmpBack; // 跳回原函数剩余部分
} TRAMPOLINE;
TRAMPOLINE g_Trampoline = {0};
void InstallInlineHook(BYTE* targetFunc, BYTE* hookFunc) {
DWORD oldProtect;
memcpy(g_Trampoline.originalBytes, targetFunc, sizeof(JMP_HOOK_64));
// 构造 Trampoline 的跳回指令
g_Trampoline.jmpBack.op_mov_rax = 0x48;
g_Trampoline.jmpBack.op_mov_rax2 = 0xB8;
g_Trampoline.jmpBack.targetAddr = (ULONG64)(targetFunc + sizeof(JMP_HOOK_64));
g_Trampoline.jmpBack.op_jmp_rax = 0xFF;
g_Trampoline.jmpBack.op_jmp_rax2 = 0xE0;
VirtualProtect(targetFunc, sizeof(JMP_HOOK_64), PAGE_EXECUTE_READWRITE, &oldProtect);
JMP_HOOK_64 hook;
hook.op_mov_rax = 0x48;
hook.op_mov_rax2 = 0xB8;
hook.targetAddr = (ULONG64)hookFunc;
hook.op_jmp_rax = 0xFF;
hook.op_jmp_rax2 = 0xE0;
memcpy(targetFunc, &hook, sizeof(JMP_HOOK_64));
VirtualProtect(targetFunc, sizeof(JMP_HOOK_64), oldProtect, &oldProtect);
FlushInstructionCache(GetCurrentProcess(), targetFunc, sizeof(JMP_HOOK_64));
}
// 使用示例
int (WINAPI* OriginalMessageBoxA)(HWND, LPCSTR, LPCSTR, UINT);
int WINAPI MyMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) {
return OriginalMessageBoxA(hWnd, "Hook 成功!", lpCaption, uType);
}
void SetupHook() {
HMODULE hUser32 = LoadLibraryA("user32.dll");
BYTE* msgBox = (BYTE*)GetProcAddress(hUser32, "MessageBoxA");
OriginalMessageBoxA = (fnMessageBoxA)msgBox;
InstallInlineHook(msgBox, (BYTE*)MyMessageBoxA);
}
坑:多字节覆盖
x64 下 JMP 需要 14 字节(mov rax, imm64; jmp rax)。但目标函数头几条指令加起来不一定正好 14 字节——可能一条 2 字节一条 3 字节,凑不够。如果在指令中间截断,蹦回去的时候 CPU 吃到错误字节就崩了。
正确做法是先反汇编目标函数头部,算清楚在指令边界上切断。Microsoft Detours 库里核心就是做了这件事。
实战中 Inline Hook 很常见,可以说是用户态 Hook 的"主力"。但它的问题也很明显——函数前几个字节变了,随便扫一下就能发现。
检测
- 看函数前 14 字节有没有 JMP 到模块外
- 函数首字节是不是
0xE9(JMP rel32)或者0x48 0xB8 - 函数首字节是不是
0xCC(调试器断点,也是 Hook 的一种) - 对已知函数算 CRC,跟原始值比对
1.4 VTable Hook(虚函数表 Hook)
原理
C++ 虚函数调用走 VTable,每个有虚函数的对象开头都有一个指向 VTable 的指针。把 VTable 里的函数指针换了,就可以劫持这个类的所有虚函数调用。
游戏逆向的常用手段——引擎大量用虚函数处理渲染、输入、网络事件,改 VTable 就能截住这些流程,而且不动模块代码段,常规的 IAT/Inline 检测扫不到。
实现
class Renderer {
public:
virtual void Draw() { printf("Renderer::Draw() original\n"); }
virtual void Update(float dt) { printf("Renderer::Update(%f) original\n", dt); }
};
void MyDraw(Renderer* self) {
printf("Renderer::Draw() HOOKED! 调用者 = %p\n", self);
}
void MyUpdate(Renderer* self, float dt) {
printf("Renderer::Update(%f) HOOKED!\n", dt);
}
void HookVTable() {
Renderer renderer;
ULONG_PTR* vtable = *(ULONG_PTR**)&renderer;
DWORD oldProtect;
VirtualProtect(&vtable[0], sizeof(ULONG_PTR) * 2, PAGE_READWRITE, &oldProtect);
vtable[0] = (ULONG_PTR)MyDraw;
vtable[1] = (ULONG_PTR)MyUpdate;
VirtualProtect(&vtable[0], sizeof(ULONG_PTR) * 2, oldProtect, &oldProtect);
}
检测
- VTable 指针指向模块的哪个段?正常的应该在 .rdata(只读数据段),跑到其他段就有问题
- 对关键类的 VTable 做 CRC 校验
- 有些反作弊会扫描所有对象的 VTable 位置合法性
1.5 Windows Message Hook(消息钩子)
原理
Windows 有一套官方的消息 Hook 机制——SetWindowsHookEx。严格来说这不算"Hack",系统就支持你这样做。但它确实能拦截键盘、鼠标、窗口消息等事件。
注意这是跨进程的——钩子 DLL 会被系统自动注入到目标进程,不需要手动写注入代码。这也是外挂和键盘记录器最常用的方式之一。
实现
#include <windows.h>
LRESULT CALLBACK KeyboardProc(int nCode, WPARAM wParam, LPARAM lParam) {
if (nCode >= 0) {
if (wParam == WM_KEYDOWN) {
KBDLLHOOKSTRUCT* pKb = (KBDLLHOOKSTRUCT*)lParam;
printf("按键: %d (vkCode=%d)\n", pKb->vkCode, pKb->vkCode);
if (pKb->vkCode == VK_F1) {
printf("F1 被拦截!\n");
return 1;
}
}
}
return CallNextHookEx(NULL, nCode, wParam, lParam);
}
void InstallMsgHook() {
HHOOK hook = SetWindowsHookEx(WH_KEYBOARD_LL, KeyboardProc,
GetModuleHandleA(NULL), 0);
if (hook) printf("键盘钩子安装成功!\n");
}
类型一览
| Hook 类型 | 常量 | 用途 |
|---|---|---|
| 键盘 | WH_KEYBOARD_LL | 低级键盘事件 |
| 鼠标 | WH_MOUSE_LL | 低级鼠标事件 |
| 消息 | WH_CALLWNDPROC | 窗口消息 |
| 日志 | WH_JOURNALRECORD | 宏记录 |
| 调试 | WH_DEBUG | 调试其他钩子 |
检测
- 遍历钩子链查一下装了哪些 Hook
- 检查进程里被注入的不可疑 DLL
- 钩子过程的地址落在哪个模块里,一眼就能看出来
1.6 API Monitor / 调试寄存器 Hook
原理
CPU 的调试寄存器 DR0-DR7 可以用来设置硬件断点。每个线程的上下文里可以设最多 4 个断点,触发 INT1 异常后在 VEH(向量化异常处理)中解析调用上下文。
好处是完全不动代码——不修改 IAT、EAT、函数指令、VTable,常规的 CRC 校验全都没用。
实现要点
void SetHardwareBreakpoint(void* targetAddr, int index) {
CONTEXT ctx = {0};
ctx.ContextFlags = CONTEXT_DEBUG_REGISTERS;
HANDLE hThread = GetCurrentThread();
GetThreadContext(hThread, &ctx);
switch (index) {
case 0: ctx.Dr0 = (ULONG_PTR)targetAddr; break;
case 1: ctx.Dr1 = (ULONG_PTR)targetAddr; break;
case 2: ctx.Dr2 = (ULONG_PTR)targetAddr; break;
case 3: ctx.Dr3 = (ULONG_PTR)targetAddr; break;
}
ctx.Dr7 |= (1 << (2 * index));
SetThreadContext(hThread, &ctx);
}
检测
- 检查线程上下文的 DR0-DR3 是不是非零
- 读 VEH 链看有没有可疑的 INT1 handler
- 某些反作弊会定期清零调试寄存器
1.7 Instrumentation Callback Hook
原理
Windows x64 有个冷门但官方的机制——ntdll!RtlpInstrumentationCallback。把这个指针改成自己的函数,每个函数调用/返回都会自动触发一次回调。相当于一个全局自动的 Inline Hook,不需要手动改任何函数。
思路
typedef void (*INSTRUMENTATION_CALLBACK)(
ULONG_PTR callerRetAddr, ULONG_PTR calleeAddr,
ULONG_PTR callSiteAddr, ULONG flags);
void MyInstrumentationCallback(
ULONG_PTR retAddr, ULONG_PTR callee, ULONG_PTR callSite, ULONG flags) {
printf("调用: 0x%llX -> 0x%llX\n", callSite, callee);
}
检测
- 检查
NtGlobalFlag的FLG_INSTRUMENTATION位 - 检查
ntdll!RtlpInstrumentationCallback指向哪 - 性能开销很明显——每个函数调用都进回调,跑的慢的就是有问题
1.8 DLL 注入技术补充
原理
很多 Hook 技术依赖把代码加载到目标进程里(尤其是跨进程的 Message Hook 和 IAT Hook)。注入本身不是 Hook,但它是前置条件。
常见方式
| 方式 | 原理 | 检测难度 |
|---|---|---|
CreateRemoteThread + LoadLibrary | 在目标进程里开线程调 LoadLibrary | ★★☆☆☆ |
SetWindowsHookEx | 系统自动把 DLL 注入到目标进程 | ★★☆☆☆ |
| APC 注入 | 利用异步过程调用队列执行注入代码 | ★★★☆☆ |
| 反射式注入 | 不依赖 LoadLibrary,自己解析 PE 手动加载 | ★★★★☆ |
| Atom Bombing | 全局原子表 + APC,不需要写磁盘的 DLL | ★★★★☆ |
| Process Hollowing | 创建挂起进程,替换代码段后恢复执行 | ★★★☆☆ |
检测
- 枚举进程模块,检查不合法的 PE
- 监控
CreateRemoteThread的调用 - 监控
LoadLibrary/LdrLoadDll事件 - 扫描内存中的可疑 PE 头(反射式注入的残留)
1.9 用户态 Hook 的通用检测
核心思想
所有用户态 Hook 的共同缺陷是内存被改了。不管改的是表还是指令还是什么,只要对比原始状态就能发现。
检测示例
bool CheckForHook(const char* moduleName, const char* funcName) {
HMODULE hMod = GetModuleHandleA(moduleName);
BYTE* memAddr = (BYTE*)GetProcAddress(hMod, funcName);
if (memAddr[0] == 0xE9 || (memAddr[0] == 0x48 && memAddr[1] == 0xB8))
return true;
return false;
}
防御思路
- 直接用
Nt*系统调用绕过用户态,不经过 user32 / kernel32 - 用独立的监控进程做校验,不在被钩的进程里运行检测代码
- 对关键函数做定期的 CRC 完整性校验