Window SEH异常 – 异常初识
异常处理的基本概念
所谓异常就是在应用程序正常执行过程中发生的不正常事件。由CPU引发的异常称为硬件异常,例如访问一个无效的内存地址由操作系统或应用程序引发的异常称为软件异常。
常见的异常见下表
异常处理的基本过程
Windows常启动后,将运行在保护模式下,当有中断或异常发生时,CPU会通过中断描述柯:表(IDT)来寻找处理函数。因此,IDT表是CPU(硬件)和操作系统(软件)交接中和异常的关口。
IDT
IDT是一张位于物内存中的线性表,共有256项。在32位模式下每个IDT项的长度是8字节,在64位模式下则为64字节。
操作系统在启动阶段会初始化这个表,系统中的每个CPU都有一份IDT的拷贝。下面主要讨论32位模式下的IDT。IDT的位置和长度是由CPU的IDTR寄存器描述的。IDTR寄存器共有48位,其中高32位是表的基址,低16位是表的长度。尽管可以使用SIDT和LIDT令来读写该寄存器,但LIDT是特权指令,只能在Ring 0特权级下运行。
IDT的每一项是一个门结构,它是发生中断或异常时CPU转移控制权的必经之路,包括如下3种门描述符。
- 任务门(Task-gate)描述符,主要用于CPU的任务切换(TSS功能)。
- 中断门(Interrupt-gate)描述符,主要用于描述中断处理程序的人口。
- 陷阱门(Trap-gate)描述符,主要用于描述异常处理程序的人口。
使用WinDbg的本地内核调试模式可以比较方便地观察IDT。
可以看到,02、08和12项就是任务门的处理过程,其他项是陷阱门的处理过程,在一些没有显示的内容中包含了中断门的处理过程。
异常处理的准备工作
当有中断或异常发生时,CPU会根据中断类型号(这里其实把异常也视为一种中断)转而执行对应的中断处理程序,对异常来说就是上面看到的KiTrapXX函数。例如,中断号03对应于一个断点异常,当该异常发生时,CPU就会执行nt!KiTrap03函数来处理该异常。各个异常处理函数除了针对本异常的特定处理之外,通常会将异常信息进行封装,以便进行后续处理。
封装的内容主要有两部分。一部分是异常记录,包含本次异常的信息,该结构定义如下。
1 | typedef struct _EXCEPTION_RECORD { |
其中,ExceptionCode字段定义了异常的产生原因,下表列出了一些常见的异常产生原因。当然,也可以定义自己的Excer廿ExceptionCode,自定义代码可在API函数RaiseException中使用。
一部分被封装的内容称为陷阱帧,它精确描述了发生异常时线程的状态(Windows的任务调度是基于线程的)。该结构与处理器高度相关,因此在不同的平台上(Intel x86/x64、MIPS、Alpha和PowerPC处理器等)有不同的定义。在常见的x86平台上,该结构定义如下。
1 | typedef struct _KTRAP_FRAME |
可以看到,上述结构中包含每个寄存器的状态,但该结构一般仅供系统内核自身或者调试系统使用。当需要把控制权交给用户注册的异常处理程序时,会将上述结构情换成一个名为CONTEXT的结构,它包含线程运行时处理器各主要寄存器的完整镜像,用于保存全程运行环境。
x86平台上的CONTEXT结构如下。
1 | typedef struct _CONTEXT { |
结构的大部分域是不言自明的。需要解释的是,其第1个域ContextFlags表示该结构中的哪些域有效,当需要用CONTEXT结构保存的信息恢复执行时可对应更新,这为有选择地更新部分域而非全部域提供了有效的手段。
包装完毕,异常处理函数会进一步调用系统内核的nt!KiDispatchException函数来处理异常。因此,只有深入分析KiDispatchException函数的执行过程,才能理解异常是如何被处理的。该函数原型及各参数的含义如下,其第i个和第3个参数正是上面封装的两个结构。
1 | VOID |
在该函数中,系统会根据是否存在内核调试器、用户态调试器及调试器对异常的干预结果完成不同的处理过程。
内核态的异常处理过程
当PreviousMode为KernelMocle时,表示是内核模式下产生的异常,此时KiDispatchException会按以下步骤分发异常。
- 检测当前系统是否正在被内核调试器调试。如果内接调试器不存在,就跳过本步骤。如果内核i式器存在,系统就会把异常处理的控制权转交给内核调试器,并注明是第l次处理机会(FirstChance)内核调试器取得控制权之,会根据用户对异常处理的设置来确定是否要处理该异常。如果无法确定该异常是否需要处理,就会发生中断,把控制权交给用户,由用户决定是否处理。
- 如果不存在内核调试器,或者在第l次处理机会出现时调试器选悔不处理该异常,系统就会调用nt!RtlDispatchException函数,根据统程注册的结构化异常处理过程来处理该异常。
- 如果nt!RtlDispatchException函数没有处理该异常,系统会给调试器第2次处理机会(SecondChance),此时调试器可以再次取得对异常的处理权。
- 如果不存在内核调试器,或者在第2次机会调试器仍不处理,系统就认为在这种情况下不能继续运行了。为了避免引起更加严重的、不可预知的错误,系统会直接调用KeBugCheckEx产生一个错误码为“KERNEL_MODE_EXCEPTION_NOT_HANDLED”(其值为Ox0000008E)的BSOD(俗称蓝屏错误)。
可以看到,在上述异常处理过程中,只有在某一步骤中异常未得到处理,才会进行下一处理过程。在任何时候,只要异常被处理了,就会终止整个异常处理过程。
用户态的异常处理过程
当PreviousMode为UserMocle时,表示是用户模式下产生的异常。此时KiDispatchException函数仍然会检测内核调试器是否存在。如果内核调试器存在,会优先把控制权交给内核调试器进行处理。所以,使用内核调试器调试用户态程序是完全可行的,并且不依赖进程的调试端口。在大多数情况下,内核调试器对用户态的异常不感兴趣,也就不会去处理它,比时nt!KiDispatchException函数仍然像处理内核态异常一样按两次处理机会进行分发,主要过程如下。
如果发生异常的程序正在被调试,那么将异常信息发送给正在调试它的用户态调试器,给调试器第l次处理机会;如果没有被调试,跳过本步。
如果不存在用户态调试器或调试器未处理该异常,那么在枝上放置EXCEPTION_RECORD和CONTEXT两个结构,并将控制权返回用户态ntdll.dll中的KiUserExceptidnDspatcher函数,由它调用ntdll!RtlDispatchException函数进行用户态的异常处理。这一部分涉及SEH和VEH两种异常处理机制。中,SEH部分包括应用程序调用API函数SetUnhandleExceptionFilter但如果有调试器存在,顶级异常处理会被跳过,进入下一阶段的处理,居则将由顶级异常处理程序进行终结处理(通常是显示一个应用程序错误对话框并根据用户的选择快定是终止程序还是附加到调试器)。如果没有调试器能附加于其上或调试器还是处理不了异常,系统就调用ExitProcess函数来终结程序。
如果ntdll!RtlDispatchException函数在调用用户态的异常处理过程中未能处理该异常,那么异常处理过程会再次返回nt!KiDispatchException,它将再次把异常信息库送给用户态的调试器,给调试器第2次处理机会。如果没有调试器存在,则不会进行第次分发,而是直接结束进程。
如果第2次机会调试器仍不处理,nt!KiDispatchException会再次尝试把异常分发给进程的异常端口进行处理。该端口通常由子系统进程csrss.exe进行监听。子系统监听到该错误后,通常会显示一个“应用程序错误”对话框,用户可以单击“确定”按钮或者最后将其附加到调试器上的“取消”按钮。如果没有调试器能附加于其上,或者调试器还是处理不了异常,系统就调用ExitProcess函数来终结程序。
在终结程序之前,系统会再次调用发生异常的线程中的所有异常处理过程,这是线程异常’处理过程所获得的清理未释放资源的最后机会,此后程序就终结了。