简介:面向Windows内核研发与逆向工程人员,这份源码包聚焦64位系统下绕过Process Guard(PG)后修改SSDT实现系统服务Hook的技术,核心解决内核安全机制限制下无法直接Hook的问题。资源基于“二次挑战方式”演示了分步绕过PG的策略,先执行初步操作避开检测,再实施SSDT Hook,对理解现代Windows内核保护很有帮助。压缩包共6个文件,包含C驱动源代码、头文件、makefile构建脚本、sources配置及编译日志,整体仅5KB,结构精简,便于快速定位核心逻辑。驱动主代码与SSDT相关头文件展示了关键操作,日志可辅助排查编译问题,读者可从中提取关键函数实现并理解规避保护机制的步骤与原理。目前已有1088人学习下载,适合有一定内核编程基础、希望深入研究PG绕过与SSDT Hook原理的开发者参考。
1. 为什么在 64 位 Windows 上做 SSDT hook,先得翻过 PatchGuard 这座山
SSDT hook 在 64 位 Windows 上不是改一个表项那么简单。KeServiceDescriptorTable不再导出,SSDT 所在页被强制只读,系统每隔几分钟就会有一个叫 PatchGuard(PG)的内核守护来校验这张表,你前一秒写进去的 hook 地址,后一秒就换来 0x109 蓝屏。所以标题里“过PG”三个字,才是这套实现方法真正要解决的问题。这篇文章适合正在做 EDR、反作弊驱动或内核安全研究的开发者。我会沿着“定位 SSDT → 解开写保护 → 绕过 PatchGuard → 验证稳定性”这条路线,把每个环节的可执行步骤、参数和翻车点讲清楚。
2. 定位 SSDT 表:64 位下没有导出表,只能特征码扫描
2.1 先找到 KiSystemCall64,再顺藤摸瓜
64 位 Windows 的系统服务派发和 32 位完全是两套逻辑。32 位时代可以直接用中断门走到 KiSystemService,再通过导出的KeServiceDescriptorTable去查表;64 位下所有用户态系统调用都会先经过 MSR0xC0000082(IA32_LSTAR)指向的KiSystemCall64,这个函数查 SSDT 时会把表基址加载到一个寄存器里。
所以我们定位 SSDT 的思路是:先读 LSTAR 拿到KiSystemCall64的运行时地址,然后在这个函数的前 0x100 字节里扫描一条lea r10, [rip+disp32]指令。这条指令的rip相对位移,指向的就是KeServiceDescriptorTable基址。不同 Windows 版本的KiSystemCall64里这条指令编码几乎一致,这给了特征码扫描很大的便利。
#include <ntifs.h> // 简单特征码扫描:mask 里 0 表示通配 PVOID find_bytes(UCHAR* base, SIZE_T len, UCHAR* pattern, UCHAR* mask, SIZE_T pat_len) { for (SIZE_T i = 0; i < len - pat_len; i++) { BOOLEAN matched = TRUE; for (SIZE_T j = 0; j < pat_len; j++) { if (mask[j] && (base[i + j] != pattern[j])) { matched = FALSE; break; } } if (matched) return base + i; } return NULL; } // 读取 MSR: 0xC0000082 -> KiSystemCall64 PVOID find_ssdt() { ULONG64 kiSystemCall64 = __readmsr(0xC0000082); // 特征码: 4C 8D 15 ?? ?? ?? ?? (lea r10, [rip+disp32]) UCHAR pattern[] = { 0x4C, 0x8D, 0x15, 0x00, 0x00, 0x00, 0x00 }; UCHAR mask[] = { 0xFF, 0xFF, 0xFF, 0x00, 0x00, 0x00, 0x00 }; PVOID inst = find_bytes((UCHAR*)kiSystemCall64, 0x100, pattern, mask, sizeof(pattern)); if (!inst) return NULL; // 位移是相对下一条指令的,所以要加 7 再读 4 字节立即数 LONG disp = *(LONG*)((UCHAR*)inst + 3); return (PVOID)((ULONG64)inst + 7 + disp); }逻辑说明:__readmsr是 MSVC 编译器内置函数,直接读 CPU 的 LSTAR 寄存器,拿到的地址就是内核系统调用入口。find_bytes里 mask 数组为 0 的位置不参与匹配,这样lea后面的 4 字节立即数无论是什么都能命中。最后一步计算目标地址用的是这条规则:lea的位移是从指令结尾开始算的,指令总长 7 字节,所以inst + 7 + disp才是表基址。
这里有个很小的坑:某些 Windows 补丁版本会把lea r10换成mov r10, [rip+disp32]或者其他等价格式。所以特征码要准备两套,不要只写死一种。我在驱动里一般维护一个特征码数组,依次尝试,直到表地址能被 windbg 验证通过为止。
2.2 特征码扫描的范围与掩码:为什么不能只搜一个固定偏移
新手容易犯的错误是:从网上复制一段特征码,在自己机器上跑通了,就以为万事大吉。实际上KiSystemCall64函数在不同 CPU 及不同累积更新下可能变化,特征码位置也会漂移。更可靠的做法是把整个ntoskrnl.exe的内存范围拿下来扫描,而不是只看KiSystemCall64附近 0x100 字节。
常见做法是先用MmGetSystemRoutineAddress拿到KeExpandKernelStackAndCallout之类的导出函数,然后从它的地址往前推一段固定大小(比如前一个 8KB),那段区域通常覆盖KiSystemCall64。更稳的方式是直接扫描ntoskrnl的基址到入口点,不过那样扫描量太大,驱动初始化会变慢。
// 多候选特征码示例 typedef struct _PATTERN_HINT { UCHAR pattern[8]; UCHAR mask[8]; SIZE_T len; SIZE_T insn_len; // 指令总长度,解析位移需要 } PATTERN_HINT; // 循环尝试每个候选 for (int i = 0; i < candidates_count; i++) { PVOID hit = find_bytes( scan_base, SCAN_LEN, candidates[i].pattern, candidates[i].mask, candidates[i].len ); if (!hit) continue; LONG disp = *(LONG*)((UCHAR*)hit + candidates[i].len - 4); PVOID table = (PVOID)((ULONG64)hit + candidates[i].insn_len + disp); // 这里要做一次合理性检查,比如 table 指针是否可读且 Alignment 为 4 if (IsValidTable(table)) return table; }参数说明:scan_base和SCAN_LEN决定了扫描代价,一般建议控制在几十 KB 以内,避免驱动加载时明显卡顿。insn_len是整条指令的长度,disp从操作码后的第一个字节开始读,所以是hit + len - 4。合理性检查很关键:SSDT 表基址通常是 8 字节对齐的,且 Table 里的前几个表项不会是 0。如果扫出来的地址没对齐,基本可以判断是误命中。
2.3 表结构:四项一组,服务号是下标不是指针
定位到表基址之后,要搞懂 64 位下 SSDT 表项到底是什么。一个常见的认知错误是“表项里存的是函数指针”,这在 32 位下勉强能解释,但 64 位下完全不对。KeServiceDescriptorTable是一个数组,每个元素四个字段:ServiceTable、ArgumentTable、Count、Limit。而ServiceTable指向的那块内存里,每个表项是一个 32 位有符号偏移量,它相对于表基址计算后才是真正的系统服务函数地址。
typedef struct _KSERVICE_TABLE_DESCRIPTOR { LONG *ServiceTable; // SSDT 表项,每个是 LONG 偏移 ULONG *ArgumentTable; // 每个服务参数大小 ULONG Count; // 服务个数 ULONG Limit; } KSERVICE_TABLE_DESCRIPTOR; NTSTATUS resolve_service(ULONG64 ssdt_base, ULONG index, ULONG64* func_addr) { LONG offset = ((LONG*)ssdt_base)[index]; if (offset == 0) { return STATUS_INVALID_PARAMETER; } // 表项值 = 目标函数地址 - 表基址,所以反推就是 base + offset *func_addr = (ULONG64)((UCHAR*)ssdt_base + offset); return STATUS_SUCCESS; }逻辑说明:表项声明成LONG是因为偏移可能是负的,比如某个服务函数位于表基址前面。在 64 位下,函数地址与表基址的差能放进 32 位有符号整数,这也是为什么微软能继续沿用这套结构。用resolve_service可以验证特征码是否正确:把索引值换成NtQuerySystemInformation的编号,解出来的地址必须与 windbg 里u nt!NtQuerySystemInformation一致,不一致就得回去重新扫描。
3. 让只读的 SSDT 变成可写:MDL 和 CR0,两条路都不是省油的灯
3.1 MDL 方式:用正规 API 骗过内存管理器
找到表以后,不能直接拿指针去写,因为 64 位系统把内核只读页真正做成了只读,直接写会触发页错误。最常见的正规做法是构造 MDL,把 SSDT 所在物理页重新映射成可写视图。这里有一个关键点:MDL(Memory Descriptor List)描述的是一段虚拟地址背后的物理页,我们不应该直接修改原始地址,而应该在重新映射出来的地址上写,否则 CRC 或缓存一致性会出问题。
// 把 SSDT 表的前 PageSize 字节映射成可写 PMDL mdl = MmCreateMdl(NULL, ssdt_base, PAGE_SIZE); if (!mdl) return STATUS_INSUFFICIENT_RESOURCES; // 非换页内存,用 MmBuildMdlForNonPagedPool 即可 MmBuildMdlForNonPagedPool(mdl); // 映射到系统地址空间,指定 KernelMode PVOID writable = MmMapLockedPagesSpecifyCache( mdl, KernelMode, MmCached, NULL, 0, MdlSystemVa ); if (!writable) { IoFreeMdl(mdl); return STATUS_INVALID_ADDRESS; } // 在可写映射上修改表项 ((LONG*)writable)[index] = new_offset; // 用完必须解映射、解锁、释放 MmUnmapLockedPages(writable, mdl); MmUnlockPages(mdl); IoFreeMdl(mdl);逻辑说明:MmCreateMdl的第一个参数是进程对象,这里传入 NULL 表示内核地址空间。MmBuildMdlForNonPagedPool会把物理页信息填进 MDL,因为 SSDT 所在内存是永不换页的非分页池。MmMapLockedPagesSpecifyCache里的MdlSystemVa表示映射到系统空间而非用户空间。写入时用的是writable地址,而不是原始ssdt_base。
MDL 方式的好处是“正规”,驱动在DriverVerifier下不会因为写只读页被直接报违规。坏处是:MDL 本身会留下痕迹,PatchGuard 检测时既能检测 SSDT 表内容差异,也能检测到系统地址空间多了一块反常的映射。所以 MDL 只解决了“能写”的问题,没解决“写了不被发现”的问题。
3.2 CR0 方式:关掉 WP 位直接写,简单但容易被 PG 盯上
另一种老派做法是关掉 CR0 的第 16 位(WP 写保护位),直接往原始地址写。Windows 内核在很多兼容路径下还保留了对这招的容忍,但 PatchGuard 会不会因为 CR0 瞬态变化而告警,完全看版本心情。这个操作必须放在 DPC 里执行,并且要保证当前 CPU 是目标 CPU,因为 CR0 是每个核心私有的寄存器,你只改了一个核,其他核照样写不进。
// 必须在 DPC 中执行,且关闭中断防止调度 KIRQL oldIrql = KeRaiseIrqlToDpcLevel(); ULONG64 cr0 = __readcr0(); // 清掉 WP 位(第 16 位) __writecr0(cr0 & ~(1ULL << 16)); // 直接写原始地址 ((LONG*)ssdt_base)[index] = new_offset; // 立刻恢复 __writecr0(cr0); KeLowerIrql(oldIrql);参数说明:KeRaiseIrqlToDpcLevel把当前 CPU 拉到 DPC 级,避免在写寄存器过程中被线程切换打断。1ULL << 16是 WP 位,置 0 后当前核心允许内核写只读页。写表动作很短,所以理论上其他核心不同步也没问题,因为每个核只写自己的映射。如果写的是全局表项,其他核读到的是同一块内存,写操作本身通过缓存一致性传播。
CR0 方式最大的坑是:它修改了全局处理器状态,如果写表过程中发生中断,中断处理程序里可能会因为预期 WP 置位而出现不可预知行为。所以代码里要先关中断或至少抬到 DPC 级。另一个坑在排错时很恼人:写完了立刻读会看到新值,但过了一会儿又变回原样,这是因为系统有线程持续重写表项,或者某个 MDS 映射还在缓存里。别急着怀疑代码,先用!dd直接看物理内存。
3.3 选型对比:实验用 CR0,产品级尽量走 MDL + 更上层方案
| 方式 | 绕过写保护原理 | 对 PatchGuard 的暴露面 | 多核同步 | 产品化难度 |
|---|---|---|---|---|
| MDL 重映射 | 把页映射为可写 | 额外映射本身可能被扫描 | 无特殊要求 | 中 |
| 临时清 CR0.WP | 修改处理器寄存器 | CR0 变化可能被检测 | 必须 DPC 内做 | 低但风险高 |
我一般会建议:如果是调试验证,用 CR0 最快,代码两行;如果要长期稳定,别在 SSDT 表项上硬改,而是考虑 hook 目标函数入口或者用内核回调机制。但标题既然锁定了 SSDT hook,那就得面对一个事实:无论你用 MDL 还是 CR0,都只是拿到了写权限,真正难的是下一步过 PG。
4. 过 PG 的核心思路:不是让 PatchGuard 失明,而是让它按你的脚本运行
4.1 PatchGuard 在查什么:校验点与随机化机制
PatchGuard 不是一个单独函数,而是一整套异步校验框架。它在系统启动早期初始化,注册若干随机周期的 DPC 和线程,每组校验线程都在完成时重新计算下一次触发的位置,你无法提前知道它下一次检查哪个区域。它校验的目标包括 SSDT 表本身、IDT、GDT、MSR 表的持久内容、内核模块头部数据、以及若干敏感全局变量。
校验方式也不是简单读一遍虚拟地址。PatchGuard 使用未导出的内存映射函数直接读物理内存,所以在 64 位下你 hook 了某个内核 API,它仍可能绕过你的 hook 去读原始页。这也是为什么“我改了表,为什么还是蓝屏”的答案常常是:你只挡住了它校验路径上的第一层,它还有第二、第三层备份。
这套框架还有一个互相监视机制:多个校验例程两两来回检查。你 hook 了例程 A,例程 B 会去校验 A 的代码段是否被改动,发现被改就立刻报告。所以单纯 inline hook 一个 PG 函数并不安全,必须在恢复原状后再放开执行。
4.2 方向一:让 PatchGuard 的校验例程跳过你改写的内存
最常见的老派思路是找到负责“遍历待校验目标”的核心函数,在它读取 SSDT 前把表项临时恢复成原始值,等它校验完再写回我们的 hook 地址。这个思路看起来不复杂,真正落地时难在找到那个核心函数,而且每次 Windows 更新都可能换位置。
// 伪代码:在 PG 校验回调里临时恢复表项 VOID MyPgCheckHandler(ULONG64 context) { // 先把 SSDT 表项恢复成原始函数地址 RestoreSstdEntry(index); // 调用原始 PatchGuard 校验逻辑 OriginalPgCheckHandler(context); // 校验结束,重新写回我们的 hook 地址 SetSstdEntry(index, MyHookAddress); }逻辑说明:这个方法的成败取决于时序。PatchGuard 的执行窗口很短,如果你在回调里恢复再写回,理论上是安全的,但实际它可能多路并发校验,或者在校验过程中读取表项两次,导致依然读到 hook 地址。另一种加固方式是拦截它读取 SSDT 的底层函数,比如 MDL 扫描或物理内存映射,让它在读取时永远看到原值。这就是所谓的“让 PG 看假数据”,但实现成本高,需要对特定 Windows 版本逆向清楚。
更实用的公开路线是找 PatchGuard 的全局“开关”标志。老版本里存在一个KiPatchGuardRandomizedCallbacks之类的遍历列表,你只要把它挂钩到一个空函数,整个校验链就断了。这个思路比 hook 每个校验例程都省事,但同样依赖版本,而且新版本把标志做了加密和保护,得现用现逆。
4.3 方向二:避开表项修改,改函数头也能达到 SSDT hook 的效果
如果我们的目标不是“改表”而是“hook 某个系统服务”,还有一个变通:不动 SSDT 表项,只改目标服务函数入口的字节,让它跳转到我们的函数。因为表项没有变化,PatchGuard 对 SSDT 表本身的校验会通过;但它对内核代码段(.text)也有完整校验,发现函数头被改同样蓝屏。
所以这个方向绕一圈还是要解决“代码段被 PG 校验”的问题。常见做法是给目标函数所在的物理页做特殊处理:先把该页内容复制到另一块内存,再把跳板写进去,让 PG 校验时看到的是旧代码。这个工程量比改表更大,而且要求你对 MDL、物理页属性和MiGetPteAddress都不陌生。
我自己的选择是:如果只是试验,方向一够用;如果要做产品,要么接受 Windows 大版本升级就失效,要么直接拥抱回调和 ETW,别和 PatchGuard 死磕。
4.4 落地建议:先隔离环境验证,再谈稳定
过 PG 是版本强相关的工作。同一个驱动在 Windows 10 某个版本上稳定跑几天,升级累积补丁后可能立刻蓝屏。所以我强烈建议:
- 准备一个独立的 VM,开启内核转储,专门用来测 PG;
- 每一次改动前记录当前 Windows 的
ntoskrnl.exe版本号和文件哈希; - 蓝屏后用
!analyze -v看 0x109 的参数,前两个参数会告诉你是哪一类校验触发; - 不要指望“一个 bypass 通吃所有版本”,维护一个特征码和绕过开关的版本表是常态。
这套工作流听起来繁琐,但能省下大量排查时间。毕竟 PatchGuard 翻车时不是报个错,而是直接重启,连日志都不给你。
5. 避坑/常见问题/排查:你八成会卡在这五个地方
5.1 特征码扫到了假表,hook 完重启进不去系统
现象:驱动加载后手动重启,系统在启动 logo 处卡住或直接 0x109 蓝屏,即使不改任何表项,只挂了一个打印日志都进不去。
原因:特征码误命中。KiSystemCall64附近的lea r10, [rip+disp]不只有一条,代码里可能还有访问其他结构体的同类指令。你扫到的是别的结构体,把index当成服务号解析,表基址完全错误。
解决:扫描到结果后不要急着用,先做三层验证:一是检查地址是否 8 字节对齐;二是读取ServiceTable->Count,判断服务数是否在合理范围内(例如几百到几千);三是在 windbg 里用dq对比你算出的表基址和nt!KeServiceDescriptorTable符号地址。如果不一致,调整特征码候选顺序,或者扩大扫描范围。
5.2 写表项成功,但调用系统服务时走的还是旧函数
现象:CR0 写表后读回来确认值变了,但从用户态调用 API 时行为完全没变,观察参数也显示旧函数被调用。
原因:最常见的是你写的是映射地址,而系统调用路径读的是原地址;第二个常见原因是 SSDT 表项在写完后被系统动态重建,比如 PatchGuard 或系统更新服务在启动后重新加载了表;第三个原因是你修改的服务号错了,64 位下用户态 API 到系统服务号的映射并不总是和导出顺序一致。
解决:先用resolve_service把你 hook 的函数地址打印出来,windbg 里用u nt!NtQuerySystemInformation对比。确认表项地址对之后,再在写表后加一个KeInvalidateAllCaches或至少_mm_mfence,避免优化乱序。最后用实时调试器断在KiSystemCall64,观察它查表时读到的表项值,是原始值还是你的 hook 值。
5.3 过 PG 的代码一开就蓝屏,不开反而没事
现象:单独改表项不蓝屏,单独挂 PatchGuard 绕过也不蓝屏,两个逻辑合在一起就触发 0x109。
原因:你的绕过逻辑覆盖范围不够。比如你只 hook 了一个校验函数,但 PatchGuard 还有另一个独立校验线程,它会检测到“第一个校验函数的代码段被修改”或者“SSDT 表项在两次校验之间变过”。这就是 PatchGuard 的多重互检机制,很多初次接触的人会踩。
解决:先别急着加绕过逻辑,直接在 windbg 里把 0x109 的第三、第四参数解开,看它报告的具体地址在被改之前是什么。然后给所有校验分支都加同样的恢复逻辑,不能只处理一条路径。另一个思路是不 hook PG 函数,而是改它的全局状态标志,这样不产生代码段修改,互检时也能通过。
5.4 单核测试正常,多核压力测试随机蓝屏
现象:虚拟机只分一个核时跑多久都没事,改成四核后几分钟内蓝屏,而且每次撕裂代码位置都不一样。
原因:PatchGuard 的校验 DPC 可能在任意 CPU 上跑,也可能同时跑多个。你的 CR0 写表只修改了当前核的 WP 位,其他核读取时冲突;或者你的过 PG hook 只挂在了一个核上,其他核上的 PatchGuard 不受控制。
解决:所有涉及 CR0、MSR 或寄存器级别的改动,都要放到KeGenericCallDpc里让所有核心同步执行。代码里可以用KeIpiGenericCall广播一个函数,在每个核上分别做写保护和写表操作。另外,过 PG 的 hook 也要确保在所有核上都生效,不能用单核的KeSetSystemAffinityThread糊弄过去。
5.5 Windows 更新后特征码全部失效,驱动加载即崩
现象:机器跑了一段时间,某次系统更新重启后驱动加载就蓝屏,或者特征码扫描结果为空。
原因:微软每个月累积更新都可能改变KiSystemCall64指令编码、SSDT 表布局,或者 PatchGuard 初始化逻辑。硬编码特征码和偏移的结果就是每次更新都断一次。
解决:把特征码维护成一个独立数据结构,每次发布驱动前在多个版本环境里重新扫描一次。更稳的做法是优先使用MmGetSystemRoutineAddress导出函数解析,实在不行再退到特征码。对 PatchGuard 部分,建议做一个“失败后自动禁用 hook 并恢复原表”的兜底路径,宁可功能失效也不要蓝屏。
6. 验证 hook 的三种方法,以及我踩过最冤的一次 PatchGuard
验证 hook 是否真的在系统服务派发路径上,最直接的方法是写一个用户态程序调用被测 API,然后在驱动里记录调用参数。以 hookNtQuerySystemInformation为例,你可以在自己的 hook 函数里加一个DbgPrint,再用用户态程序枚举系统信息,看内核调试器是否输出。如果输出了,证明系统服务号解析、写表、过 PG 三个环节全部打通。
第二种验证方法是直接用 windbg 下条件断点:bp MyHookFunction "kd> dv; g"。然后触发调用,观察输入参数是否和数据包一致。这种验证方式不依赖驱动日志,也不受优化干扰,是排错时最可靠的参照。
第三种方法是用 Windows 安全日志作为旁证。如果你的 hook 针对的是进程操作或敏感调用,可以在 hook 里主动写一条审计记录,再去事件查看器确认。但要注意,写日志本身就是高频操作,不要在 hook 路径里做重量级操作,否则系统调用延迟会明显上升,反被 PatchGuard 注意到。
说一个我自己踩过的坑:有次我成功绕过了 PatchGuard 对 SSDT 表的校验,驱动在测试机上跑了一天没蓝屏,结果第二天升级了一个安全补丁后重启直接 0x109。原因是补丁把 PatchGuard 的全局开关位置改了,我的绕过逻辑仍然指向旧地址,等于没绕过。从那以后我养成了一个习惯:每次写内核 hook 之前,先把当前版本、特征码、绕过开关地址做成一条快照记录;发布前再开内核转储跑 72 小时稳定性测试。PatchGuard 这个东西,玄学成分很大,但只要你把自己的每一步都记录清楚,翻车时就能快速定位。希望这篇笔记能让你少走一次我走过的弯路。
本文还有配套的精品资源,点击获取