各位看雪论坛的朋友大家好,我是__Shinn,凌晨码字,精神有点迷糊,帖子中如果存在表述不严谨、甚至出错的地方,欢迎在评论区指正
本文定位为抛砖引玉,分享一些实验性质的思路、代码实现方法,本文在结构上大体分为了以下2部分
笔者最初原稿是4种提前接管异常的手法,后来发现,利用InstrumentationCallback提前接管异常,论坛内已经有师傅发文公开了,所以发帖前删除了这部分内容,避免重复赘述
本文涉及到的大部分工程文件已经整理好并开源到Github,欢迎给仓库Star
本次分析的目标系统版本为Win11 26H1 28000,如图所示:

首先我们可以主动制造一个除零异常,如下所示:
当CPU检测到除零异常后会抛出#DE。由于#DE的异常向量号为0,所以CPU会找到IDT表中第0项中断门描述符,从中解析出异常处理回调的函数入口,将执行流转移到该入口。使用WinDbg调试如下所示:

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

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

如果时来自内核的#DE异常,处理流程会简短很多,首先读取CET影子栈指针并将其保存到TrapFrame中

这里涉及到的CET机制,笔者比较通俗的理解如下:
随后执行lfence入口屏障,限制CPU预测执行不能超过这个位置,根据每 CPU 的预测执行状态更新Msr寄存器

保存SIMD浮点现场、保存XMM0~XMM5等易失寄存器现场

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

构造内部异常错误码10000003h、异常发生时的线性地址,调用KiExceptionDispatch公共异常分发入口进行异常派发

如果时来自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返回时,CPU会提前读取RSB中的返回地址,提前取指令、译码,减少流水线执行的等待空窗,提高CPU执行效率
结合本文的异常处理,由于本次异常(R3异常路径)是在R3程序中出现的,就导致RSB中可能已经存储了大量R3的返回地址,那么CPU就有可能拿着R3返回地址进行错误的预测执行,这是非常危险的。
写到这里,这些大量调用的call xxx的作用就非常明显了,其作用如下:
如此循环32次后,RSB中会被覆盖为内核态的返回地址,这个过程叫做RSB stuffing。同时由于CET影子栈保护机制的存在,如此循环执行32次call xxx,会导致Shadow Stack 也会保存32条内核态返回地址,所以还需要同步影子栈,如图所示:

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

之后的代码其实算是R3、R0的公共代码了,都是保存MxCsr、XMM0~XMM5、尝试重新启用中断、调用KiExceptionDispatch派发异常,如下所示:

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

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

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

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

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

在当前函数中,有着TrapFrame对象,其中保存着易失寄存器环境。还有着ExceptionFrame对象,其中保存着非易失寄存器环境。通过两者结合,就可以还原出异常发生时完整的Context上下文,有过异常开发、异常处理相关经验的朋友对这个对象肯定不陌生
转换的过程为:获取Context对象大小、初始化Context对象、将TrapFrame和ExceptionFrame转为Context

通过前文我们可以知道,#DE除零错误的内部错误码为0x10000003,会调用KiPreprocessFault将内部错误码转为熟知的C0000094,代码如下:

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

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

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

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

RtlDispatchException函数返回FALSE,说明内核SEH没有处理该(程序没有注册SEH、或者SEH没处理)异常,此时就会进入到内核异常的二次派发,当然,笔者称其为二次派发可能不太严谨,因为KiDispatchEXception函数本身并没有二次调用
内核异常的二次派发其实就是再次将异常派发给内核调试器,如果这次内核调试还不处理异常,则调用KeBugCHeckEx触发蓝屏

内核异常的派发,笔者画了一个粗糙的流程图:

我们首先来看一下如果#DE异常来自用户态,并且为首次派发的情况。
会检测内核全局变量KdIgnoreUmExceptions的状态,如果为FALSE,那么会将该用户态异常派发到内核调试器,如果内核调试器顺利处理了该异常,会将Context恢复到TrapFrame中,之后KiDispatchException函数正常返回

如果内核调试器没有处理该异常,则调用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),内部主要做了以下几件事:

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

各位朋友看到这里应该发现了,用户态的异常派发相比内核态异常过程要麻烦得多,因为用户态异常的处理中间还涉及到构建用户栈、回退用户态、再次派发异常进入内核态,笔者当年首次分析Windows这套异常体系时,就被迷惑了好一段时间,这里笔者画了一张粗糙的流程图,或许可以帮助初学的朋友理清流程:

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

我们注意看函数的开头部分,首先判断一个全局变量Wow64PrepareForException是否为空,如果不为空则提前调用该回调,该回调的调用时机是明显早于RtlDispatchException的,即异常处理的优先级高于所有的用户VEH、SEH异常处理的

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

那么这个点我们可以利用起来,手动将我们自己的一个回调"注册"进去,这样每次自己的异常回调不就早于所有的VEH、SEH提前处理异常了?
现在方向非常明确了,我们要找到ntdll!Wow64PrepareForException这个全局变量,将我们的回调函数写进该变量中,那么如何定位到该变量呢?
笔者最早想到的是直接特征码硬搜呗,但是检查了一下发现,ntdll!KiUserExceptionDispatcher这个函数其实是导出的,那就很方便了

所有我们的注册异常回调函数,部分代码如下如下:
到了这里我们注册好了异常处理回调,那么这个回调本身笔者的写法如下所示:
测试代码如下所示,自定义异常处理器确实早于VEH、SEH接管了异常处理:

现在有了一个可以最早接管用户态异常的手法,笔者在想是不是应该做一个有意思的实验来证明其价值。所以就利用这个手法实现一个无痕Hook实验吧
当然,这里的无痕Hook并不是绝对意义上的无痕,指的是我们不会破坏目标代码处的任何机器码,让常规的代码CRC校验失效
笔者这里挑选的目标函数,是网络通讯中最常用的send函数
笔者的实验思路、粗糙画图如下:

制造影子模块其实很简单,传入我们要Hook的函数,这里以send发包函数为例,找到其所在模块基址、解析PE头得到模块大小,再完完整整构造一份ShadowModule,部分关键代码如下所示:
在构造好影子模块后,就要想办法让send函数所在的页面注入页面异常了,很常见的方式是将目标函数所在页面使用VirtualProtect,修改页面属性为以下几种:
这种都是非常常见的应用层套路,单论效果来说,确实可以达到注入页面异常的效果,但是这样痕迹太明显了,安全程序扫描时直接调用VirtualQuery就能查询目标页面的内存保护属性,很轻易就能发现页面保护属性被修改了
所以我们目前的发现,就是要隐藏目标页面的不可执行属性,该如何实现呢,这里笔者想到了DEP数据执行保护机制,简单讲解一下这是什么(很口语化的讲解,不保证严谨):
在很早期的Win系统时期,当时的应用程序虽然在设计上区分出来代码段、数据段(堆栈区),但是这仅仅是逻辑上的划分,数据段(堆栈区)其实也是可以运行代码的
笔者这里举一个非常经典的栈溢出攻击例子:调用recv接收网络数据包时,攻击者可以精心构造一个数据包,其中其实是一段shellcode,当数据包接收后,会栈溢出覆盖栈上的RetAddress,这样函数返回时就会跑去执行攻击者的shellcode,做到劫持执行流,可以给目标PC植入木马
所以后来Win系统跟CPU厂商合计了一下发现,嘶~这是个大问题啊,得想办法修好这个问题。所以就设计出了DEP数据执行保护机制,通过硬件层面的强控制,来保证数据区(堆栈区)不能执行代码
DEP数据执行保护在硬件层面设计上,是将PTE的最高位保留为Nx不可执行属性位,由于Win的段页模式存在2MB大页面的情况,所以PDE的最高位也保留为了Nx不可执行属性位。
当PTE.Nx = 1后,该PTE所控制的4kb页面就是不可执行代码的。当PDE.Nx = 1后,该PDE所控制的2MB大页面都是不可执行代码的
设置目标页面代码不可执行,关键代码如下所示:
我们设置硬件PTE来隐藏不可执行页面,效果如下所示,虽然内存监控工具CE表面上显示该页面保护属性为PAGE_EXECUTE_READ可执行可读,但是实际上该页面已经被注入了页面执行异常

我们构造好了ShadowModule、目标函数页面注入了执行异常,那么我们要着手写异常处理回调中的逻辑了,关键代码如下所示:
我们现在编译出exe、drv进行实验,放到Win11 26H1中进行实验,打开程序结果直接崩溃了,好吧果然不能一次过
我们挂上x64Dbg调试器看看是啥情况,笔者第一次看到这个反汇编窗口,懵了一下,代码怎么跑飞到这种地方来了,没招了分析吧




既然定位到了问题的根源,要解决该问题就轻松多了,笔者的解决思路如下:
修复CFG控制流防护问题的关键代码如下:
在修复了CFG控制流防护问题后,再次运行程序,这个崩溃果然解决了
但是随之而来程序又崩溃了,额,挂上x64Dbg调试器再看看,如图所示,笔者分析过程如下:

笔者首先想到了修改PTE.page_frame_num修改PTE的物理页帧号,使2个线性地址都指向同一份4KB物理页,实现代码如下所示:
但是很遗憾,经过笔者实验后发现,这种修改PTE页帧号的手法,在处理一些普通的内存页面时,的确可以实现内存共享(稳定挺长时间不蓝屏)。但是涉及到dll模块内的内存时,修改后系统1分钟之内必定会出现蓝屏错误,错误码为MEMORY_MANAGEMENT
直接修改硬件PTE的方式似乎走不通了,笔者这里选择的是通过MDL内存映射实现了内存页面共享,实现思路如下:
经过前文的各种调试、处理后,的确可以较为稳定地将代码执行流劫持到影子模块中
那么接下来完美就可以自由地做手脚了,笔者这里给影子模块中的send函数下inline hook,可以正常监控参数信息,如下所示:
使用调试器,send处的代码没有任何修改,但是实际上确实是被我们Hook了,并正常输出参数

这部分代码已经整理好,Git仓库地址见文末
前文介绍了一种Hook ntdll!Wow64PrepareForException实现提前接管用户态异常的方法,但是毕竟是用户态的对抗,一些游戏安全厂商、安全开发者往往会直接Hook ntdll!KUserExceptionDispatcher实现总管用户态异常,那么我们提前接管用户态异常的行为,还是有可能会被监控、扫描出来
那么有没有一种方法?
在退回到用户态之前,直接在内核态提前接管用户态异常呢?
有的兄弟,有的,笔者接下来将会介绍一种类似Hook nt!KiDispatchException的方法,实现在内核态提前接管用户异常
前文长篇分析Win11内核异常体系、无痕Hook,都属于前期铺垫,现在笔者将带着各位朋友,把对抗的层级拉到内核层,本文精彩的部分才刚刚开始
我们重新回到nt!KiDispatchException中进行分析,在该函数的开头,会连续判断3个条件是否成立:

如果以上3点都满足,那么不调用KiPreprocessFault函数提前将#DE内部错误转换为文档化的错误码C0000094

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

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

我们直接定位到该函数处,更惊喜的发现,这个函数居然还是导出!这个函数本身不长,我们直接来看:

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


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

根据以上汇编,笔者反推参数1、参数2的部分对象结构,如下所示:
Pico回调的注册流程已经分析清楚了,那么我们的注册思路、关键代码如下:
我们现在注册好了Pico异常处理回调,但是该回调只能提前接管WSL子进程的异常,那么如何将目标进程变为WSL子进程呢?非常检查,只需要将EPROCESS.PicoContext字段成员设置一个任意非零值即可,笔者这里直接将其设置为1
实现以上步骤后,当目标进程发生用户态异常后,我们的Pico回调就能实现最高接管该异常,早于内核调试器、用户调试器,至于VEH、SEH更不必多说,此时我们可以将前文的无痕Hook实现,异常处理部分改动一下,就能实现更加隐蔽的无痕Hook效果,关键代码、效果图如下所示:

传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!
最后于 17小时前
被__Shinn编辑
,原因: