1. 前言
本文定位为 抛砖引玉 ,分享一些实验性质的思路与代码实现方法。全文在结构上分为两部分:
- 针对 Win11 26H1 内核异常体系进行详细分析,理清异常体系的工作方式。
- 基于现有异常体系,分析出 3 种可以提前接管用户态、内核态异常的利用点,并借这些点实现对抗层面的操作。
笔者最初的原稿包含 4 种提前接管异常的手法,后来发现利用 InstrumentationCallback 提前接管异常的方式论坛内已有师傅公开过,所以发帖前删除了这部分内容,避免重复。
2. Win11 26H1 内核异常体系分析
本次分析的目标系统版本为 Win11 26H1 28000,系统信息如下:

首先可以主动制造一个除零异常:
volatile int Divisor = 0;
volatile int Result = 1 / Divisor;
当 CPU 检测到除零异常后,会抛出 #DE。由于 #DE 的异常向量号为 0,CPU 会找到 IDT 表中第 0 项中断门描述符,从中解析出异常处理回调的函数入口,然后将执行流转移过去。用 WinDbg 调试可以看到:

由于在虚拟机中调试,并未启用内核页表隔离,所以这里的异常处理例程为 KiDivideErrorFault。正常用户的 PC 开启该机制后,CPU 抛出 #DE 实际执行的是 KiDivideErrorFaultShadow。
2.1 内核页表隔离(KVA Shadow)
内核页表隔离并不是 Windows 独有的,在 Linux 下这项技术叫做 KPTI。
早期 Windows、Linux 中,用户态和内核态共用同一份页表基址(CR3)。当前 CR3 除了映射用户地址空间,还会完整映射内核地址空间。由于现代 CPU 存在预测执行机制,以 2018 年公开的 Meltdown(CVE-2017-5754) 为例,攻击者可以构造一段用户态代码,使 CPU 在权限检查完成前短暂读取内核地址中的数据。虽然这次读取最终会被判定为非法并回滚,但 CPU 回滚状态时并不会清空缓存内容,通过侧信道攻击后,R3 就能读到 R0 的数据。
Windows 开启内核页表隔离后,一个进程实际上拥有两套页目录表基址(CR3)。平时 R3 程序运行时使用一份“残缺”的页表,这份页表会映射完整的用户态内容,同时因为异常、中断、系统调用等操作需要进入内核,必须在内核设置一个落脚点,所以还会映射一小部分内核态代码,这部分代码保存在 KVASCODE 节区中。真正进入内核后,再切换到包含完整内核地址空间的页表。
在 KPROCESS 结构中,有两个 CR3 的保存点:
struct _KPROCESS
{
struct _DISPATCHER_HEADER Header; // 0x0
struct _LIST_ENTRY ProfileListHead; // 0x18
ULONGLONG DirectoryTableBase; // 0x28 内核态 CR3
// ...
ULONGLONG UserDirectoryTableBase; // 0x280 用户态 CR3
// ...
};
2.1.1 两套 CR3 引入的效率问题
我们知道不同 CR3 切换时会清空 TLB 缓存(当然,PTE.G = 1 的全局 PTE 会保留)。那么启用 KVA Shadow 后,一个进程有两套 CR3,会不会也导致切换 CR3 时清空 TLB?
实际上 CPU 厂商在这里引入了优化:CR4.PCIDE = 1 启用进程识别标识符后,此时 CR3 的 Bit0~11 位会保存一个标识符。通过这个标识符可以告诉 CPU 这是同一个进程下不同的 CR3 在切换,从而保留 TLB 缓存。
2.2 KiDivideErrorFaultShadow
KiDivideErrorFaultShadow 这个函数本身只是一个桩函数(过渡用,本身不处理异常),代码保存在 KVASCODE 节区中。它首先判断 CS.RPL 来区分本次异常来源是 R3 还是 R0。如果是来自 R0 的异常,直接跳转到 KiDivideErrorFault 处理;如果是来自 R3 的异常,则会做以下事情:
- 将 CR3 切换为完整内核页表。
- 将 KVA Shadow 使用的临时栈切换为真正使用的内核栈。
- 在内核堆栈中重新构造一份异常现场。
- 最后直接 jmp 到真正的
#DE 处理函数。

2.3 KiDivideErrorFault
KiDivideErrorFault 函数开头其实是在堆栈中构造 TrapFrame 对象。由于 #DE 不压入硬件错误码,所以函数入口先保留了 8 字节的槽位,再保存 Rcx、Rdx、R8、R9 等易失寄存器,因为这些寄存器可能包含被中断代码正在使用的临时值:

2.3.1 R0 异常派发路径
如果是来自内核的 #DE 异常,处理流程会简短很多。首先读取 CET 影子栈指针并将其保存到 TrapFrame 中:

关于 CET 机制,笔者的通俗理解如下:
- 某些攻击手法是想办法让栈溢出、覆盖返回地址,做到劫持执行流,跳转执行自己的代码。
- 开启
CET 机制后,call xxx 调用函数时,CPU 会同时将返回地址保存到普通栈和影子栈中。在 ret 返回时,CPU 同时取出两个栈中的返回地址做校验,如果不一致就抛出异常。
- 普通栈、影子栈是相互独立的,很难同时攻击两个栈中的返回地址。
随后执行 lfence 入口屏障,限制 CPU 预测执行不能超过这个位置,并根据每 CPU 的预测执行状态更新 MSR 寄存器:

接着保存 SIMD 浮点现场与 XMM0~XMM5 等易失寄存器现场:

由于 CPU 执行中断门时会自动清除 RFlags.IF,这里会判断异常发生时 CPU 的中断响应状态,并根据先前状态重新设置中断状态:

最后构造内部异常错误码 10000003h 与异常发生时的线性地址,调用 KiExceptionDispatch 公共异常分发入口进行异常派发:
// 根据 x64 调用约定,伪代码如下
KiExceptionDispatch(
0x10000003,
0,
ExceptionAddress
);

2.3.2 R3 异常路径
如果是来自 R3 的 #DE 错误,首先根据 KVA Shadow 状态选择是否切换 GS:

在 KiDivideErrorFaultShadow 中已经将内核过渡栈切换到了内核普通栈,但由于 CET 保护机制 的存在,还需要将过渡影子栈切换到内核影子栈:

继续往下看汇编,会发现大量重复的 call xxx + add rsp, 8 组合,这样的组合有 32 组:

这里的汇编涉及 CPU 中的 RSB 机制,笔者理解如下:
- RSB 叫做 Return Stack Buffer,可以理解为 CPU 内部专门预测 ret 返回地址的小型硬件栈。
- 每次
call xxx 时,会将返回地址同时压入普通栈和 RSB 硬件栈。当 CPU 执行到 ret 返回时,会提前读取 RSB 中的返回地址,提前取指令、译码,减少流水线等待空窗,提高执行效率。
结合本文的异常处理,由于本次 R3 异常路径是在 R3 程序中出现的,导致 RSB 中可能已经存储了大量 R3 的返回地址,那么 CPU 就有可能拿着 R3 返回地址进行错误的预测执行,这是非常危险的。
所以这些大量调用的 call xxx 作用就很明显了:
// 1. 将返回地址压入普通栈
// 2. 将预测返回地址压入 CPU 的 RSB
call xxx
// 丢弃普通栈上的返回地址,但是保留 RSB 中的返回地址
add rsp, 8
如此循环 32 次后,RSB 会被覆盖为内核态的返回地址,这个过程叫做 RSB stuffing。同时由于 CET 影子栈 保护机制的存在,这样循环 32 次 call xxx 会导致 Shadow Stack 也保存 32 条内核态返回地址,所以还需要同步影子栈:

随后判断当前进程是否处于调试状态,如果是则保存调试寄存器:

之后的代码其实属于 R3、R0 的公共部分:保存 MxCsr 与 XMM0~XMM5、尝试重新启用中断、调用 KiExceptionDispatch 派发异常:

2.4 KiExceptionDispatch
函数入口处抬升堆栈,在栈中构造 ExceptionRecord、ExceptionFrame 对象,将 XMM6~XMM15、rbx、rdi、rsi、r12~r15 等非易失寄存器保存到 KEXCEPTION_FRAME 中:

继续构造 ExceptionRecord 对象,填充异常错误码 0x10000003、ExceptionAddress 等:

构造好 ExceptionRecord、ExceptionFrame 后,准备参数调用 KiDispatchException 继续派发异常:

在 KiDispatchException 返回后,会有一次派发用户 APC 回调的机会:

2.5 KiDispatchException
KiDivideErrorFault、KiExceptionDispatch 等函数主要功能是完成异常的记录,也有朋友称为异常登记,指的是同一件事。KiDispatchException 函数则负责异常的派发。函数开头会增加异常派发计数:

2.5.1 上下文转换(构造 Context)
当前函数中,有 TrapFrame 对象保存着易失寄存器环境,还有 ExceptionFrame 对象保存着非易失寄存器环境。两者结合就可以还原出异常发生时完整的 Context 上下文。
转换过程为:获取 Context 对象大小、初始化 Context 对象、将 TrapFrame 和 ExceptionFrame 转为 Context:

2.5.2 内部错误码转换
通过前文可知,#DE 除零错误的内部错误码为 0x10000003。系统会调用 KiPreprocessFault 将内部错误码转为熟知的 C0000094:

2.5.3 R0 异常路径
首先分析 R0 发生 #DE 异常时的处理路径:会判断 FirstChance 是否成立,如果成立,就尝试先将本次异常派发给内核调试器。
这里存在一个内核全局变量 KdpDebugRoutineSelect,由它控制调用 KdTrap 还是 KdStub。这是一个伏笔,后续章节会涉及到该全局变量。

不管是调用 KdTrap 还是 KdStub,返回 TRUE 则代表内核调试器顺利处理了该异常,会将 Context 恢复到 TrapFrame 中,之后 KiDispatchException 函数正常返回:

如果内核调试器没有处理异常,KdTrap、KdStub 就会返回 FALSE,此时会调用 RtlDispatchException 函数,将异常派发到内核 SEH,尝试让程序自己处理:

如果程序的 SEH Handler 顺利处理了该异常,RtlDispatchException 返回 TRUE,将 Context 恢复到 TrapFrame 后正常返回:

若 RtlDispatchException 返回 FALSE,说明内核 SEH 没有处理该异常(程序没有注册 SEH 或 SEH 未处理)。此时进入内核异常的“二次派发”。这个叫法可能不太严谨,因为 KiDispatchException 函数本身并没有被二次调用。
内核异常的二次派发实际上就是再次将异常派发给内核调试器。如果这次内核调试器还不处理,则调用 KeBugCheckEx 触发蓝屏:

内核异常派发的流程图:

2.5.4 R3 异常路径(FirstChance 路径)
先看 #DE 异常来自用户态且为首次派发的情况。
系统会检测内核全局变量 KdIgnoreUmExceptions 的状态。如果为 FALSE,则将该用户态异常派发到内核调试器。如果内核调试器顺利处理了异常,会将 Context 恢复到 TrapFrame 后正常返回:

如果内核调试器没有处理,则调用 DbgkForwardException 将异常派发给用户态调试器(即 Windbg、x64Dbg 等)。该函数返回 TRUE 说明 R3 调试器处理了异常,KiDispatchException 正常返回,本次异常派发流程结束:

如果该 3 环程序没有挂载调试器或调试器选择不处理,DbgkForwardException 会返回 FALSE。此时轮到用户态的 VEH、SEH 处理异常了。但这些回调处于 R3 用户态,必须退回到 R3 环境才能执行。所以需要构造返回 R3 的用户栈,这个过程与用户 APC 回调非常相似。
要安全地访问用户态内存,需要使用 ProbeForWrite 来探测用户态内存,之后在用户栈中填充 ExceptionRecord 与 Context:



退回到用户态的栈环境布置好后,接下来修改 TrapFrame 中的 Rsp、Rip。这里的 Rip 就是用户态 ntdll!KiUserExceptionDispatcher 函数,该函数是用户态异常分发的落脚点。很多游戏安全厂商喜欢 Hook 这个点,以最早监控到用户态异常动态:

完成以上步骤后,KiDispatchException 正常返回,结束 FirstChance 异常派发。退回到用户态继续执行时,就是 ntdll!KiUserExceptionDispatcher 的工作了,它主要做以下几件事:
- 从堆栈拿异常信息
ExceptionRecord 与异常上下文 Context。
- 调用
RtlDispatchException:内部首先遍历 ntdll 中的一个全局链表,该链表保存着所有用户注册的 VEH 异常回调,逐个调用 VEH 回调处理异常。如果 VEH 没处理,则调用 LookupFunctionEntry 尝试找到该函数的异常信息,让 SEH 来处理。
RtlDispatchException 返回 TRUE 则说明 VEH 或 SEH 处理成功,调用 RtlGuardRestoreContext,这个函数内部最终调用 ZwContinue 恢复到原代码处继续执行。
- 如果返回
FALSE 则说明用户态异常处理失败,调用 ZwRaiseException 模拟触发一次异常派发。

由于本文主要针对 Win11 内核异常体系,用户态部分碍于篇幅不再详细展开。
2.5.5 R3 异常路径(SecondChance 路径)
上一小节提到,如果用户态异常没有处理成功,会调用 ZwRaiseException 模拟产生异常派发,这就是用户态异常的二次派发。视角继续回到 KiDispatchException 函数。
用户异常的二次派发就简单多了:尝试将异常派发给用户调试器、异常端口,如果都没处理,最后结束当前进程:

2.5.6 R3 异常路径小结
用户态的异常派发相比内核态异常过程要麻烦得多,中间还涉及构建用户栈、回退用户态、再次派发异常进入内核态。笔者第一次分析 Windows 这套异常体系时也被迷惑了好一阵。这里画了一张粗糙的流程图,或许能帮初学者理清流程:

2.6 Win11 26H1 异常体系总结
从整个异常处理流程来看,大体上遵循 异常记录、异常派发、异常处理 三个步骤:保存被打断的执行现场,将 CPU 硬件事件转换为统一的 Windows 异常对象,再根据异常来源和处理机会逐层派发,最后使用修改后的上下文恢复执行,或者终止无法恢复的执行主体。
KVA Shadow、CET、RSB 等机制相关代码,更多是出于安全考虑,并不等同于异常处理逻辑本身。
3. 提前接管用户态异常方法 1
经过上半部分的铺垫,对 Windows 异常体系有了初步认识后,接下来分析有哪些可以利用的点。
3.1 ntdll!KiUserExceptionDispatcher
回顾一下:内核态退回到用户态让 VEH、SEH 处理异常时,落脚点为 KiUserExceptionDispatcher 函数。该函数完整反汇编如下:

注意函数开头部分,首先判断全局变量 Wow64PrepareForException 是否为空,不为空则提前调用该回调。这个回调的调用时机明显早于 RtlDispatchException,也就是说它的优先级高于所有用户 VEH、SEH 异常处理:

用 x64Dbg 附加一个 64 位程序调试,会发现大部分应用程序的 Wow64PrepareForException 变量其实是空的:

那么这个点就可以利用起来:手动把自己的回调“注册”进去,这样每次你自己的异常回调就能早于所有 VEH、SEH 提前处理异常。
3.2 ntdll!Wow64PrepareForException
方向很明确:找到 ntdll!Wow64PrepareForException 全局变量,把回调函数写进去。如何定位呢?
笔者最早想到的是特征码硬搜,但检查发现 ntdll!KiUserExceptionDispatcher 这个函数是导出的,这就方便了:

注册异常回调函数的部分代码如下:
- 获取
ntdll!KiUserExceptionDispatcher 函数地址。
- 从中匹配
mov rax, xxx 的 3 字节特征码。
- 解引用算出
Wow64PrepareForException 全局变量地址。
- 将自己的异常回调写进去,同时保存原有的回调指针。
BOOLEAN
InstallExceptionHook(
_In_ fn_ExceptionCallback ExceptionCallback
)
{
BOOLEAN Ret = FALSE;
do
{
if (nullptr == ExceptionCallback)
{
break;
}
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
if (nullptr == hNtdll)
{
break;
}
_NtContinue = (fn_NtContinue)GetProcAddress(hNtdll, "NtContinue");
if (nullptr == _NtContinue)
{
break;
}
PVOID pKiUserExcpetionDispatch = GetProcAddress(hNtdll, "KiUserExceptionDispatcher");
if (nullptr == pKiUserExcpetionDispatch)
{
break;
}
BOOLEAN bIsFound = FALSE;
PUCHAR pByetCode = (PUCHAR)pKiUserExcpetionDispatch;
while (TRUE)
{
if (pByetCode[0] == 0xC3 && pByetCode[1] == 0xCC)
{
break;
}
if (pByetCode[0] == 0x48 && pByetCode[1] == 0x8B && pByetCode[2] == 0x05)
{
bIsFound = TRUE;
break;
}
pByetCode++;
}
if (!bIsFound)
{
break;
}
_ExceptionCallback = ExceptionCallback;
LONG Offset = *(LONG*)(pByetCode + 3);
pWow64PrepareForException = (fn_ExceptionCallback*)(pByetCode + 7 + Offset);
DWORD OldProtrect = 0;
VirtualProtect(
(PVOID)PAGE_ALIGN(pWow64PrepareForException),
USN_PAGE_SIZE,
PAGE_READWRITE,
&OldProtrect
);
Old_ExceptionCallback =
(fn_ExceptionCallback)_InterlockedExchangePointer(
(volatile PVOID*)pWow64PrepareForException,
Wow64PrepareForException_Hook
);
VirtualProtect(
(PVOID)PAGE_ALIGN(pWow64PrepareForException),
USN_PAGE_SIZE,
OldProtrect,
&OldProtrect
);
Ret = TRUE;
} while (FALSE);
return Ret;
}
回调本身的实现如下:
- 出于工程解耦的考虑,
_ExceptionCallback 才是外部注册时传进来的异常处理回调。
- 如果
_ExceptionCallback 返回 TRUE,则调用 ntdll!NtContinue 直接 吞掉本次异常,不再继续调用 VEH、SEH。
- 如果返回
FALSE 选择不处理,Wow64PrepareForException_Hook 直接返回,不影响正常的 VEH、SEH 调用。
VOID
Wow64PrepareForException_Hook(
_In_ PEXCEPTION_RECORD ExceptionRecord,
_In_ PCONTEXT ContextRecord
)
{
if (_ExceptionCallback(ExceptionRecord, ContextRecord))
{
_NtContinue(ContextRecord, FALSE);
}
else
{
if (nullptr != Old_ExceptionCallback)
{
Old_ExceptionCallback(ExceptionRecord, ContextRecord);
}
}
return VOID();
}
测试代码如下,可以看到自定义异常处理器确实早于 VEH、SEH 接管了异常处理:

4. 无痕 Hook 代码实验(以无痕 Hook 发包函数为例)
现在有了一个可以最早接管用户态异常的手法,笔者想做一个有意思的实验来证明其价值:利用这个手法实现一个 无痕 Hook 实验。
当然,这里的无痕 Hook 并不是绝对意义上的无痕,而是指 不破坏目标代码处的任何机器码,让常规的代码 CRC 校验失效。
笔者挑选的目标函数是网络通讯中最常用的 send 函数。
实验思路如下:
- 注册
Wow64PrepareForException,提前接管异常。
- 定位
send 函数所在模块信息,如模块基址、模块大小。
- 完完整整复制一份目标模块,作为影子模块
ShadowModule。
- 向
send 函数所在页面注入一个页面异常,这样 send 每次被调用都会触发异常。
- 异常回调接管到异常后,判断异常发生地址、错误码是否是我们关注的。
- 如果是,根据
ExceptionAddress 相对原模块的偏移,将 Ctx->Rip 重定向到 ShadowModule 中对应位置。
- 最后在
ShadowModule 中的 send 函数处挂上 inline Hook,就能像常规逆向一样监控、过滤参数信息。

4.1 制造影子模块(ShadowModule)
传入要 Hook 的函数(以 send 为例),找到其所在模块基址、解析 PE 头得到模块大小,再完整构造一份 ShadowModule。部分关键代码如下:
PVOID
BuildShadowModule(
_In_ PVOID pFuncAddr
)
{
PVOID ShadowFuncAddr = nullptr;
do
{
if (nullptr == pFuncAddr)
{
break;
}
PVOID ImageBase = nullptr;
if (!RtlPcToFileHeader(pFuncAddr, &ImageBase))
{
break;
}
ULONG64 FuncOffset = (ULONG64)pFuncAddr - (ULONG64)ImageBase;
SHADOW_MODULE_INFO* pExisting = FindModuleByBase(ImageBase);
if (nullptr != pExisting)
{
ShadowFuncAddr = (PVOID)((ULONG64)pExisting->ShadowImageBase + FuncOffset);
break;
}
PIMAGE_DOS_HEADER pDosHeader = (PIMAGE_DOS_HEADER)ImageBase;
PIMAGE_NT_HEADERS64 pNtHeaders =
(PIMAGE_NT_HEADERS64)((PUCHAR)pDosHeader + pDosHeader->e_lfanew);
ULONG SizeOfImage = pNtHeaders->OptionalHeader.SizeOfImage;
PVOID ShadowModule = VirtualAlloc(
NULL,
SizeOfImage,
MEM_COMMIT,
PAGE_EXECUTE_READWRITE
);
if (nullptr == ShadowModule)
{
break;
}
ULONG PageNum = SizeOfImage >> 12;
BOOLEAN CopySuccess = TRUE;
for (ULONG i = 0; i < PageNum; i++)
{
ULONG CurOffset = i * USN_PAGE_SIZE;
BOOL ReadRet = ReadProcessMemory(
GetCurrentProcess(),
(PUCHAR)ImageBase + CurOffset,
(PUCHAR)ShadowModule + CurOffset,
USN_PAGE_SIZE,
NULL
);
if (FALSE == ReadRet)
{
CopySuccess = FALSE;
VirtualFree(ShadowModule, 0, MEM_RELEASE);
break;
}
}
if (!CopySuccess)
{
break;
}
SHADOW_MODULE_INFO ShadowInfo = { 0 };
ShadowInfo.OriginalImageBase = ImageBase;
ShadowInfo.ShadowImageBase = ShadowModule;
ShadowInfo.ImageSize = SizeOfImage;
PIMAGE_SECTION_HEADER pSectionHeader = IMAGE_FIRST_SECTION(pNtHeaders);
for (WORD i = 0; i < pNtHeaders->FileHeader.NumberOfSections; i++)
{
if (memcmp(pSectionHeader[i].Name, ".data", 5) == 0)
{
ShadowInfo.OriginalDataSectionAddr =
(PVOID)((ULONG64)ImageBase + pSectionHeader[i].VirtualAddress);
ShadowInfo.ShadowSectionDataAddr =
(PVOID)((ULONG64)ShadowModule + pSectionHeader[i].VirtualAddress);
ShadowInfo.DataSectionSize = pSectionHeader[i].Misc.VirtualSize;
break;
}
}
g_ShadowModules.push_back(ShadowInfo);
ShadowFuncAddr = (PVOID)((ULONG64)ShadowModule + FuncOffset);
} while (FALSE);
return ShadowFuncAddr;
}
4.2 向目标函数页面注入异常(隐藏不可执行页面)
构造好影子模块后,就要让 send 所在页面注入页面异常。常见方式是使用 VirtualProtect 修改页面属性:
- 修改页面保护属性为
PAGE_READONLY、PAGE_READWRITE 等,总之让页面不可执行。
- 修改页面保护属性,增加
PAGE_GUARD。
这些套路确实可以达到注入页面异常的效果,但痕迹太明显。安全程序扫描时直接调用 VirtualQuery 就能查询目标页面的内存保护属性,很容易发现保护属性被修改了。
所以要 隐藏目标页面的不可执行属性。这里笔者想到了 DEP 数据执行保护 机制。简单讲解一下(口语化讲解,不保证严谨):
早期 Windows 系统里,应用程序虽然在设计上区分代码段、数据段(堆栈区),但这只是逻辑上的划分,数据段(堆栈区)其实也可以运行代码。
举一个经典的栈溢出攻击例子:调用 recv 接收网络数据包时,攻击者精心构造一个包含 shellcode 的数据包。数据包接收后栈溢出覆盖栈上的 RetAddress,函数返回时就跑去执行攻击者的 shellcode,实现劫持执行流、植入木马。
后来 Windows 与 CPU 厂商设计了 DEP 数据执行保护机制,通过硬件层面的强控制来保证数据区(堆栈区)不能执行代码。
DEP 在硬件层面的设计是:将 PTE 的最高位保留为 Nx 不可执行属性位。由于 Windows 段页模式存在 2MB 大页面的情况,PDE 的最高位也保留为 Nx 不可执行属性位。
当 PTE.Nx = 1 后,该 PTE 控制的 4KB 页面不可执行代码;当 PDE.Nx = 1 后,该 PDE 控制的 2MB 大页面都不可执行代码。
设置目标页面代码不可执行的代码如下:
typedef struct HARDWAREPTE_X64
{
ULONG64 valid : 1; // [0]
ULONG64 write : 1; // [1]
ULONG64 owner : 1; // [2]
ULONG64 write_through : 1; // [3]
ULONG64 cache_disable : 1; // [4]
ULONG64 accessed : 1; // [5]
ULONG64 dirty : 1; // [6]
ULONG64 large_page : 1; // [7]
ULONG64 global : 1; // [8]
ULONG64 copy_on_write : 1; // [9]
ULONG64 prototype : 1; // [10]
ULONG64 reserved0 : 1; // [11]
ULONG64 page_frame_number : 36; // [12:47]
ULONG64 reserved1 : 4; // [48:51]
ULONG64 software_ws_index : 11; // [52:62]
ULONG64 no_execute : 1; // [63]
} HARDWAREPTE, *PHARDWAREPTE;
BOOLEAN
SetPageNonExecutable(
ULONG64 VirtualAddress,
ULONG Size
)
{
ULONG64 EndAddress = (ULONG64)PAGE_ALIGN(VirtualAddress + Size);
ULONG64 StartAddress = (ULONG64)PAGE_ALIGN(VirtualAddress);
while (EndAddress >= StartAddress)
{
HARDWAREPTE* Pde = (HARDWAREPTE*)GetPde(StartAddress);
if (MmIsAddressValid(Pde) && Pde->valid && !Pde->large_page)
{
HARDWAREPTE* Pte = (HARDWAREPTE*)GetPte(StartAddress);
if (MmIsAddressValid(Pte) && Pte->valid)
{
Pte->no_execute = 1;
}
}
StartAddress += PAGE_SIZE;
}
return TRUE;
}
设置硬件 PTE 来隐藏不可执行页面的效果如下:虽然 CE 表面显示该页面保护属性为 PAGE_EXECUTE_READ,但实际上该页面已被注入页面执行异常:

4.3 修复控制流防护问题(CFG)
构造好 ShadowModule、目标函数页面注入执行异常后,就要写异常处理回调中的逻辑了,关键步骤如下:
- 判断异常错误码是不是
STATUS_ACCESS_VIOLATION,不是就放过。
- 如果是页面访问异常,继续判断
ExceptionAddress 是否处于 ws2_32.dll 模块内。
- 如果是,计算
ExceptionAddress 到原模块的偏移。
- 根据偏移将
Rip 一比一重定向到影子模块中。
BOOLEAN
ExceptionHandler(
_In_ PEXCEPTION_RECORD ExceptionRecord,
_In_ PCONTEXT ContextRecord
)
{
if (STATUS_ACCESS_VIOLATION == ExceptionRecord->ExceptionCode)
{
ShadowModule::SHADOW_MODULE_INFO ShadowInfo = { 0 };
if (!ShadowModule::QueryByExceptionAddress(
ExceptionRecord->ExceptionAddress,
&ShadowInfo
))
{
return FALSE;
}
ULONG64 FuncOffset =
(ULONG64)ExceptionRecord->ExceptionAddress -
(ULONG64)ShadowInfo.OriginalImageBase;
ContextRecord->Rip = (ULONG64)ShadowInfo.ShadowImageBase + FuncOffset;
return TRUE;
}
return FALSE;
}
编译出 exe、drv 放入 Win11 26H1 实验,结果程序直接崩溃了。挂上 x64Dbg 看看情况:
当前崩溃的 ExceptionAddress = 12B701980C0,这个 Rip 地址看起来属于影子模块 ShadowModule 范围内:

看栈顶的返回地址 000000242BFFF5D8,反汇编窗口显示问题点很清晰,十有八九是 call 12B6FDEC010 出了问题:

跟进这个 call 看看,果然是 jmp 12B701980C0 跳转到了异常的代码段。由于这里是相对跳转 E9 xxxx,相对偏移跳不到正确代码,这就与步骤 1 的现象吻合了:

用 IDA 反汇编 ws2_32.dll 原始模块对比,发现原来是 CFG 控制流防护相关的代码:

到了这里就豁然开朗了。解决思路如下:
- 众所周知,CFG 控制流防护的
_guard_dispatch_icall 会将真实调用的函数地址放在 rax 寄存器中。
- 那直接动态 Patch 影子模块中的
_guard_dispatch_icall,将其修改为直接 jmp rax 即可。
修复 CFG 的关键代码:
BOOLEAN
FixCFG_ws2_32(
_In_ ULONG64 ModuleAddress
)
{
const auto ResolveCallRel32 = [](ULONG64 callAddress) -> ULONG64
{
if (!callAddress || *reinterpret_cast<BYTE*>(callAddress) != 0xE8)
{
return NULL;
}
const auto relativeOffset =
*reinterpret_cast<std::int32_t*>(callAddress + 1);
return callAddress + 5 + relativeOffset;
};
SearchCode::cSearchCode CodeSearcher;
ULONG64 CodeAddr = CodeSearcher.SearchModule(
ModuleAddress,
"48 8D 54 24 ?? 4C 89 7C 24 ?? 48 8D 4C 24 ?? E8 ?? ?? ?? ?? 89 44 24 ?? 85 C0 0F 84 ?? ?? ?? ?? EB ??",
PAGE_EXECUTE_READWRITE
);
if (NULL == CodeAddr)
{
return FALSE;
}
ULONG64 ResolveAddr = ResolveCallRel32(CodeAddr + 15);
if (NULL == ResolveAddr)
{
return FALSE;
}
// Jmp rax
*(USHORT*)((PUCHAR)ResolveAddr) = 0xE0FF;
return TRUE;
}
4.4 修复全局变量不一致
修复 CFG 问题后再次运行,又崩溃了。挂上 x64Dbg 分析:
rdx 寄存器为空,明显访问了空指针。
rdx 来源于全局变量 000002A93348AB28 解引用。
- 在内存区域查看该全局变量,确实是空的。

问题根源很清晰:源模块与影子模块虽然代码区域一模一样,但 全局变量本质上是独立的。劫持执行流后,在影子模块中访问全局变量出现了上下文不一致。
解决思路:想办法让原始模块、影子模块的 全局变量区域内存变为共享 —— 虽然用户态线性地址不一样,但最终指向的物理页面是同一份。
笔者首先想到修改 PTE.page_frame_num 修改 PTE 的物理页帧号,使两个线性地址都指向同一份 4KB 物理页:
BOOLEAN
MapAddress_PTE(
ULONG64 SrcVa,
ULONG64 DstVa,
ULONG Size
)
{
ULONG64 SrcStartAddress = (ULONG64)PAGE_ALIGN(SrcVa);
ULONG64 DstStartAddress = (ULONG64)PAGE_ALIGN(DstVa);
ULONG64 EndOffset = (ULONG64)PAGE_ALIGN((PVOID)(SrcVa + Size));
ULONG64 PageCount = (EndOffset - SrcStartAddress) / PAGE_SIZE;
if (PageCount == 0)
{
PageCount = 1;
}
for (ULONG64 i = 0; i < PageCount; i++)
{
ULONG64 CurrentSrcVa = SrcStartAddress + (i * PAGE_SIZE);
ULONG64 CurrentDstVa = DstStartAddress + (i * PAGE_SIZE);
HARDWAREPTE* SrcPde = (HARDWAREPTE*)GetPde(CurrentSrcVa);
HARDWAREPTE* DstPde = (HARDWAREPTE*)GetPde(CurrentDstVa);
if (!MmIsAddressValid(SrcPde) || !SrcPde->valid)
{
return FALSE;
}
if (!MmIsAddressValid(DstPde) || !DstPde->valid)
{
return FALSE;
}
HARDWAREPTE* SrcPte = (HARDWAREPTE*)GetPte(CurrentSrcVa);
HARDWAREPTE* DstPte = (HARDWAREPTE*)GetPte(CurrentDstVa);
if (!MmIsAddressValid(SrcPte) || !SrcPte->valid)
{
return FALSE;
}
if (!MmIsAddressValid(DstPte) || !DstPte->valid)
{
return FALSE;
}
DstPte->page_frame_number = SrcPte->page_frame_number;
__invlpg((PVOID)CurrentDstVa);
}
return TRUE;
}
但很遗憾,经实验发现:修改 PTE 页帧号处理普通内存页面时,确实可以实现内存共享(稳定较长时间不蓝屏)。但涉及 DLL 模块内的内存时,修改后 1 分钟内必定蓝屏,错误码为 MEMORY_MANAGEMENT。
直接改硬件 PTE 的路走不通。笔者最终选择通过 MDL 内存映射实现内存页面共享:
- 释放影子模块
.data 节区的内容,让该线性地址释放出来。
- 调用
MDL 内存映射,让系统将指定的物理页面映射到笔者指定的线性地址处。
BOOLEAN
MapAddress_MDL(
ULONG64 SrcVa,
ULONG64 DstVa,
ULONG Size
)
{
ULONG64 SrcStartAddress = (ULONG64)PAGE_ALIGN(SrcVa);
ULONG64 DstStartAddress = (ULONG64)PAGE_ALIGN(DstVa);
ULONG64 EndOffset = (ULONG64)PAGE_ALIGN((PVOID)(SrcVa + Size));
ULONG64 PageCount = (EndOffset - SrcStartAddress) / PAGE_SIZE;
if (PageCount == 0)
{
PageCount = 1;
}
for (ULONG64 i = 0; i < PageCount; i++)
{
ULONG64 CurrentSrcVa = SrcStartAddress + (i * PAGE_SIZE);
ULONG64 CurrentDstVa = DstStartAddress + (i * PAGE_SIZE);
if (!MmIsAddressValid((PVOID)CurrentSrcVa))
{
DbgPrint("[MapAddress] Source address 0x%llx is invalid\n", CurrentSrcVa);
return FALSE;
}
PVOID BaseAddress = (PVOID)CurrentDstVa;
SIZE_T RegionSize = PAGE_SIZE;
NTSTATUS status = ZwFreeVirtualMemory(
NtCurrentProcess(),
&BaseAddress,
&RegionSize,
MEM_RELEASE
);
if (!NT_SUCCESS(status))
{
DbgPrint("[MapAddress] Failed to decommit target address 0x%llx, status=0x%x\n",
CurrentDstVa, status);
}
PMDL Mdl = IoAllocateMdl((PVOID)CurrentSrcVa, PAGE_SIZE, FALSE, FALSE, NULL);
if (!Mdl)
{
DbgPrint("[MapAddress] Failed to allocate MDL for 0x%llx\n", CurrentSrcVa);
return FALSE;
}
__try
{
MmProbeAndLockPages(Mdl, UserMode, IoModifyAccess);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
DbgPrint("[MapAddress] Failed to lock pages for 0x%llx\n", CurrentSrcVa);
IoFreeMdl(Mdl);
return FALSE;
}
PVOID MappedAddress = MmMapLockedPagesSpecifyCache(
Mdl,
UserMode,
MmCached,
(PVOID)CurrentDstVa,
FALSE,
NormalPagePriority
);
if (!MappedAddress)
{
DbgPrint("[MapAddress] Failed to map to address 0x%llx\n", CurrentDstVa);
MmUnlockPages(Mdl);
IoFreeMdl(Mdl);
return FALSE;
}
if (MappedAddress != (PVOID)CurrentDstVa)
{
DbgPrint("[MapAddress] Mapped to 0x%p instead of requested 0x%llx\n",
MappedAddress, CurrentDstVa);
MmUnmapLockedPages(MappedAddress, Mdl);
MmUnlockPages(Mdl);
IoFreeMdl(Mdl);
return FALSE;
}
if (!AddMdlMapEntry(CurrentDstVa, Mdl))
{
DbgPrint("[MapAddress] Failed to track MDL for 0x%llx\n", CurrentDstVa);
MmUnmapLockedPages(MappedAddress, Mdl);
MmUnlockPages(Mdl);
IoFreeMdl(Mdl);
return FALSE;
}
DbgPrint("[MapAddress] Successfully mapped 0x%llx -> 0x%llx (MDL tracked)\n",
CurrentSrcVa, CurrentDstVa);
}
return TRUE;
}
4.5 给影子模块挂 Inline Hook
经过前面的调试处理后,已经可以较稳定地将执行流劫持到影子模块中。接下来给影子模块中的 send 函数挂 inline hook,正常监控参数信息:
int
WINAPI
MySend(
_In_ SOCKET s,
_In_reads_bytes_(len) const char FAR* buf,
_In_ int len,
_In_ int flags
)
{
printf("--------------------------------------------------- \n");
printf("[+] Send Hook - Socket: %llu, Length: %d\n", (ULONG64)s, len);
printf("[+] Data:%s \n", buf);
printf("--------------------------------------------------- \n");
return g_OriginalSend(s, buf, len, flags);
}
从调试器可以看到,send 处的代码没有任何修改,但实际上确实被 Hook 并正常输出参数:

这部分代码已整理好,Git 仓库地址见文末。
5. 提前接管用户态异常方法 2
前文介绍了一种 Hook ntdll!Wow64PrepareForException 实现提前接管用户态异常的方法,但毕竟是用户态的对抗。一些游戏安全厂商、安全开发者往往会直接 Hook ntdll!KiUserExceptionDispatcher 来总管用户态异常,那么我们的行为还是有可能被监控、扫描出来。
有没有一种方法,在退回到用户态之前,直接在内核态提前接管用户态异常?
有的。下面介绍一种 类似 Hook nt!KiDispatchException 的方法,在内核态提前接管用户异常。
前文对 Win11 内核异常体系和无痕 Hook 的分析都属于前期铺垫,现在把对抗层级拉到内核层。
5.1 nt!PsPicoDispatchException
重新回到 nt!KiDispatchException 中分析,函数开头会连续判断 3 个条件:
- 是否为首次异常派发。
- 判断当前进程对象是否为空。
- 判断当前进程
Process.PicoContext 是否为空。

如果以上 3 点都满足,则不调用 KiPreprocessFault 将 #DE 内部错误转换为文档化错误码 C0000094:

随后从全局变量 xmmword_140F05660 取出一个函数指针,通过 CFG 发起一次间接函数调用。如果该函数返回 TRUE,则 KiDispatchException 提前返回。所以 该函数的异常接管优先级非常高,早于内核调试器、R3 调试器、用户 VEH、用户 SEH。
这个函数调用实际是 nt!PsPicoDispatchException,它与 WSL 子系统有关。笔者粗浅理解如下:
由于 WSL Linux 子系统的存在,子系统中的进程(WSL 进程)内同样会出现各种异常,需要系统异常体系派发处理。但 WSL 进程毕竟不是原生 Win 进程,微软希望由 WSL 子系统本身优先接管处理异常。
异常派发中如何区分原生 Win 进程与 WSL 子系统进程?靠的就是 EPROCESS.PicoContext 字段是否为 NULL。没错,判断条件非常单薄。

5.2 分析 PicoProvider 注册流程
由于是从全局变量 xmmword_140F05660 取出函数指针再通过 CFG 发起间接调用,简单粗暴的方法就是直接特征码搜索到这处代码,然后把我们自己的异常回调写进去。
这种方法是可行的,但笔者认为并非最优解。首先涉及特征码,多版本兼容时工程维护麻烦;其次系统异常派发的关键代码路径上,涉及到的全局变量可能有 PG 保护。
所以笔者的思路是:既然这是给 WSL 子系统准备的异常处理,那么能否通过合法方式将自己的 Pico 处理回调注册进去?既免去后期特征码维护,还让系统自己把异常回调写到全局变量 xmmword_140F05660 中。
查看全局变量的交叉引用,发现 PsRegisterPicoProvider 函数处有调用:

更惊喜的是,这个函数居然是导出函数。函数本身不长:
- 判断参数 1 的第一个成员是否等于 88。
- 判断参数 2 的第一个成员是否等于 96。
- 判断
[参数1 + 72] 处成员高 11 位是否为 0。
- 判断
[参数1 + 76] 处成员高 11 位是否为 0。
- 全局变量
PspPicoRegistrationDisabled = 0,否则当前禁止注册 Pico 异常回调。
- 不满足以上任意一项,Pico 注册函数都返回失败。

使用 Windbg 调试发现,Win11 26H1 正常启动进入桌面后,全局变量 PspPicoRegistrationDisabled 会被置 1。看来微软并不想让其他驱动随便注册 Pico 回调:


继续分析 PsRegisterPicoProvider,以下代码功能很清晰:将参数 1 中的各个字段成员保存到全局变量中,参数 2 作为输出参数接收结果:

根据汇编反推参数 1、参数 2 的部分对象结构:
typedef struct _PS_PICO_EXCEPTION_ARG1
{
ULONG64 Size;
PVOID Pad1[3];
PVOID DispatchException; // 关键:自定义的异常处理回调指针
PVOID Pad2[2];
ULONG Flag[2];
PVOID Pad3[3];
} PS_PICO_EXCEPTION_ARG1, *PPS_PICO_EXCEPTION_ARG1;
typedef struct _PS_PICO_EXCEPTION_ARG2
{
ULONG64 Size;
PVOID Pad1[11];
} PS_PICO_EXCEPTION_ARG2, *PPS_PICO_EXCEPTION_ARG2;
5.3 注册成为 PicoProvider
Pico 回调注册流程已经分析清楚,注册思路如下:
- 从 nt 导出表找到
PsRegisterPicoProvider 函数指针。
- 构造注册参数 1、参数 2,补齐必要字段信息,避免注册函数检查失败。
- 修改
PspPicoRegistrationDisabled 控制变量,手动置零。
- 调用
PsRegisterPicoProvider 注册 Pico 回调。
- 还原
PspPicoRegistrationDisabled。由于该变量修改窗口期非常小,不会对系统运行有什么影响。
typedef
BOOLEAN
(*fn_PicoExceptionHandler)(
_In_ PEXCEPTION_RECORD ExceptionRecord,
_In_ PKEXCEPTION_FRAME ExceptionFrame,
_In_ PKTRAP_FRAME TrapFrame,
_In_ ULONG Unknown,
_In_ KPROCESSOR_MODE PreviousMode
);
BOOLEAN
InstallPicoExceptionHook(
_In_ fn_PicoExceptionHandler ExceptionHandler
)
{
BOOLEAN Ret = FALSE;
NTSTATUS Status;
do
{
if (NULL == ExceptionHandler)
{
break;
}
UNICODE_STRING FuncName;
RtlInitUnicodeString(&FuncName, L"PsRegisterPicoProvider");
_PsRegisterPicoProvider =
(fn_PsRegisterPicoProvider)MmGetSystemRoutineAddress(&FuncName);
if (NULL == _PsRegisterPicoProvider)
{
break;
}
_pPspPicoRegistrationDisabled =
SearchPspPicoRegistrationDisabled(_PsRegisterPicoProvider);
if (NULL == _pPspPicoRegistrationDisabled)
{
break;
}
PS_PICO_EXCEPTION_ARG1 ProviderRoutines = { 0 };
ProviderRoutines.Size = 88;
ProviderRoutines.DispatchException = ExceptionHandler;
ProviderRoutines.ProtectedRanges[0] = 0;
ProviderRoutines.ProtectedRanges[1] = 0;
PS_PICO_EXCEPTION_ARG2 PicoRoutines = { 0 };
PicoRoutines.Size = 96;
*_pPspPicoRegistrationDisabled = 0;
Status = _PsRegisterPicoProvider(&ProviderRoutines, &PicoRoutines);
*_pPspPicoRegistrationDisabled = 1;
if (!NT_SUCCESS(Status))
{
break;
}
Ret = TRUE;
} while (FALSE);
return Ret;
}
5.4 将目标进程设置为 WSL 子进程
注册好 Pico 异常处理回调后,该回调只能提前接管 WSL 子进程的异常。如何将目标进程变为 WSL 子进程?非常简单,只需将 EPROCESS.PicoContext 字段成员设置一个任意非零值即可,这里直接设置为 1:
// 0x840 bytes (sizeof)
struct _EPROCESS
{
struct _KPROCESS Pcb; // 0x0
struct _EX_PUSH_LOCK ProcessLock; // 0x1c8
VOID* UniqueProcessId; // 0x1d0
// ...
VOID* PicoContext; // 0x640
// ...
};
VOID
SetProcessPicoContext(
_In_ PEPROCESS Process
)
{
ULONG PicoContextOffset = GetPicoContextOffset();
*(PUCHAR)((PUCHAR)Process + PicoContextOffset) = 1;
}
5.5 利用 Pico 异常处理回调实现无痕 Hook
实现以上步骤后,目标进程发生用户态异常时,我们的 Pico 回调就能最高优先级接管该异常,早于内核调试器、用户调试器,更不用说 VEH、SEH。
将前文的无痕 Hook 实现中的异常处理部分改动一下,就能实现更隐蔽的效果:
BOOLEAN
ExceptionHandler(
PEXCEPTION_RECORD ExceptionRecord,
PKEXCEPTION_FRAME ExceptionFrame,
PKTRAP_FRAME TrapFrame,
ULONG Unknown,
KPROCESSOR_MODE PreviousMode
)
{
UNREFERENCED_PARAMETER(ExceptionFrame);
UNREFERENCED_PARAMETER(Unknown);
UNREFERENCED_PARAMETER(PreviousMode);
if (g_TargetProcess == PsGetCurrentProcess() &&
STATUS_ACCESS_VIOLATION == ExceptionRecord->ExceptionCode)
{
SHADOW_EXCEPTION_INFO ShadowInfo = { 0 };
if (!QueryShadowModule(
(ULONG64)ExceptionRecord->ExceptionAddress,
&ShadowInfo
))
{
return FALSE;
}
ULONG64 FuncOffset =
(ULONG64)ExceptionRecord->ExceptionAddress - ShadowInfo.OriginalImageBase;
TrapFrame->Rip = ShadowInfo.ShadowImageBase + FuncOffset;
return TRUE;
}
return FALSE;
}
这部分代码已整理好,Git 仓库地址见文末。
5.6 利用 Pico 异常回调实现反调试效果
既然能最早接管目标进程的用户态异常,就很容易实现各种反调试手法。
第一种常见思路是 破坏调试现场。攻击方使用调试器分析代码时,一个重要过程就是看寄存器列表、反推参数意义。暴力清空所有寄存器是非常明显且强烈的反调试信号,等于提示调试者“这里有反调试措施”。所以笔者的思路是:魔改一份寄存器现场,让寄存器内容看起来非常正常,但调试者尝试断点、单步时就会出现各种乱七八糟的情况,误导调试者以为程序数据结构很乱,增加时间成本。
第二种反调试方式更直接——退出自身进程:
BOOLEAN
ExceptionHandler(
PEXCEPTION_RECORD ExceptionRecord,
PKEXCEPTION_FRAME ExceptionFrame,
PKTRAP_FRAME TrapFrame,
ULONG Unknown,
KPROCESSOR_MODE PreviousMode
)
{
UNREFERENCED_PARAMETER(ExceptionFrame);
UNREFERENCED_PARAMETER(Unknown);
UNREFERENCED_PARAMETER(PreviousMode);
if (g_TargetProcess == PsGetCurrentProcess() &&
(STATUS_BREAKPOINT == ExceptionRecord->ExceptionCode ||
STATUS_SINGLE_STEP == ExceptionRecord->ExceptionCode))
{
// 结束自身进程,报一个栈溢出的假退出码
ZwTerminateProcess((HANDLE)-1, STATUS_STACK_OVERFLOW);
return TRUE;
}
return FALSE;
}
调试者看到的效果就是:断点一命中,程序就退出了。
5.7 WSL 子进程进程保护
笔者测试将任务管理器进程 Process.PicoContext 置 1,将其变为 WSL 子进程。此时使用普通的调试器,如 x64Dbg、CE 都无法打开进程:
x64Dbg 进程列表找不到目标程序。
CE 无法打开目标进程并报错。

关于这种进程保护的原理,碍于篇幅这里不展开详细分析过程。笔者之前在 IDA 中粗略分析了一下,原理如下:
NtOpenProcess 尝试打开进程时,会判断目标进程是不是 WSL 子进程,如果是就返回错误码 STATUS_ACCESS_DENIED,从而达到了进程保护的效果。
6. 提前接管用户态、内核态异常方法
6.1 Win11 26H1
前文介绍了 2 种提前接管用户态异常的方法,但局限在于仅能接管用户态异常。这里介绍在 Win11 26H1 下可以同时提前接管用户态、内核态异常的思路。
回到 KiDispatchException 函数,已知需要将异常派发到内核调试器时,会调用 KdTrap 或者 KdStub 函数:

利用点在 KdTrap 中。大量无关中间调用这里不展开,直到最后的 KeStallExecutionProcessor 函数:

在 KeStallExecutionProcessor 函数中,会取出 HalpStallCounter 对象下 0x70 的函数指针,最后通过 CFG 发起间接函数调用。看到这里,对 ETW Hook 有所了解的朋友应该猜到劫持异常的原理了——又是 Halxxx 指针导致的问题。
已知不管是用户态还是内核态异常,系统都先尝试将异常派发到内核调试器。此时替换 Halxxx 中的函数指针,并配合修改沿途的一些标志位,使得最终调用到我们的处理回调中,就有机会提前接管异常。这个实现思路跟 ETW Hook(或 InfinityHook)非常相似:


6.2 Win11 25H2 及以下
着手点还在 KdTrap 中,直到最后的 KeQueryPerformanceCounter 函数:

在 KeQueryPerformanceCounter 函数中,会取出 HalpPerformanceCounter 对象下 0x70 的函数指针,最后通过 CFG 发起间接函数调用。没错,又又是 Halxxx 指针导致的问题:


首先特征码定位 HalpPerformanceCounter 对象:
ULONG64
SearchHalpPerformanceCounter()
{
static ULONG64 HalpPerformanceCounter = 0;
if (HalpPerformanceCounter)
{
return HalpPerformanceCounter;
}
ULONG64 Addr = SearchFeatureCode(
(PCCHAR)"ntoskrnl.exe",
(PCCHAR)".text",
(PCUCHAR)"\x48\x8B\x35\xFC\xE3\xD6\x00\x48\x89\x7C\x24\x28\x4C\x89\x74\x24\x20\x83\xBE\xE4\x00\x00\x00\x05\x0F\x84\x0A\x01\x00\x00\x83\xBE\xDC\x00\x00\x00\x40\x48\x8B\x9E\xC0\x00\x00\x00",
"xxx????xxxx?xxxx?xx?????xx????xx?????xxx????",
0
);
if (NULL == Addr)
{
return NULL;
}
ULONG64* pHalpPerformanceCounter = DEREF_RELATIVE_ADDR(ULONG64*, Addr, 3);
HalpPerformanceCounter = *pHalpPerformanceCounter;
return HalpPerformanceCounter;
}
6.2.2 替换 Hal 函数指针
搜索成功后,替换其中 0x70 处的 Hal 函数指针,替换为自己的异常处理函数:
g_HalpHvCounterQueryCounter =
(fn_HalpHvCounterQueryCounter)_InterlockedExchangePointer(
(volatile PVOID*)(HalpPerformanceCounter + 0x70),
HalpHvCounterQueryCounter_Hook
);
__int64
HalpHvCounterQueryCounter_Hook(
PVOID Arg1,
PVOID Arg2
)
{
_InterlockedIncrement64((volatile LONG64*)&g_Counter);
// 筛选出异常调用来源
if (ExceptionStackTrace())
{
_InterlockedIncrement64((volatile LONG64*)&g_ExceptionCounter);
// 从堆栈中恢复丢失的 ExceptionRecord、Context 等
CONTEXT* Context = NULL;
EXCEPTION_RECORD* ExceptionRecord = NULL;
ResumeExceptionInfoFromStack(&Context, &ExceptionRecord);
if (STATUS_ACCESS_VIOLATION == ExceptionRecord->ExceptionCode)
{
// ...
}
// 构造中断返回栈,恢复异常派发
ContinueExecution();
}
return g_HalpHvCounterQueryCounter(Arg1, Arg2);
}
6.2.3 堆栈回溯筛选出异常派发调用
由于 Hook 点位于 KeQueryPerformanceCounter,存在大量非异常调用来源,所以要回溯堆栈信息筛选出异常派发来源。笔者这里的判定比较简单:在堆栈中找到 KeThawExecution、KdpReport、KiDispatchException 范围附近的返回地址,便认为本次调用来源属于异常派发:
BOOLEAN
ExceptionStackTrace()
{
PVOID* StackBase = (PVOID*)__readgsqword(0x1A8);
PVOID* StackCurrent = (PVOID*)_AddressOfReturnAddress();
if ((ULONG64)StackBase - (ULONG64)StackCurrent > 0x6000 ||
StackCurrent > StackBase)
{
return FALSE;
}
BOOLEAN bFindKeThawExecution = FALSE;
BOOLEAN bFindKdpReport = FALSE;
BOOLEAN bFindKiDispatchException = FALSE;
for (; StackCurrent < StackBase; ++StackCurrent)
{
ULONG64 StackValue = *(ULONG64*)StackCurrent;
if (StackValue <= (ULONG64)MM_HIGHEST_USER_ADDRESS)
{
continue;
}
if (!bFindKeThawExecution)
{
if (StackValue > g_TrackStackFunc.KeThawExecution &&
StackValue < g_TrackStackFunc.KeThawExecution + 0x100)
{
bFindKeThawExecution = TRUE;
}
continue;
}
if (!bFindKdpReport)
{
if (StackValue > g_TrackStackFunc.KdpReport &&
StackValue < g_TrackStackFunc.KdpReport + 0x150)
{
bFindKdpReport = TRUE;
}
continue;
}
if (!bFindKiDispatchException)
{
if (StackValue > g_TrackStackFunc.KiDispatchException &&
StackValue < g_TrackStackFunc.KiDispatchException + 0x100)
{
bFindKiDispatchException = TRUE;
return TRUE;
}
}
}
return FALSE;
}
6.2.4 从堆栈中恢复丢失的异常信息
顺利筛选出异常派发的调用来源后,新问题来了:多层函数调用后,原始 ExceptionRecord、Context 等参数已经丢失,并没有传递给我们的 Hook 异常回调。
此时可以分析上层函数的调用逻辑,向上回溯堆栈,尝试 从上层函数栈帧中恢复异常参数。根据 x64 调用约定,子函数使用非易失寄存器时由被调用方自己保存、恢复。这里列出整个调用链中每个函数的序言部分:
; KdpTrap
PAGEKD:0000000140B662B8 48 8B C4 mov rax, rsp
PAGEKD:0000000140B662BB 48 89 58 08 mov [rax+8], rbx
PAGEKD:0000000140B662BF 48 89 68 10 mov [rax+10h], rbp
PAGEKD:0000000140B662C3 48 89 70 20 mov [rax+20h], rsi
PAGEKD:0000000140B662C7 57 push rdi
PAGEKD:0000000140B662C8 48 83 EC 40 sub rsp, 40h
; KdpReport
.text:00000001404CEF84 48 8B C4 mov rax, rsp
.text:00000001404CEF87 48 89 58 08 mov [rax+8], rbx ; Context
.text:00000001404CEF8B 48 89 68 10 mov [rax+10h], rbp
.text:00000001404CEF8F 48 89 70 18 mov [rax+18h], rsi
.text:00000001404CEF93 48 89 78 20 mov [rax+20h], rdi
.text:00000001404CEF97 41 54 push r12
.text:00000001404CEF99 41 56 push r14
.text:00000001404CEF9B 41 57 push r15
.text:00000001404CEF9D 48 83 EC 20 sub rsp, 20h
; KdExitDebugger
PAGEKD:0000000140B66008 40 53 push rbx
PAGEKD:0000000140B6600A 48 83 EC 20 sub rsp, 20h
; KeThawExecution
.text:00000001404EE390 48 89 5C 24 08 mov [rsp+arg_0], rbx
.text:00000001404EE395 48 89 74 24 10 mov [rsp+arg_8], rsi
.text:00000001404EE39A 57 push rdi
.text:00000001404EE39B 48 83 EC 20 sub rsp, 20h
; KeQueryPerformanceCounter
.text:0000000140254AD0 40 53 push rbx
.text:0000000140254AD2 41 54 push r12
.text:0000000140254AD4 41 57 push r15
.text:0000000140254AD6 48 83 EC 30 sub rsp, 30h
从 KdpTrap 函数开始分析:该函数将 Context 保存到了 rbx 这个非易失寄存器中。由于调用的 KdpReport 函数内部使用了 rbx,会将原 rbx 保存到栈上。那么我们可以向上回溯堆栈,找到一个返回地址处于 KdpTrap 函数范围内的,即可找到其栈帧位置,从中恢复 Context 异常上下文信息:

6.2.5 构造中断返回帧,恢复异常派发流程
接管异常后,不能从正常栈中取出返回地址一层层返回,需要从 KeQueryPerformanceCounter 直接折返到 KdpTrap 的返回地址处,即直接跳转回 KiDispatchException 函数内:

要做到这一点,需要保证进入、退出 KdpTrap 函数时非易失寄存器现场一致,向上回溯栈帧恢复所有被破坏的非易失寄存器环境。部分实验代码如下:
typedef struct _EXECUTION_CONTEXT
{
ULONGLONG RFlags; // +0x00
ULONGLONG Rax; // +0x08
ULONGLONG Rcx; // +0x10
ULONGLONG Rdx; // +0x18
ULONGLONG Rbx; // +0x20
ULONGLONG Rsp; // +0x28
ULONGLONG Rbp; // +0x30
ULONGLONG Rsi; // +0x38
ULONGLONG Rdi; // +0x40
ULONGLONG R8; // +0x48
ULONGLONG R9; // +0x50
ULONGLONG R12; // +0x58
ULONGLONG R13; // +0x60
ULONGLONG R14; // +0x68
ULONGLONG R15; // +0x70
ULONGLONG Rip; // +0x78
} EXECUTION_CONTEXT, *PEXECUTION_CONTEXT;
VOID
ContinueExecution()
{
PVOID* StackBase = (PVOID*)__readgsqword(0x1A8);
PVOID* CurStackPtr = (PVOID*)_AddressOfReturnAddress();
if ((ULONG64)StackBase - (ULONG64)CurStackPtr > 0x6000 ||
CurStackPtr > StackBase)
{
return VOID();
}
BOOLEAN bFindKdpReport = FALSE;
BOOLEAN bFindKdpTrap = FALSE;
BOOLEAN bFindKdTrap = FALSE;
EXECUTION_CONTEXT ExecutionContext = { 0 };
ExecutionContext.R13 = Asm_GetR13();
ExecutionContext.RFlags = Asm_GetRFlags() | 0x200;
for (; CurStackPtr < StackBase; ++CurStackPtr)
{
ULONG64 StackValue = *(ULONG64*)CurStackPtr;
if (StackValue <= (ULONG64)MM_HIGHEST_USER_ADDRESS)
{
continue;
}
if (!bFindKdpReport)
{
if (StackValue > g_TrackStackFunc.KdpTrap &&
StackValue < g_TrackStackFunc.KdpTrap + 0x100)
{
ExecutionContext.Rbx = *(ULONG64*)((ULONG64)CurStackPtr + 0x08);
ExecutionContext.Rbp = *(ULONG64*)((ULONG64)CurStackPtr + 0x10);
ExecutionContext.Rsi = *(ULONG64*)((ULONG64)CurStackPtr + 0x18);
ExecutionContext.Rdi = *(ULONG64*)((ULONG64)CurStackPtr + 0x20);
ExecutionContext.R12 = *(ULONG64*)((ULONG64)CurStackPtr - 0x8);
ExecutionContext.R14 = *(ULONG64*)((ULONG64)CurStackPtr - 0x10);
ExecutionContext.R15 = *(ULONG64*)((ULONG64)CurStackPtr - 0x18);
bFindKdpReport = TRUE;
}
continue;
}
if (!bFindKdTrap)
{
if (StackValue > g_TrackStackFunc.KiDispatchException &&
StackValue < g_TrackStackFunc.KiDispatchException + 0x100)
{
ExecutionContext.Rip = StackValue;
ExecutionContext.Rsp = (ULONG64)CurStackPtr + 8;
ExecutionContext.Rbx = *(ULONG64*)((ULONG64)CurStackPtr + 0x08);
ExecutionContext.Rbp = *(ULONG64*)((ULONG64)CurStackPtr + 0x10);
ExecutionContext.Rsi = *(ULONG64*)((ULONG64)CurStackPtr + 0x18);
ExecutionContext.Rdi = *(ULONG64*)((ULONG64)CurStackPtr - 0x8);
bFindKdTrap = TRUE;
break;
}
}
}
if (bFindKdpReport && bFindKdTrap)
{
ContinueWithContext(&ExecutionContext);
KeBugCheck(0);
}
else
{
DbgBreakPoint();
KeBugCheck(0);
}
return VOID();
}
6.2.6 大量踩坑
这一部分从劫持异常的原理来说不算非常难,但上手写代码、调试时遇到了大量踩坑,感受最强烈的两点:
- 这种劫持异常的点正好处在系统派发异常到内核调试器的路径上,导致调试比较困难。笔者甚至遇到了无限异常递归的尴尬情况:虚拟机没问题,但 Windbg 一直接收不到异常。
- 挂内核调试器与不挂内核调试器似乎会改变系统内核异常派发逻辑。不挂调试器时加载驱动卡死,挂上 WinDbg 后再加载驱动问题却消失了。
由于以上两点问题的存在,这个实验本身进度非常缓慢,达不到笔者心中可公开 PoC 的标准。所以笔者决定暂时不放出这部分实验的工程实现。对这部分内容感兴趣的朋友可以关注一下,日后工程完善了会放出实验工程文件。
7. Git 仓库开源地址
劫持 Wow64PrepareForException 接管异常,实现无痕 Hook,仓库地址:InvisibleHook_ShadowPage
注册 PicoDispatchException 接管异常,实现无痕 Hook,仓库地址:InvisibleHook_PicoException
本文为看雪论坛精华文章,由 __Shinn 原创,转载请注明来自看雪社区。