Windows对异常的管理(描述、分发和处理)及源码剖析

之前的两篇文章分别介绍了C++的异常处理Windows的异常处理,但都停留在应用程序层面,这篇文章来深入地扒一扒在Windows内核层面,对异常的描述、分发和处理。

异常

异常通常是CPU在执行指令时因为检测到预先定义的某个(或多个)条件而产生的同步事件。它是CPU主动产生的。
异常的来源有三种:

  • 程序错误:即CPU在执行程序指令时遇到操作数有错误(如除零异常)或检测到指令规范中定义的非法情况(如在用户模式下执行特权指令)。
  • 特殊指令:这些指令的预期行为就是产生相应的异常(如INT 3指令的目的就是产生一个断点异常,让CPU中断进调试器)。
  • 机器检查异常:从奔腾CPU引入的,当CPU执行指令期间检测到CPU内部或外部的硬件错误时,会触发。

异常的描述方式

Windows内部使用EXCEPTION_RECORD结构来描述异常。

typedef struct _EXCEPTION_RECORD 
{
    
    
	DWORD ExceptionCode;	// 异常代码,是一个32位的整数,格式是Windows系统的状态代码格式
	DWORD ExceptionFlags;	// 异常标志,它的每一位代表一种标志
	struct _EXCEPTION_RECORD *ExceptionRecord;// 与该异常有关的另一个异常,如果没有相关的异常则为NULL。
	PVOID ExceptionAddress;	// 异常发生地址,对于硬件地址,它的值可能是导致异常的那条指令的地址,也可能是导致异常指令的下一条指令的地址;对于软件地址,是RaiseException。
	DWORD NumberParameters;	// 参数数组中的元素个数
	ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];	// 参数数组
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;

异常的分类

根据CPU报告异常的方式和导致异常的指令是否可以安全地重新执行,异常可以分为错误类异常陷阱类异常终止类异常

分类 报告时间 保存的CS和IP 可恢复性 举例
错误 开始执行导致异常的指令时 导致异常的那条指令 可以恢复执行 内存页错误
陷阱 执行完导致异常的指令时 导致异常的那条指令的下一条指令 可以恢复执行 INT 3指令
终止 不确定 不确定 不可以 硬件错误等严重的错误

根据异常的产生者,异常可以分为两种:

  • CPU异常:CPU产生的异常。
  • 软件异常:通过软件方式模拟出的异常,RaiseExceptionthrow

CPU异常

当有异常发生时,CPU会通过IDT表找到异常处理函数,即内核中的KiTrapXX系列函数,然后转去执行。但是KiTrapXX函数通常只是对异常作简单的表示和描述,然后调用CommonDispatchException,并通过寄存器将如下信息传给它。

  1. 将唯一标识该异常的一个异常代码放入EAX寄存器;
  2. 将导致异常的指令地址放入EBX寄存器;
  3. 将其他信息作为附带参数(最多三个)分别放入EDX(参数1)、ESI(参数2)和EDI(参数3)寄存器,并将参数个数放入ECX寄存器。

CommonDispatchException函数被调用后,会在栈中分配一个EXCEPTION_RECORD结构,并把以上异常信息存储到该结构中。准备好这个结构后,就会调用内核中的KiDispatchException函数来分发异常。

IDT表

上面提到了IDT表,这里也简单介绍一下:
IDT:Interrupt Descriptor Table,中断描述符表。

  • IDT是一张位于物理内存中的线性表,共有256项。32位下每项8个字节,总长度是2KB;64位下每项16个字节,总长度是4KB。
  • IDT表的位置和长度由CPU的IDTR寄存器来描述,在内核调试下,可以通过windbg命令r idtrr idtl来查看IDTR寄存器的值。
  • 操作系统在启动阶段会初始化IDT表,系统中的每个CPU都有一份IDT的拷贝。
  • 当有中断或异常发生时,CPU是通过IDT来寻找处理函数的。

软件异常

软件异常:通过软件方式模拟出的异常,RaiseExceptionthrow。但其实throw抛出异常底层也是通过RaiseException实现的。
举个小例子:

try
{
    
    
    int v1 = 10;
    if (v1 != 0)
    {
    
    
        throw -1;
    }
}
catch (int e)
{
    
    
    std::cout << e << std::endl;
}

看一下它的汇编:
在这里插入图片描述
throw -1的汇编实现:
首先mov指令将-1保存在局部变量中,然后将其赋给eax寄存器,两条push指令都是压参数,最后调用__CxxThrowException@8函数。在这里插入图片描述
__CxxThrowException对参数进行了简单的处理就调用了RaiseException.
在RaiseException中主要就是构建结构体EXCEPTION_CRECORD,然后调用RtlRaiseException。
RtlRaiseException内部会调用ZwRaiseException,经过系统调用最终会进入NtRaiseException。
在vs里看汇编太难了,我们转战OD!可以发现调用的流程是完全一致的!
在这里插入图片描述在这里插入图片描述
在这里插入图片描述在这里插入图片描述
NtRaiseException内部会调用KiRaiseException,KiRaiseException会:

扫描二维码关注公众号,回复: 13266728 查看本文章
  • 通过KeContextToTrapFrame函数将ContextRecord结构中的信息复制到当前线程的内核栈;
  • 然后把ExceptionRecord中的异常代码的最高位清0,以便把软件异常与CPU异常区分开来;
  • 最后会调用KiDispatchException函数开始分发该异常。
    在这里插入图片描述

异常的分发

综上所述,不论是CPU异常还是软件异常,最终都会调用内核中的KiDispatchException函数来分发异常。
CPU异常:CommonDispatchException()—>KiDispatchException()
软件异常:RaiseException()—>RtlRaiseException()—>ZwRaiseException()–系统调用–>NtRaiseException()—>KiRaiseException()—>KiDispatchException()

VOID NTAPI KiDispatchException(
	IN PEXCEPTION_RECORD ExceptionRecord,	// 异常信息
	IN PKEXCEPTION_FRAME ExceptionFrame, 	// x86时为NULL
	IN PKTRAP_FRAME TrapFrame,			 	// 描述异常发生时的处理器状态,包括各种通用寄存器、调试寄存器、段寄存器等
	IN KPROCESSOR_MODE PreviousMode,	 	// 触发异常代码的执行模式,为0(KernelMode)表示内核模式,为1(UserMode)表示用户模式
	IN BOOLEAN FirstChance);			 	// 是否是第一轮分发这个异常(对于一个异常,Windows系统最多会分发两轮)

内核模式下和用户模式下对异常的分发是不一样的。但也有类似之处,都会试图先交给调试器处理,且最多给每个异常两轮处理机会
先画个流程草图:
在这里插入图片描述

内核态异常的分发

第一轮处理时,KiDispatchException会试图先通知内核调试器来处理该异常。
内核变量KiDebugRoutine用来标识内核调试引擎交互的接口函数。当内核调试引擎被启用时,KiDebugRoutinue指向的是内核调试引擎的KdpTrap函数,该函数会进一步把异常信息封装为数据包发送给内核调试器;当内核调试引擎没有启用时,KiDebugRoutine指向的是KdpStub函数,它的实现很简单,做一些简单的处理后直接返回FALSE。
在这里插入图片描述
KiDebugRoutinue返回TRUE表示内核调试器处理了该异常,那么KiDispatchException便停止分发,准备返回;返回FALSE则表示内核调试器没有处理该异常,那么KiDispatchException会调用RtlDispatchException,试图寻找已经注册的结构化异常处理器(SEH)
在这里插入图片描述
RtlDispatchException会先获得异常注册链表(Exception Registration List)的首节点地址(FS寄存器偏移0开始的DWORD),然后遍历异常注册链表,依次执行每个异常处理器,如果某一个处理器返回了ExceptionContinueExecution,则返回TRUE,表示已经处理了该异常。如果返回FALSE,也就是没有找到处理该异常的异常处理器,那么KiDispatchException会试图给内核调试器第二轮处理机会。

如果这次KiDebugRoutinue仍然返回FALSE,那么KiDispatchException会认为这是个无人处理的异常(简称未处理异常)。
对于发生在内核态中的未处理异常,Windows认为这是一个严重的错误,会调用KeBugCheckEx引发蓝屏(BSOD,Blue Screen Of Death),报告错误并终止系统运行。
KeBugCheckEx的第一个参数被置为KMODE_EXCEPTION_NOT_HANDLED(0x1E),代表未处理的内核异常。异常代码和异常地址会作为参数传给KeBugCheckEx,并显示在蓝屏界面上。

用户态异常的分发

首先会判断是否需要将异常发给内核调试器,判断的条件:这个异常是否是内核调试器触发的,以及内核调试的设置选项中是否接受用户态异常。如果判断的结果是需要发送,则通过内核调试会话发送给主机上的内核调试器,但内核调试器通常不处理用户态的异常,直接返回不处理。
在这里插入图片描述
第一轮处理时,KiDispatchException会试图先将该异常分发给用户态的调试器,方法是调用用户态调试子系统的内核例程DbgkForwardException
DbgkForwardException会检查当前进程的DebugPort是否为空,不为空则调用DbgkpSendApiMessage将异常发给调试子系统,调试子系统又将异常发给调试器。DbgkForwardException返回TRUE,该异常的分发过程就结束了;若返回FALSE,KiDispatchException下一步会试图寻找处理块来处理该异常,因为异常发生在用户态代码中,异常处理块也应该在用户态函数中。KiDispatchException会准备转回用户态去执行。
如何转回到用户态执行呢?
这一块Reactos源码写的不详细,我们看一下XP的源码:
在这里插入图片描述
KiDispatchException先确认用户栈有足够的空间容纳CONTEXT结构和EXCEPTION_RECORD结构,然后将这两个结构复制到用户态栈中。.而后将TrapFrame所指向的KTRAP_FRAME结构中的状态信息调整为在用户态执行所需的合适值,包括段寄存器和栈指针。
最后将KeUserExceptionDispatcher的值赋给KTRAP_FRAME结构中的EIP,目的是让这个线程返回用户态后从KiUserExceptionDispatcher函数处开始执行。以上工作做好后,KiDispatchException函数就会返回

内核变量KeUserExceptionDispatcher记录了用户态中的异常分发函数。在目前的Windows中,它指向的是NTDLL中的KiUserExceptionDispatcher函数。

对于软件异常,会通过系统调用返回到用户态,然后从KiUserExceptionDispatcher处开始执行,而不是本来调用系统服务的地方;
对于CPU异常,会返回到CommonDispatchException,然后执行KiExceptionExit并根据TrapFrame恢复CPU状态,而后执行异常返回指令(IRETD),异常返回指令执行后,当前线程便转到用户态的KiUserExceptionDispatcher函数处开始执行。

回到用户态后,KiUserExceptionDispatcher会通过调用RtlDispatchException来寻找异常处理器。
在这里插入图片描述
RtlDispatchException返回TRUE则表示已经有异常处理器处理了该异常,KiUserExceptionDispatcher会调用ZwContinue系统服务继续执行原来发生异常的代码。该调用如果成功,便不会再返回到KiUserExceptionDispatcher,如果返回则说明Continue失败,KiUserExceptionDispatcher会通过调用RtlRaiseException来二次抛出异常。

第二轮分发时,KiDispatchException第二次调用DbgkForwardException,如果返回TRUE,则分发结束;若返回FALSE,则表示该进程不在被调试或调试器没有处理该异常。那么KiDispatchException会尝试把异常发给该进程的异常端口,如果还是返回FALSE,那么KiDispatchException会终止当前进程,并调用KeBugCheckEx引发蓝屏。
在这里插入图片描述

猜你喜欢

转载自blog.csdn.net/qq_42814021/article/details/121272962