之前的两篇文章分别介绍了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产生的异常。
- 软件异常:通过软件方式模拟出的异常,
RaiseException
或throw
。
CPU异常
当有异常发生时,CPU会通过IDT表找到异常处理函数,即内核中的KiTrapXX
系列函数,然后转去执行。但是KiTrapXX函数通常只是对异常作简单的表示和描述,然后调用CommonDispatchException
,并通过寄存器将如下信息传给它。
- 将唯一标识该异常的一个异常代码放入EAX寄存器;
- 将导致异常的指令地址放入EBX寄存器;
- 将其他信息作为附带参数(最多三个)分别放入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 idtr
和r idtl
来查看IDTR寄存器的值。 - 操作系统在启动阶段会初始化IDT表,系统中的每个CPU都有一份IDT的拷贝。
- 当有中断或异常发生时,CPU是通过IDT来寻找处理函数的。
软件异常
软件异常:通过软件方式模拟出的异常,RaiseException
或 throw
。但其实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会:
- 通过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
引发蓝屏。