异常处理小记

记录Windows PE 权威指南以及加密解密等书中对异常处理的原理及流程的部分描述;

Windows 的异常处理是操作系统处理程序错误或异常的一系列流程和技术的总称;

开发人员主要使用的两种异常处理技术是:

  • SEH(Structured Exception Handling,结构化异常处理):全局异常链表,不同的线程共用一个;
  • VEH(Vectored Exception Handler,向量化异常处理):局部异常链表,线程相关,位于当前线程的堆栈当中,不同线程不同堆栈;

异常基本概念

Intel 在从 386 开始的 IA-32 家族处理中引入了中断(Interrupt)和 异常(Exception)的概念;

  1. 中断是由外部硬件设备或异步事件产生的;
  2. 异常是由内部事件产生的,可分为故障(Fault)、陷阱(Trap)和终止(Abort) 3 种;
  3. 故障异常和陷阱异常是可恢复的;
  4. 故障异常通常是因为执行指令失败而引起的,如除 0 异常,以及 EIP 指向了不可执行的页面等;
    此类异常有一个共同点,那就是发生异常时自动压入栈的是失败指令的地址,而不是下一条指令的地址,这样做的原因是:当异常遍历过程返回时,可以重新执行一遍这条指令;
  5. 陷阱异常通常是因为执行了自陷指令(如 INT3)而引发的异常,这类异常的返回地址是自陷指令的下一条指令所在的地址;
  6. 终止类异常是不可恢复的,发生此类异常,系统必须重启;
    这类异常专指那些已经无法恢复的严重错误,如硬件故障引发的异常,或系统表中出现了错误值引发的异常;

异常就是在应用程序正常执行过程中发生的不正常事件;

由 CPU 引发的异常称为硬件异常,例如访问无效的内存地址;

由操作系统或应用程序引发的异常称为软件异常;

Intel CPU 常见的异常列表:

中断类型号类型相关指令
00触发运算出错,除数为 0 时中断DIV、IDIV
01调试异常(包括单步调试和硬件调试)任何指令
03断点中断INT3 指令
04溢出中断INT0
05读写内存冲突,即越界异常BOUND
06非法指令故障非法指令编码或操作数
07设备不可用浮点指令或 WAIT
08异常嵌套,双重故障,即在异常处理过程中又发生了异常任何指令
0A非法任务状态段,无效 TSS 中断JMP、CALL、IRET、中断
0B段不存在异常装载段寄存器
0C栈异常装载 SS 寄存器或 SS 段寻址
0D通用保护异常任何特权指令,任何访问存储器的指令
0E页异常任何访问存储器的指令
0F浮点运算异常

除了 CPU 能够捕获一个事件并引发一个硬件异常外,在代码段中可以主动引发一个软件异常,只需调用 RaiseException() 函数;

实际上,在高级语言的异常处理模型中的大部分抛出异常的操作,最终都是对 RaiseException() 函数的调用;

RaiseException() 函数声明如下:

1
2
3
4
5
6
VOID RaiseException(
DWORD dwExceptionCode, //标识引发异常的代码
DWORD dwExceptionFlags, //异常是否继续执行的标识
DWORD nNumberOfArguments, //参数个数
CONST DWORD *lpArguments //指向参数缓冲区的指针
);

程序捕获软件异常的方法与捕获硬件异常的方法完全相同;

异常处理的基本过程

Windows 正常启动后,将运行在保护模式下,当有中断或异常发生时,CPU 会通过中断描述符表(Interrupt Descriptor Table,IDT)来寻找处理函数;

IDT 表是 CPU(硬件)和操作系统(软件)交接中断和异常的关口;

IDT

IDT 是一张位于物理内存中的线性表,共有 256 项;

在 32 位模式下每个 IDT 项的长度是 8 字节,在 64 位模式下则为 16 字节;

操作系统在启动阶段会初始化这个表,系统中的每个 CPU 都有一份 IDT 的拷贝;

IDT 的位置和长度是由 CPU 的 IDTR 寄存器描述的;

IDTR 寄存器共有 48 位,其中高 32 位是表的基址,低 16 位是表的长度;

尽管可以使用 SIDT 和 LIDT 指令来读写该寄存器,但 LIDT 是特权指令,只能在 Ring0 特权级下运行;

IDT 的每一项都是一个门结构,它是发生中断或异常时 CPU 转移控制权的必经之路:

  • 任务门(Task-gate)描述符,主要用于 CPU 的任务切换(TSS 功能);
  • 中断门(Interrupt-gate)描述符,主要用于描述中断处理程序的入口;
  • 陷阱门(Trap-gate)描述符,主要用于描述异常处理程序的入口;

异常处理的准备工作

当有中断或异常发生时,CPU 会根据中断类型号(这里把异常也视为一种中断)转而执行对应的中断处理程序,对异常来说就是 KiTrapXX 函数;

例如 KiTrap03:

  • 处理 INT3 异常的函数;
  • 在开始异常处理时,先构造 TRAP_FRAME 陷阱帧结构,用来保存系统调用、中断、异常发生时的寄存器现场,方便之后回到用户空间 / 回到中断处时,恢复寄存器的值,继续执行;
  • KiTrap03 实际上调用了 CommonDispatchException;

CommonDisPatchException:

  • 判断是用户异常还是内核异常,使用的是 CS 段寄存器的最后两位;
  • 构建一个异常记录的结构;
  • 实际上调用了 KiDispatchException 来处理异常;

KiDispatchException:

  • 该函数用于分发异常,根据异常所产生的模式找到异常的函数;
  • 如果异常发生在内核模式,函数会直接调用一个异常处理函数直接处理异常;
  • 如果异常发生在用户模式,则函数会将异常记录、异常栈帧和陷阱栈帧拷贝到用户模式的线程的栈(这些信息在用户态可以被修改,被修改会重新设置到线程环境上),随后函数会进入到用户态,到了用户态之后会被专门的函数去处理异常;

各个异常处理函数除了针对本异常的特定处理外,通常会将异常信息进行封装,以便进行后续处理;

封装的内存主要有两部分,一部分是异常记录,包含本次异常的信息,该结构定义如下:

1
2
3
4
5
6
7
8
typedef struct _EXCEPTION_RECORD{
NTSTATUS ExceptionCode; //异常代码
ULONG ExceptionFlags; //异常的状态位标志
struct _EXCEPTION_RECORD *ExceptionRecord; //指向另一个 EXCEPTION_RECORD(异常记录块) 的指针
PVOID ExceptionAddress; //异常发生的地址(本次异常的返回位置)
ULONG NumberParameters; // 数组 ExceptionInformation 中有效数据的个数
ULONG_PTR ExceptionInformation; //附加信息
} EXCEPTION_RECORD

异常记录块用来记录一个异常所对应的相关信息,其中含有异常的代码、发生异常时的系统状态、异常之间的联系等,每一个异常发生时,系统都会传递一个这样的数据结构;

另一部分被封装的内容称为陷阱帧,它精确描述了发生异常时线程的状态(Windows 的任务调度是基于线程的);
TRAP_FRAME 陷阱帧结构与处理器高度相关,因此在不同的平台上有不同的定义;

TRAP_FRAME 陷阱帧结构包含每个寄存器的状态,但该结构一般仅供系统内核自身或调试系统使用,当需要把控制权交给用户注册的异常处理程序时,会将 TRAP_FRAME 转换成一个名为 CONTEXT 的结构,它包含线程运行时处理器各主要寄存器的完整镜像,用于保存线程运行环境;

包装完毕后,异常处理函数会进一步调用系统内核的 nt!KiDispatchException 函数来处理异常;

该函数原型定义如下,其中第 1 参数和第 3 参数正是上面封装的两个结构体:

1
2
3
4
5
6
7
8
VOID
KiDispatchException(
IN PEXCEPTION_RECORD ExceptionRecord, //异常结构信息,指向 ExceptionRecord 的指针
IN PKEXCEPTION_FRAME ExceptionFrame, //对 NT386(x86) 系统总是为 NULL,未使用
IN PKTRAP_FRAME TrapFrame, //发生异常时的陷阱帧
IN KPROCESSOR_MODE PreviousMode, //发生异常时的 CPU 模式是内核模式还是用户模式
IN BOOLEAN FirstChance //是否第 1 次处理该异常
);

内核状态的异常处理过程

当 PreviousMode 为 KernelMode 时,表示是内核模式下产生的异常,此时 KiDispatchException 会按以下步骤分发异常:

  1. 检测当前系统是否正在被内核调试器调试,如果内核调试器不存在,将跳过本步骤;

    如果内核调试器存在,系统就会把异常处理的控制权转交给内核调试器,并注明是第 1 次处理机会(FirstChance);

    内核调试器取得控制权之后,会根据用户对异常处理的设置来确定是否要处理该异常;

    如果无法确定该异常是否要处理,就会发生中断,把控制权交给用户,由用户决定是否处理;

    如果调试器正确处理了该异常,那么发生异常的线程就会回到原来产生异常的位置继续执行;

  2. 如果不存在内核调试器,或者在第 1 次处理机会时调试器选择不处理该异常,系统就会调用 nt!RtlDispatchException 函数,根据线程注册的结构化异常处理(SEH)过程来处理该异常;

  3. 如果 nt!RtlDispatchException 函数没有处理该异常,系统会给调试器第 2 次处理机会(Second Chance),此时调试器可以再次取得对异常的处理权;

  4. 如果不存在内核调试器,或者第 2 次机会调试器仍不处理,系统就认为在这种情况下不能继续运行了,为了避免引起更加严重的、不可预知的错误,系统会直接调用 KeBugCheckEx 产生一个错误码为“KERNEL_MODE_EXCEPTION_NO_HANDLED”(其值为 0x0000008E)的 BSOD(俗称蓝屏错误);

用户态的异常处理过程

当 PreviousMode 为 UserMode 时,表示是用户模式下产生的异常,此时 KiDispatchException 函数仍然会检测内核调试器是否存在;

如果内核调试器存在,会优先把控制权交给内核调试器进行处理,所以,使用内核调试器调试用户态程序是完全可行的,并且不依赖进程的调试端口;

在大多数情况下,内核调试器对用户态的异常不感兴趣,也就不会去处理它,此时,nt!KiDispatchException 函数仍然像处理内核态异常一样按两次处理机会进行分发;

  1. 如果发生异常的程序正在被调试,那么将异常信息发送给正在调试它的用户态调试器,给调试器第 1 次处理机会,如果没有调试器,则跳过本步骤;

  2. 如果不存在用户态调试器或调试器未处理该异常,那么在栈上放置 EXCEPTION_RECORD 和 CONTEXT( 用户态 TRAP_FRAME 陷阱帧结构) 两个结构,并将控制权返回用户态的 ntdll.dll 中的 KiUserExceptionDispatcher 函数,由它调用 ntdll!KiDispatchException 函数进行用户态的异常处理,这一部分涉及 SEH 和 VEH 两种异常处理机制;

    其中,SEH 部分包括应用程序调用 API 函数 SetUnhandleExceptionFilter 设置的顶级异常处理程序;

    但如果有调试器存在,顶级异常处理将被跳过,进入下一阶段的处理,否则将由顶级异常处理程序进行终结处理(通常是显示一个应用程序错误对话框并根据用户的选择决定是终止程序还是附加到调试器);

    如果没有调试器能附加于其上或调试器还是处理不了异常,系统就调用 ExitProcess 函数来终结程序;

    KiUserExceptionDispatcher:

    • 函数第 1 个参数为异常类型,第 2 个参数为产生异常时的上下文记录;
    • KiUserExceptionDispatcher 的核心是对 RtlDispatchException 的调用;
      如果某个处理程序处理这个异常并继续执行,那么对 RtlDispatchException 的调用就不会返回;
      如果它返回了,只有两种可能:调用了 NtContinue 以便让进程继续执行,或者产生了新的异常,如果是这样,那异常就不能再继续处理了,必须终止进程;
  3. 如果 ntdll!KiDispatchException 函数在调用用户态的异常处理过程中未能处理该异常,那么异常处理过程会再次返回 nt!KiDispatchException ,它将再次把异常信息发送给用户态的调试器,给调试器第 2 次处理机会;

    如果没有调试器存在,则不会进行第 2 次分发,而是直接结束进程;

  4. 如果第 2 次机会调试器仍不处理,nt!KiDispatchException 会再次尝试把异常分发给进程的异常端口进行处理;

    该端口通常由子系统进程 csrss.exe 进行监听,子系统监听到该错误后,通常会显示一个“应用程序错误”对话框,用户可以点击“确定”按钮或者最后将其附加到调试器上的“取消”按钮;

    如果没有调试器能附加于其上,或者调试器还是处理不了异常,系统就调用 ExitProcess 函数来终结程序;

  5. 在终结程序之前,系统会再次调用发生异常的线程中的所有异常处理过程,这是线程异常处理过程所获得的清理未释放资源的最后机会,此后程序就终结了;

异常的分发过程

内核态异常的分发过程:

  1. 如果 PreviousMode 为 KernelMode(0),那么对于第 1 轮处理机会,KiDispatchException 会试图先通知内核调试器来处理该异常;

  2. 内核变量 KiDebugRoutine 是用来标识内核调试引擎交互的接口函数;

    当内核调试引擎启用时,KiDebugRoutine 指向内核调试引擎 KdpTrap,这个函数会进一步把异常信息封装为数据包发送给内核调试器,当调试内核调试引擎没有启用时,KiDebugRoutine 指向 KdpStub 函数,简单处理后返回;

  3. 如果 KiDebugRoutine 返回 TRUE,也就是内核引擎处理了异常,那么 KiDispatchException 便停止继续分发,准备返回;

    如果 KiDebugRoutine 返回 FALSE,也就是没有处理该异常,那么 KiDispatchException 会调用 RtlDispatchException 函数,试图寻找已经注册的结构化异常处理器;

    会遍历异常登记链表,依次执行每个异常处理器;

    如果某个异常处理器处理了,RtlDispatchException 返回 TRUE,否则返回 FALSE;

  4. RtlDispatchException 返回 FALSE,KiDispatchException 会试图给内核调试器第二次机会,如果 KiDebugRoutine 仍然返回 FALSE,那么 KiDispatchException 会认为这是无人处理的异常,会调用 KeBugCheckEx;

用户态异常的分发过程:

  1. 如果是用户模式,即 PreviousMode 参数等于 UserMode(0),对于第 1 次处理机会,KiDispatchException 会试图将异常分发给用户态的调试器,如果 DebugPort 不为空,将异常发送给调试子系统,调试子系统将异常发送给调试器,如果处理了异常分发结束;

  2. 如果调试器没有处理该异常,KiDispatchException 修改用户态栈,返回用户层之后执行 KiUserExceptionDispatcher,此函数会调用 RtlDispatchException 来寻找异常处理器,首先遍历 VEH,然后遍历 SEH;

    如果 RtlDispatchException 返回 FALSE,并且当前进程在被调试,那么 KiUserExceptionDispatcher 会调用 ZwRaiseException 并将 FirstChance 设置为 FALSE,进行第二轮分发。如果没有被调试,结束进程;

  3. ZwRaiseException 会通过内核服务 NtRaiseException 把异常传递给 KiDispatchException 来进行分发;

    第二次,将异常传递给调试器,如果没有处理将异常分配给 ExceptionPort 异常端口监听者处理,如果返回 FALSE,结束进程;