从了解 Hook 到全方面会用 Hook

逆向进阶篇 — 从 Ring 3 到 Ring -2 的 Hook 技术全景

IAT Hook Inline Hook SSDT Hook EPT Hook SMM Rootkit PatchGuard
为什么需要理解 Hook

做逆向的,天天都在跟 Hook 打交道。反调试器挂了一大堆钩子等你踩,杀软在各种路径上插监控,游戏反作弊更是恨不得在每一个 API 上铺一圈钩网。你搞不明白它在哪钩的、怎么钩的,你就绕不过去。

Hook 归根到底就一件事:让一段正常的代码,先经过你的代码再继续跑。所有花样都是这个本质的变种:

改指针 — IAT、VTable、SSDT,改表里指向的函数地址
改指令 — 函数开头写个 JMP,跳到自己的 handler
借硬件 — EPT、SMM,在最底层拦截连操作系统都不知道

下面按权限从低到高往上刨。读到每个技术的时候可以想一个问题:这个 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)

上手成本最低、用得最多的 Hook 方式。不用写驱动,不用跟内核的内存管理较劲,改指针或者改字节就能干活。

OD 和 x64dbg 里下的每个断点,本质也是一种 Hook——把指令第一个字节改成 0xCC。道理是一样的,只是实现精细度不一样。


1.1 IAT Hook(导入地址表 Hook)

原理

PE 文件的导入表(IAT)里存着程序从外部 DLL 导入的函数地址。程序调 MessageBoxA,实际上是 call 到 IAT 表项指向的地址去执行。改掉这个表项,调用就跑到了自己的函数里。

操作上就几步:找到目标 DLL 在 IAT 里的位置 → 找到目标函数条目 → 把地址换成自己的 Hook 函数 → 完事。

IAT Hook 只管通过 IAT 走的调用。程序要是自己拿 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;
}

注意点

实战里遇到 IAT Hook 的情况其实不多——因为太容易被发现了。不过外挂和某些杀软早期版本用过,现在更多是作为理解 Hook 基础概念的切入点。

检测

难度:★☆☆☆☆


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;
        }
    }
}

注意

检测

难度:★☆☆☆☆


1.3 Inline Hook / Detour 钩子

原理

改表始终有局限——IAT Hook 管不着动态解析的调用。Inline Hook 的思路更暴力:不管你怎么调用、从哪调用,我直接改目标函数的指令。在函数开头写一个 JMP,跳转到我的 handler。

问题是覆盖的字节是原始指令的一部分,hook 完了还得能恢复并执行原函数。保存+恢复这段逻辑叫 Trampoline,比看起来要麻烦不少。

实现(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 的"主力"。但它的问题也很明显——函数前几个字节变了,随便扫一下就能发现。

检测

难度:★★☆☆☆


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);
}

检测

难度:★★☆☆☆


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调试其他钩子

检测

难度:★☆☆☆☆


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);
}

检测

难度:★★★☆☆


1.7 Instrumentation Callback Hook

原理

Windows x64 有个冷门但官方的机制——ntdll!RtlpInstrumentationCallback。把这个指针改成自己的函数,每个函数调用/返回都会自动触发一次回调。相当于一个全局自动的 Inline Hook,不需要手动改任何函数。

不碰 IAT,不写指令,不走调试寄存器,三种常规检测全部绕开。比较适合安全软件做全局行为监控。

思路

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);
}

检测

难度:★★★★☆


1.8 DLL 注入技术补充

原理

很多 Hook 技术依赖把代码加载到目标进程里(尤其是跨进程的 Message Hook 和 IAT Hook)。注入本身不是 Hook,但它是前置条件。

常见方式

方式原理检测难度
CreateRemoteThread + LoadLibrary在目标进程里开线程调 LoadLibrary★★☆☆☆
SetWindowsHookEx系统自动把 DLL 注入到目标进程★★☆☆☆
APC 注入利用异步过程调用队列执行注入代码★★★☆☆
反射式注入不依赖 LoadLibrary,自己解析 PE 手动加载★★★★☆
Atom Bombing全局原子表 + APC,不需要写磁盘的 DLL★★★★☆
Process Hollowing创建挂起进程,替换代码段后恢复执行★★★☆☆

检测


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;
}

防御思路

第二层:内核态 Hook(Ring 0)— 传统手段

Ring 3 改表改代码虽然灵活,但权限有限——但凡进程自己不愿意被钩,做点检测就能把你扫出来。Ring 0 的玩法不一样:直接动内核的数据结构,系统服务表、中断表、驱动分发全都能改。代价也直接——写错一个指针就是蓝屏,没有"崩了重来"这回事。

这一层只记最经典的基础手法。更深度的内核防护(PatchGuard)和绕过方法放在第三层。


2.1 SSDT Hook(系统服务描述符表 Hook)

原理

SSDT 是内核用来派发系统调用的核心表。用户态调 NtOpenProcess(ntdll 里的 syscall 指令进内核之后),内核靠这个表找到真正的 NtOpenProcess 实现函数。表里存的是 ntoskrnl.exe 导出的所有 Nt*/Zw* 服务地址。

这个表在 x86 上是可以直接改的——x64 上 PatchGuard 会盯着它,改完几分钟就蓝屏。

SSDT Hook 步骤

  1. 拿 KeServiceDescriptorTable 拿到 SSDT 基址
  2. 按服务索引号定位目标条目
  3. 把条目里的函数指针换成自己的
  4. 自己的逻辑跑完之后调原函数

完整实现

#include <ntddk.h>

// 声明 KeServiceDescriptorTable(在 ntoskrnl 中未导出,需自己声明)
typedef struct _SERVICE_DESCRIPTOR_TABLE {
    PVOID  ServiceTableBase;      // SSDT 基址
    PVOID  ServiceCounterTable;
    ULONG  NumberOfServices;
    ULONG  ParamTableBase;
} SERVICE_DESCRIPTOR_TABLE, *PSERVICE_DESCRIPTOR_TABLE;

extern PSERVICE_DESCRIPTOR_TABLE KeServiceDescriptorTable;

// 保存原函数指针
ULONG_PTR OriginalNtOpenProcess = NULL;

// 函数声明
NTSTATUS MyNtOpenProcess(
    PHANDLE ProcessHandle,
    ACCESS_MASK DesiredAccess,
    POBJECT_ATTRIBUTES ObjectAttributes,
    PCLIENT_ID ClientId
) {
    // 检查要打开的进程 PID
    if (ClientId && ClientId->UniqueProcess) {
        HANDLE targetPid = ClientId->UniqueProcess;
        DbgPrint("NtOpenProcess 被调用!目标 PID = %lu\n", (ULONG)targetPid);

        // 可以阻止打开某些进程
        if (targetPid == (HANDLE)4) {  // System 进程
            return STATUS_ACCESS_DENIED;
        }
    }
    // 调用原函数
    return ((NTSTATUS(*)(PHANDLE, ACCESS_MASK, POBJECT_ATTRIBUTES, PCLIENT_ID))
            OriginalNtOpenProcess)(ProcessHandle, DesiredAccess, ObjectAttributes, ClientId);
}

VOID InstallSsdtHook() {
    // 获取 NtOpenProcess 在 SSDT 中的索引
    // 索引值随 Windows 版本变化
    ULONG ssdtIndex = 0x3A;  // Win10 x64 的 NtOpenProcess 索引

    // 保存原函数地址
    ULONG_PTR* targetEntry = &((ULONG_PTR*)KeServiceDescriptorTable->ServiceTableBase)[ssdtIndex];
    OriginalNtOpenProcess = *targetEntry;

    // 关闭 WP 保护(写保护)—— CR0 寄存器的第 16 位
    // SSDT 所在内存通常是只读的,需要禁用写保护
    _disable_write_protection();  // 伪代码,实际需要汇编操作 CR0

    // 替换为 Hook 函数
    *targetEntry = (ULONG_PTR)MyNtOpenProcess;

    _enable_write_protection();

    DbgPrint("SSDT Hook 安装成功!\n");
}

问题:KPP / PatchGuard

x64 Windows 的 PatchGuard 每 5-10 分钟扫一次 SSDT、IDT、GDT。发现修改就触发 Bug Check 0x109(CRITICAL_STRUCTURE_CORRUPTION)。这是 SSDT Hook 在 x64 上的硬门槛。

检测

难度:★★☆☆☆(PatchGuard 动手之前)


2.2 IDT Hook(中断描述符表 Hook)

原理

IDT(Interrupt Descriptor Table)保存了每个中断/异常的处理函数入口。修改其中的中断处理函数(如 INT 0x2E、INT 0x80)可拦截中断调用。x86 上这是经典 rootkit 手段,x64 下 IDT 受 PatchGuard 保护,此路不通。

// x86 上的 IDT Hook 示例
// IDT 寄存器通过 SIDT 指令获取

#pragma pack(push, 1)
typedef struct _IDT_ENTRY {
    USHORT offsetLow;
    USHORT selector;
    UCHAR  reserved;
    UCHAR  flags;
    USHORT offsetHigh;
} IDT_ENTRY;
#pragma pack(pop)

typedef struct _IDTR {
    USHORT limit;
    ULONG  base;
} IDTR;

void InstallIdtHook() {
    IDTR idtr;
    __asm sidt idtr;  // 获取 IDT 基址

    IDT_ENTRY* idt = (IDT_ENTRY*)idtr.base;

    // 修改 INT 0x2E(旧式系统调用入口)
    ULONG originalOffset = idt[0x2E].offsetLow | (idt[0x2E].offsetHigh << 16);
    // 替换为 Hook 函数...
}

检测

难度:★☆☆☆☆(PatchGuard 自动检测)


2.3 GDT / LDT Hook

原理

GDT(Global Descriptor Table)定义内存段的属性和权限级别。修改 GDT 中的代码段描述符(CS),将用户态权限提升到 Ring 0。常用于内核漏洞利用中的提权(ret2Ring0)。实战中多见于结合栈溢出的复合攻击。

// 修改 GDT 中的段描述符以提升权限
// 此技术通常配合栈溢出使用
// 将 CS 段的选择子从 Ring 3 (3) 修改为 Ring 0 (0)

检测

难度:★☆☆☆☆


2.4 DKOM(直接内核对象操作)

原理

DKOM 不是 Hook,而是内核态隐藏/欺骗的经典手段。直接修改内核链表结构(进程链表、线程链表、驱动链表),将目标条目从链表中摘除,实现进程/驱动隐藏。虽然古老,但仍是恶意软件的保留手段。

VOID HideProcess(ULONG targetPid) {
    // 遍历 EPROCESS 链表,摘除目标进程
    PLIST_ENTRY current = &PsInitialSystemProcess->ActiveProcessLinks;
    do {
        PEPROCESS process = (PEPROCESS)((ULONG_PTR)current - offsetof(EPROCESS, ActiveProcessLinks));
        ULONG pid = (ULONG)PsGetProcessId(process);

        if (pid == targetPid) {
            // 从链表中摘除
            current->Blink->Flink = current->Flink;
            current->Flink->Blink = current->Blink;
            break;
        }
        current = current->Flink;
    } while (current != &PsInitialSystemProcess->ActiveProcessLinks);
}

检测

难度:★★★☆☆


2.5 IRP Hook(I/O 请求包 Hook)

原理

驱动设备对象(DEVICE_OBJECT)维护了一个 MajorFunction 表(IRP_MJ_* 函数的分发例程)。替换表中的函数指针可拦截发往该设备的所有 IRP。

NTSTATUS MyIrpCreate(PDEVICE_OBJECT DeviceObject, PIRP Irp) {
    DbgPrint("IRP_MJ_CREATE 被调用!\n");
    // 自定义处理逻辑
    return ((PDRIVER_DISPATCH)OriginalIrpCreate)(DeviceObject, Irp);
}

VOID HookIrpDispatch(PDRIVER_OBJECT DriverObject) {
    // 替换 IRP 分发函数
    OriginalIrpCreate = DriverObject->MajorFunction[IRP_MJ_CREATE];
    DriverObject->MajorFunction[IRP_MJ_CREATE] = MyIrpCreate;
}

检测

难度:★★☆☆☆


2.6 MSR Hook(模型特定寄存器 Hook)

原理

x64 系统调用通过 syscall 指令实现,入口地址存放在 MSR 0x176(IA32_LSTAR / Star MSR)中。修改这个 MSR 重定向所有系统调用入口,实现全局系统级 Hook。

// 使用 WRMSR 指令修改 IA32_LSTAR
// 注意:此操作在 PatchGuard 监控下,且需要 Ring 0 权限

void InstallMsrHook() {
    __writemsr(0xC0000082, (ULONG64)MySyscallEntry);  // IA32_LSTAR
}

检测

难度:★★☆☆☆

第三层:内核态 Hook(Ring 0)— 高级手段

传统内核手段被 PatchGuard 盯死后,这一层是"带镣铐跳舞"——EDR 和游戏反作弊驱动的主要活动区间。

每一项技术都比第二层复杂,也更难被检测。


3.1 内核回调(Callback)机制

原理

Windows 内核提供官方的注册回调 API,用于监控进程创建、线程创建、模块加载、注册表修改、对象访问等事件。这不是 Hack,而是正经的 Windows 驱动开发接口。使用回调可实现与 Hook 相同的监控效果,且不被 PatchGuard 视为篡改。这是 EDR 最常使用的监控方案。

// 进程创建回调
VOID MyProcessCallback(HANDLE ParentId, HANDLE ProcessId, BOOLEAN Create) {
    if (Create) {
        DbgPrint("进程创建: %lu (父进程: %lu)\n", (ULONG)ProcessId, (ULONG)ParentId);
    }
}

VOID InstallCallbacks() {
    // 注册进程创建/退出回调
    PsSetCreateProcessNotifyRoutine(MyProcessCallback, FALSE);

    // 注册线程创建/退出回调
    PsSetCreateThreadNotifyRoutine(MyThreadCallback);

    // 注册模块加载回调
    PsSetLoadImageNotifyRoutine(MyImageLoadCallback);

    // 注册注册表回调(Win10+)
    CmRegisterCallbackEx(MyRegistryCallback, &Altitude, NULL, NULL, ®Cookie, NULL);

    // 注册对象监控回调
    ObRegisterCallbacks(&obRegistration, &obCookie);
}

常见回调类型

回调API用途
进程通知PsSetCreateProcessNotifyRoutine监控进程创建/退出
线程通知PsSetCreateThreadNotifyRoutine监控线程创建/退出
模块加载PsSetLoadImageNotifyRoutine监控 DLL/驱动加载
注册表CmRegisterCallback拦截注册表操作
对象ObRegisterCallbacks拦截进程/句柄操作

检测

难度:★★★☆☆


3.2 Minifilter(文件系统微过滤器)

原理

Minifilter 是 Windows 文件系统驱动的新一代框架。注册 Minifilter 驱动后,透明拦截所有文件 I/O 操作——读、写、创建、删除、重命名。这是文件监控/隐藏的标准架构,反病毒软件的核心组件。

// 注册 Minifilter 的 pre-operation 和 post-operation 回调
CONST FLT_REGISTRATION FilterRegistration = {
    sizeof(FLT_REGISTRATION),
    FLT_REGISTRATION_VERSION,
    0,
    NULL,
    &MyPreOperation,   // IRP 前回调
    &MyPostOperation,  // IRP 后回调
    NULL
};

FLT_PREOP_CALLBACK_STATUS MyPreOperation(
    PFLT_CALLBACK_DATA Data, PCFLT_RELATED_OBJECTS FltObjects,
    PVOID* CompletionContext
) {
    // 拦截文件操作
    if (Data->Iopb->MajorFunction == IRP_MJ_CREATE) {
        DbgPrint("文件被打开: %wZ\n", &Data->Iopb->TargetFileObject->FileName);
    }
    return FLT_PREOP_SUCCESS_WITH_CALLBACK;
}

检测

难度:★★★★☆


3.3 NDIS Hook / LSP(网络层 Hook)

原理

在网络层面,注册 NDIS 协议驱动或中间层驱动拦截所有网络流量。应用层通过 LSP 或 Windows Filtering Platform(WFP)实现网络监控。WFP 是微软推荐的方案,LSP 已逐渐被淘汰。

// WFP 注入 DLL 的用户态钩子自动后的回调
// WFP 提供 classify 回调,每个网络包经过时被调用
void MyClassifyCallback(
    const FWPS_INCOMING_VALUES* inFixedValues,
    const FWPS_INCOMING_METADATA_VALUES* inMetaValues,
    void* layerData, const void* classifyContext,
    FWPS_FILTER* filter, UINT64 flowContext,
    FWPS_CLASSIFY_OUT* classifyOut
) {
    // 检查并允许/阻止网络连接
    classifyOut->actionType = FWP_ACTION_BLOCK;  // 阻止
}

检测

难度:★★★☆☆


3.4 Object Hook(对象 Hook / 句柄篡改)

原理

通过 ObRegisterCallbacks 注册对象拦截回调,在进程打开句柄时拦截。EDR 监控进程访问的常用手段。

代码思路

OB_PREOP_CALLBACK_STATUS MyObjectPreCallback(
    PVOID RegistrationContext,
    POB_PRE_OPERATION_INFORMATION OperationInfo
) {
    if (OperationInfo->ObjectType == PsProcessType) {
        PEPROCESS targetProcess = (PEPROCESS)OperationInfo->Object;
        ULONG pid = (ULONG)PsGetProcessId(targetProcess);

        // 拦截对 LSASS 的打开操作
        if (pid == 0x2A0) { // lsass.exe
            OperationInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_READ;
        }
    }
    return OB_PREOP_SUCCESS;
}

3.5 BYOVD(Bring Your Own Vulnerable Driver)

原理

利用已签名但存在漏洞的驱动(MSI Afterburner、Asus PCEEControl、Capcom.sys 等),在用户态程序中获得读写内核内存的能力。最常见的是利用驱动的 MmMapIoSpace 功能读写物理内存。这是近年来攻击者最青睐的初始权限获取方式。

不写自己的驱动,找已签名的存在漏洞的驱动,通过其 IOCTL 接口操纵内核。
// 利用 Capcom.sys 提权(经典案例)
// Capcom.sys 提供了一个 IOCTL,可以执行任意 Shellcode

HANDLE hDevice = CreateFileA("\\\\.\\Htsysm72FB", GENERIC_READ|GENERIC_WRITE,
                              0, NULL, OPEN_EXISTING, 0, NULL);

// 发送 Shellcode 执行请求(在 Ring 0 上下文执行!)
BYTE shellcode[] = { /* 你的内核 Shellcode */ };
DWORD bytesRet;
DeviceIoControl(hDevice, 0xAA013044, shellcode, sizeof(shellcode),
                NULL, 0, &bytesRet, NULL);

检测

难度:★★★☆☆


3.6 PTE Hook(页表项 Hook)

原理

不修改代码,而是修改页表项(PTE)的映射关系。将物理页重新映射到另一个物理页,透明替换代码——虚拟地址不变,但实际指向 Payload。

这是最"隐蔽"的内核 Hook 方式之一,几乎所有主流 EDR 都使用了这种技术。

// PTE Hook 的核心思想:
// 1. 找到目标代码所在的物理页
// 2. 分配一个新的物理页,写入 Hook 代码
// 3. 修改 PTE,将原虚拟地址映射到你的物理页
// 4. 原页面作为"备份"保留

// 因为虚拟地址没有变(代码段也没有变),
// 所以 CRC 校验和 VAD 校验都查不出来。

检测

难度:★★★★☆

第四层:Hypervisor Hook(Ring -1)

Hypervisor 比操作系统内核还低一层,运行在 CPU 最高特权级(VMX root mode),完全控制宿主机的 CPU 和内存。

Ring 0 Hook 是操作系统内部修改,Hypervisor Hook 则是把整个操作系统"装进虚拟机",从外部监视一切。操作系统每一次内存访问、每一条特权指令都在 Hypervisor 监视之下。


4.1 EPT Hook(扩展页表 Hook)

原理

EPT(Extended Page Table)是 Intel VT-x 中用于虚拟化内存的二级页表。Hypervisor 修改 EPT 映射,在 Guest(操作系统)不知情的情况下控制物理内存访问。GameGuard 等反作弊系统使用此方案。

EPT Hook 的核心思路:

  1. 将目标代码所在物理页的 EPT 权限设为"不可执行"(NX)
  2. 当 Guest 尝试执行该页代码时,CPU 触发 EPT Violation(VMExit)
  3. Hypervisor 捕获这个 VMExit,分析上下文,执行自定义逻辑
  4. 单步执行原指令后继续
EPT Hook 对 Guest 完全透明。操作系统不知道 Hypervisor 存在,PatchGuard 也无法检测——它只能检查 Ring 0,EPT 是 Ring -1 的数据。

核心流程

// 伪代码:EPT Hook 处理流程

VOID HandleEptViolation(VMX_EXIT_QUALIFICATION qual) {
    // 1. 获取触发 EPT Violation 的 Guest 物理地址
    ULONG64 guestPhysAddr = ReadVMCS(GUEST_PHYSICAL_ADDRESS);

    // 2. 检查是否是我们 Hook 的目标地址
    if (IsHookedAddress(guestPhysAddr)) {
        // 3. 单步执行原指令
        SingleStepOriginalInstruction();

        // 4. 执行自定义 Hook 逻辑
        DoCustomHookLogic();

        // 5. 修改 EPT 权限,继续执行
        SetEptPermissions(guestPhysAddr, EPT_READ_WRITE_EXECUTE);
    }
}

检测

难度:★★★★★


4.2 VMFUNC Hook

原理

VMFUNC 是 Intel VT-x 提供的一组函数,允许 Guest(无需 VMExit)直接调用 Hypervisor 服务。通过 VMFUNC 修改 EPT 映射(EPT Switching),实现超高性能 Hook——无需每次触发 VMExit。比 EPT Violation 性能更好,但实现也更复杂。

// VMFUNC EPT Switching 的核心思想:
// - Hypervisor 提前配置好多个 EPT 视图(View)
// - 需要在不同"视图"之间切换时,Guest 直接执行 VMFUNC 指令
// - 无需 VMExit,性能远高于 EPT Violation 方案

__asm {
    mov eax, 0          // VMFUNC 号 (0 = EPT Switching)
    mov ecx, 1          // 切换到 View 1
    vmfunc              // 调用 Hypervisor 功能
}

检测

难度:★★★★★


4.3 Stealth Hypervisor(隐形 Hypervisor)

原理

前面的 Hypervisor 技术有一个共同的弱点——存在可以被检测到(CPUID 时间戳、VMWARE 端口、红色 pill 等)。Stealth Hypervisor 的核心目标是:让 Guest 完全无法感知 Hypervisor 的存在。

实现策略包括:

// 伪造 CPUID 返回
// 当 Guest 执行 CPUID 指令时,Hypervisor 截获并修改返回结果

VOID FakeCpuid(ULONG64* rax, ULONG64* rbx, ULONG64* rcx, ULONG64* rdx) {
    if (*rax == 1) {
        // 清除 CPUID.1:ECX[31] — Hypervisor Present Bit
        *rcx &= ~(1 << 31);
    }
}

检测

难度:★★★★★

第五层:固件层 Hook(Ring -2)

这是 Hook 技术的最底层。SMM 运行在比 Hypervisor 更隐蔽的环境中(Hypervisor 也无法直接读取 SMM 内存)。SMM 中的任何修改,对操作系统和 Hypervisor 完全不可见。

这一层的技术仅供安全研究人员了解和防御参考。实际利用需要物理访问或极高的权限。

5.1 SMM Hook(系统管理模式 Hook)

原理

SMM 通过 SMI(系统管理中断)触发。SMI 发生时,CPU 保存当前状态并切换到 SMM 模式,执行 SMI Handler。SMM 代码存储在 SMRAM 中——被 CPU 硬件保护,Ring 0 和 Ring -1 都无法直接访问。

SMM Hook 修改 SMI 处理程序或注册 SMI 处理函数,在 SMM 中执行恶意代码。

// SMM Hook 通常通过以下方式实现:
// 1. 利用 BIOS/UEFI 中的漏洞写入 SMRAM
// 2. 修改 SMI 分发器的处理函数指针
// 3. 在 SMM 中安装一个 rootkit,对所有操作系统完全隐形

// SMM 中的代码可以:
// - 读写物理内存(修改 Hypervisor 的 EPT、修改内核 SSDT)
// - 监控所有 IO 操作
// - 在操作系统关机/重启后仍然存在(持久化)

检测

难度:★★★★★


5.2 DMA 攻击

原理

利用 Thunderbolt / PCIe 等高速外设总线的 DMA 能力,外设不经过 CPU 直接读写物理内存。伪造一个 PCIe 设备(如 Thunderbolt 接口的 FPGA),无需任何软件漏洞即可读取整个系统内存——包括内核和 Hypervisor。

// DMA 攻击的核心:
// 1. 连接一个支持 DMA 的设备(Thunderbolt 网卡、FPGA 开发板等)
// 2. 通过 PCIe BAR 空间访问物理内存
// 3. 直接读取/写入内核数据结构

// 防御:VT-d / IOMMU 可以阻止 DMA 攻击
// 但不支持 VT-d 的旧系统无法防御

检测

难度:★★★★☆


5.3 UEFI / BIOS 级别 Hook

原理

修改 UEFI 固件中的启动代码或 Runtime Services,在操作系统加载前植入代码。每次系统启动自动激活,对操作系统完全透明。重装操作系统也无法清除。

// UEFI Bootkit 的常见手法:
// 1. 修改 UEFI 引导加载程序
// 2. 替换 UEFI Runtime Services 中的函数指针
// 3. 在 S3 恢复(休眠恢复)过程中重新注入

// 这类 Hook 甚至可以在重装操作系统后仍然存在
// ——因为代码固化在固件中,而不是硬盘上

检测

难度:★★★★★


结语

从 Ring 3 到 Ring -2,Hook 技术的演进是一场永无止境的攻防博弈:操作系统不断加固堵漏,攻击者不断寻找更低层的切入点。

理解 Hook 技术不只是为了写 Rootkit 或反外挂:

最后一条防线往往不是更聪明的算法,而是更底层的控制权。

— 本文基于 Hook 技术文献与实战经验整理 —