64位Windows SSDT Hook过PatchGuard实战:从定位到稳定验证
简介面向Windows内核研发与逆向工程人员这份源码包聚焦64位系统下绕过Process GuardPG后修改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 所在页被强制只读系统每隔几分钟就会有一个叫 PatchGuardPG的内核守护来校验这张表你前一秒写进去的 hook 地址后一秒就换来 0x109 蓝屏。所以标题里“过PG”三个字才是这套实现方法真正要解决的问题。这篇文章适合正在做 EDR、反作弊驱动或内核安全研究的开发者。我会沿着“定位 SSDT → 解开写保护 → 绕过 PatchGuard → 验证稳定性”这条路线把每个环节的可执行步骤、参数和翻车点讲清楚。2. 定位 SSDT 表64 位下没有导出表只能特征码扫描2.1 先找到 KiSystemCall64再顺藤摸瓜64 位 Windows 的系统服务派发和 32 位完全是两套逻辑。32 位时代可以直接用中断门走到 KiSystemService再通过导出的KeServiceDescriptorTable去查表64 位下所有用户态系统调用都会先经过 MSR0xC0000082IA32_LSTAR指向的KiSystemCall64这个函数查 SSDT 时会把表基址加载到一个寄存器里。所以我们定位 SSDT 的思路是先读 LSTAR 拿到KiSystemCall64的运行时地址然后在这个函数的前 0x100 字节里扫描一条lea r10, [ripdisp32]指令。这条指令的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, [ripdisp32]) 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, [ripdisp32]或者其他等价格式。所以特征码要准备两套不要只写死一种。我在驱动里一般维护一个特征码数组依次尝试直到表地址能被 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 所在物理页重新映射成可写视图。这里有一个关键点MDLMemory 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, [ripdisp]不只有一条代码里可能还有访问其他结构体的同类指令。你扫到的是别的结构体把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 这个东西玄学成分很大但只要你把自己的每一步都记录清楚翻车时就能快速定位。希望这篇笔记能让你少走一次我走过的弯路。本文还有配套的精品资源点击获取