首页
社区
课程
招聘
[原创]Win11 26H1内核异常体系分析与实验:提前接管用户态、内核态异常,实现无痕Hook、进程保护、反调试,附PoC
发表于: 17小时前 391

[原创]Win11 26H1内核异常体系分析与实验:提前接管用户态、内核态异常,实现无痕Hook、进程保护、反调试,附PoC

17小时前
391

​ 各位看雪论坛的朋友大家好,我是__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 xxxadd 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的公共代码了,都是保存MxCsrXMM0~XMM5、尝试重新启用中断、调用KiExceptionDispatch派发异常,如下所示:

图片描述

​ 函数入口处抬升堆栈,在栈中构造ExceptionRecordExceptionFrame对象,将XMM6~XMM15rbxrdirsir12~r15等非易失寄存器保存到KEXCEPTION_FRAME中:

图片描述

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

图片描述

​ 构造好ExceptionRecordExceptionFrame对象后,构造参数调用KiDispatchException继续派发异常:

图片描述

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

图片描述

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

图片描述

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

​ 转换的过程为:获取Context对象大小、初始化Context对象、将TrapFrameExceptionFrame转为Context

图片描述

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

图片描述

​ 首先来分析R0发生#DE异常时的处理路径:会判断FirstChance是否成立,如果成立,就尝试先将本次异常派发给内核调试器`

​ 这里存在一个内核全局变量KdpDebugRoutineSelect,由这个全局变量控制调用KdTrap还是KdStub,这是一个伏笔,后续章节会涉及到该全局变量

图片描述

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

图片描述

​ 如果内核调试器没有处理异常,KdTrapKdStub就会返回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,此时轮到用户态的VEHSEH处理异常了,但是这些异常处理回调处于R3用户态,必须退回到R3环境才能执行这些回调,所以我们需要构造返回R3的用户栈,该过程跟用户Apc回调非常相似

​ 要安全地访问用户态内存,要使用ProbeForWrite来探测用户态内存,之后在用户栈中填充ExceptionRecordContext

图片描述

图片描述

图片描述

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

图片描述

​ 完成以上步骤后,KiDispatchException正常返回,结束FirstChance异常派发,当退回到用户态继续执行时,用户态代码如图所示(ntdll!KiUserExceptionDispatcher),内部主要做了以下几件事:

图片描述

​ 由于本文主要是针对Win11内核异常体系的分析,用户态部分碍于篇幅,在此就不详细展开分析了

​ 上一小节提到了,如果用户态异常也没处理好异常,会调用ZwRaiseException模拟产生异常异常派发,这就是用户态异常的二次派发,我们的视角继续回到KiDispatchException函数

​ 用户异常的二次派发就简单多了,尝试将异常派发给用户调试器、异常端口,如果都没处理,最后结束当前进程

图片描述

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

图片描述

​ 从整个异常处理的流程来看,大体上还是遵循着异常记录异常派发异常处理这三步骤进行:保存被打断的执行现场,将 CPU 硬件事件转换为统一的 Windows 异常对象,再根据异常来源和处理机会逐层派发,最后使用修改后的上下文恢复执行,或者终止无法恢复的执行主体。

KVA ShadowCETRSB等机制相关的代码,更多的是出于安全考虑,并不等同于异常处理逻辑本身

​ 经过本文上半部分的铺垫,相信各位朋友对于Windows异常体系已经有了初步的认识,接下来笔者将会分析有哪些可以利用的点。

​ 首先让让我回顾一下,已知内核态退回到用户态让VEHSEH有机会处理异常时,回去的落脚点为KiUserExceptionDispatcher函数,该函数完整反汇编如下:

图片描述

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

图片描述

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

图片描述

​ 那么这个点我们可以利用起来,手动将我们自己的一个回调"注册"进去,这样每次自己的异常回调不就早于所有的VEHSEH提前处理异常了?

​ 现在方向非常明确了,我们要找到ntdll!Wow64PrepareForException这个全局变量,将我们的回调函数写进该变量中,那么如何定位到该变量呢?

​ 笔者最早想到的是直接特征码硬搜呗,但是检查了一下发现,ntdll!KiUserExceptionDispatcher这个函数其实是导出的,那就很方便了

图片描述

​ 所有我们的注册异常回调函数,部分代码如下如下:

​ 到了这里我们注册好了异常处理回调,那么这个回调本身笔者的写法如下所示:

​ 测试代码如下所示,自定义异常处理器确实早于VEHSEH接管了异常处理:

图片描述

​ 现在有了一个可以最早接管用户态异常的手法,笔者在想是不是应该做一个有意思的实验来证明其价值。所以就利用这个手法实现一个无痕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回调就能实现最高接管该异常,早于内核调试器用户调试器,至于VEHSEH更不必多说,此时我们可以将前文的无痕Hook实现,异常处理部分改动一下,就能实现更加隐蔽的无痕Hook效果,关键代码、效果图如下所示:

Pico异常回调实现无痕Hook


传递专业知识、拓宽行业人脉——看雪讲师团队等你加入!!

最后于 17小时前 被__Shinn编辑 ,原因:
收藏
免费 21
打赏
分享
最新回复 (10)
雪    币: 206
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
2
牛逼
16小时前
0
雪    币: 2
活跃值: (1593)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
3
666啊
16小时前
0
雪    币: 2790
活跃值: (6901)
能力值: ( LV6,RANK:90 )
在线值:
发帖
回帖
粉丝
4
感谢你分享
11小时前
0
雪    币: 11802
活跃值: (7762)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
5
666,多谢分享
11小时前
0
雪    币: 310
活跃值: (2931)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
6
多谢分享
9小时前
0
雪    币: 0
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
7
666
7小时前
0
雪    币: 5294
活跃值: (6102)
能力值: ( LV4,RANK:50 )
在线值:
发帖
回帖
粉丝
8
感谢分享。
7小时前
0
雪    币: 6
能力值: ( LV1,RANK:0 )
在线值:
发帖
回帖
粉丝
9
厉害了
6小时前
0
雪    币: 29
活跃值: (1100)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
10
tql lhl
4小时前
0
雪    币: 255
活跃值: (982)
能力值: ( LV2,RANK:10 )
在线值:
发帖
回帖
粉丝
11
666666666
1小时前
0
游客
登录 | 注册 方可回帖
返回