从了解 Hook 到全方面会用 Hook

从了解 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 起步期 2000s 爆发期 2010s 对抗期 2020s 隐蔽期
IAT/EAT Hook 出现(PE 标准化后) SSDT Hook 成 Rootkit 标配 PatchGuard 封杀直接修改 PTE/VMFUNC 走向实用
Windows 9x VxD Hook Inline Hook(Detours) Callback/Minifilter 成 EDR 主流 Stealth Hypervisor 精细化
API Monitor 跟踪 DKOM 隐藏进程/驱动 EPT/Hypervisor 技术出现 DMA + FPGA 组合
用户态 Rootkit 组合拳 BYOVD 成为新入口 固件级威胁成焦点

目录


第一层:用户态 Hook(Ring 3)

门槛最低的 Hook 方式。不写驱动,不管内核内存布局,改指针或改字节就能生效。

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


1.1 IAT Hook(导入地址表 Hook)

原理

程序从 DLL 调的函数地址全记在 IAT 里。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;
}

注意点

  • 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 的导出表记着所有公开函数地址,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 去。

问题是覆盖的字节是原始指令的一部分,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 的"主力"。但它的问题也很明显——函数前几个字节变了,随便扫一下就能发现。

检测

检测难度:较低

  • 看函数前 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(消息钩子)

原理

SetWindowsHookEx——Windows 自带的消息 Hook 机制。不算 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,不需要手动改任何函数。

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

检测

检测难度:较高

  • 检查 NtGlobalFlagFLG_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 完整性校验

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

Ring 3 再怎么改表改代码也受制于权限——进程不想被钩,检测代码一跑就现原形。Ring 0 是另一个级别:直奔内核数据结构,系统服务表、中断表、驱动分发例程,想改就改。代价也很直接——指针写错就是蓝屏,没人给你第二次机会。

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


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

原理

系统调用的分发中心。用户态 NtOpenProcess 走到 ntdll 的 syscall 指令进入内核后,SSDT 告诉内核去哪找真正的实现。表里全是 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
) {
    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() {
    ULONG ssdtIndex = 0x3A;  // Win10 x64 的 NtOpenProcess 索引

    ULONG_PTR* targetEntry = &((ULONG_PTR*)KeServiceDescriptorTable->ServiceTableBase)[ssdtIndex];
    OriginalNtOpenProcess = *targetEntry;

    _disable_write_protection();

    *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 动手之前)

  • 自动:PatchGuard 发现直接蓝屏
  • 手动:跟磁盘上的 sys 文件比一下 SSDT 内容
  • 绕过:干掉 PatchGuard(第三层讲),或者用不碰 SSDT 的方式 Hook(直接改函数代码)

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_ENTRY* idt = (IDT_ENTRY*)idtr.base;

    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)

检测

检测难度:低

  • SGDT 指令获取 GDT 基址,检查段描述符的 DPL(描述符特权级)
  • PatchGuard 在 x64 上检测 GDT 修改

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

原理

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

VOID HideProcess(ULONG targetPid) {
    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);
}

检测

检测难度:中

  • 交叉检测法:通过不同方式枚举进程(如 CreateToolhelp32Snapshot vs Csrss.exe 句柄表)
  • 对比进程链表与句柄表的一致性

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) {
    OriginalIrpCreate = DriverObject->MajorFunction[IRP_MJ_CREATE];
    DriverObject->MajorFunction[IRP_MJ_CREATE] = MyIrpCreate;
}

检测

检测难度:较低

  • 对比驱动文件的 MajorFunction 表与内存中的值
  • 检查分发函数地址是否在驱动模块地址范围内

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
}

检测

检测难度:较低

  • 读取 MSR 0xC0000082 的值,与 ntoskrnl 中的 KiSystemCall64 对比
  • PatchGuard 自动检测

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

PatchGuard 把传统内核手段封死之后,这一层就是"带镣铐跳舞"。EDR 和反作弊驱动主要在这个区间活动。

每一项都比第二层复杂,检测代价也更高。


3.1 内核回调(Callback)机制

原理

Windows 内核自带的回调注册 API,能监控进程、线程、模块、注册表、对象访问。不是旁门左道,是正经驱动接口。回调能实现跟 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);
    CmRegisterCallbackEx(MyRegistryCallback, &Altitude, NULL, NULL, &regCookie, NULL);
    ObRegisterCallbacks(&obRegistration, &obCookie);
}

常见回调类型

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

检测

检测难度:中

  • 枚举内核回调链表,检查回调函数地址是否属于合法驱动
  • 使用 !process 0 0 配合 WinDbg 检查回调表
  • 对比系统中已加载的驱动模块地址范围与回调地址

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

原理

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

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

检测

检测难度:较高

  • 枚举已注册的 Minifilter,检查其 Altitude 和回调地址
  • 通过 fltmc filters 命令查看所有 Minifilter

3.3 NDIS Hook / LSP(网络层 Hook)

原理

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

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

检测

检测难度:中

  • 检查网络驱动栈中的中间层驱动(NDIS)
  • 枚举 WFP 过滤器的回调函数地址

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

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

BYTE shellcode[] = { /* 内核 Shellcode */ };
DWORD bytesRet;
DeviceIoControl(hDevice, 0xAA013044, shellcode, sizeof(shellcode),
                NULL, 0, &bytesRet, NULL);

检测

检测难度:中

  • 维护已知漏洞驱动的黑名单
  • 监控 IOCTL 分发例程的异常调用
  • 检查驱动签名证书是否在已知漏洞列表中

3.6 PTE Hook(页表项 Hook)

原理

不动代码,动页表项(PTE)。把物理页重映射到另一页,代码就透明替换了——虚拟地址不变,实际指向 Payload。

最"隐形"的内核 Hook 之一,主流 EDR 几乎都在用。

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

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

检测

检测难度:较高

  • 检查 PTE 的物理页属性是否异常(如代码页应该是只读的)
  • 对比同一物理页的多个虚拟地址映射
  • 检查 TLBs 是否一致性

第四层:Hypervisor Hook(Ring -1)

Hypervisor 在操作系统底下再垫一层,跑在 CPU 最高特权级(VMX root mode),宿主机的 CPU 和内存全归它管。

Ring 0 Hook 是在操作系统内部动手脚,Hypervisor Hook 是把整个系统"装进虚拟机",从外面盯着里面的一举一动。


4.1 EPT Hook(扩展页表 Hook)

原理

EPT 是 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) {
    ULONG64 guestPhysAddr = ReadVMCS(GUEST_PHYSICAL_ADDRESS);

    if (IsHookedAddress(guestPhysAddr)) {
        SingleStepOriginalInstruction();
        DoCustomHookLogic();
        SetEptPermissions(guestPhysAddr, EPT_READ_WRITE_EXECUTE);
    }
}

检测

检测难度:高

  • 检测 Hypervisor 的存在(通过 CPUID、VMWARE 端口等特征)
  • 测量指令执行时间——EPT Hook 引入的 VMExit 开销会导致明显的延迟
  • 检查 EPT 分页结构是否一致

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 功能
}

检测

检测难度:高

  • 检查 VMCS(虚拟机控制结构)中是否启用了 VMFUNC
  • 检测 Guest 中是否出现异常的 VMFUNC 指令

4.3 Stealth Hypervisor(隐形 Hypervisor)

原理

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

实现策略包括:

  • 拦截并伪造 CPUID 返回(清除 Hypervisor 位)
  • 伪造时间戳计数器(TSC),消除 VMExit 引入的时间差
  • 模拟所有"检测型"指令序列
// 当 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);
    }
}

检测

检测难度:高

  • 基于时间差的检测(需要统计学的异常检测)
  • 检查 TLB 和缓存行为的一致性
  • 通过 CPU 硬件漏洞(如 Meltdown)侧信道读取 Hypervisor 内存

第五层:固件层 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 操作
// - 在操作系统关机/重启后仍然存在(持久化)

检测

检测难度:高

  • 检查 SMRAM 是否被锁定(SMM Lock)
  • 检查 SMI 处理程序是否被修改(通过对比 flash 中的 SMI 代码)
  • 基于时间的 SMI 执行时间检测

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 的旧系统无法防御

检测

检测难度:较高

  • 开启 IOMMU / VT-d 并验证其正确配置
  • 检查 DMA 重映射表(DMAR)
  • 使用内核 DMA 保护(Kernel DMA Protection)

5.3 UEFI / BIOS 级别 Hook

原理

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

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

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

检测

检测难度:高

  • 安全启动(Secure Boot)验证固件签名
  • UEFI 固件扫描工具(如 CHIPSEC)
  • 硬件安全模块(TPM)的启动度量(测量启动)

结语

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

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

  • 调试和逆向:深入理解系统行为
  • 安全防护:了解攻击手段才能有效防御
  • 系统架构:Hook 揭示了 Windows 系统的层次和边界

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


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

posted @ 2026-07-13 14:48  dsc-  阅读(7)  评论(0)    收藏  举报