Windows下的异常处理
基础
开发人员主要使用两种异常处理技术,一种是 SEH (结构化异常处理),另一种是 VEH (向量化异常处理,XP 以上)
Intel公司在从386开始的IA-32家族处理器中引人了**中断(Interrupt)和异常(Exception)**的概念。
中断是由外部硬件设备或异步事件产生的,而异常是由内部事件产生的,又可分为故障、陷阱和终止3类。故障和陷阱,正如其名称所示的,是可恢复的;终止类异常是不可恢复的,如果发生了这种异常,系统必须重启。
CPU 访问无效内存,是硬件异常;操作系统或软件引发的异常,是软件
常见异常
同时代码可以通过函数 RaiseException ,主动引发一个异常
VOID RaiseException{
DWORD dwExceptionCode,//标识所引发异常的代码
DWORD dwExceptionFlags,//异常是否继续执行的标识
DWORD nNumberOfArguments,//附加信息
CONST DWORD *lpArguments//附加信息
};
异常处理的基本过程
Windows 正常启动后,会运行在保护模式下,当有中断或异常发生时,CPU 会通过**中断描述符(Interrupt Descriptor Table)**来寻找处理函数。
因此,IDT表是CPU与操作系统交接终端和异常的关口
IDT
IDT 是一张位于物理内存中的线性表,共有 256 项。在 32 位模式下每个 IDT 项的长度是 **8 字节,**在 64 位模式下则为 16 字节。
操作系统在启动阶段会初始化这个表,系统中的每个 CPU 都有一份 IDT 的拷贝。IDT 的位置和长度是由 CPU 的 IDTR 寄存器描述的。IDTR 寄存器共有 48 位,其中高 32 位是表的基址,低 16 位是表的长度。尽管可以使用 SIDT 和 LIDT 指令来读写该寄存器,但 LIDT 是特权指令,只能在 Ring 0 特权级下运行。
SIDT 指令的功能 (仅对当前的 CPU): 将中断描述符表寄存器IDTR--64位宽,16~47Bit 存有中断描述符表 IDT 基地址的内容存入指定地址单元。获得 IDT 的基地址后,可以修改 IDT,增加一个中断门安置自己的中断服务。
IDT 的每一项都是一个门结构,它是发生中断或异常时 CPU 转移控制权的必经之路,包括如下
• 任务门(Task-gate) 描述符,主要用于 CPU 的任务切换(TSS功能)。(微软没有采用该方式,内存频繁读写,拖慢系统速度,在 x64 中被废除)
• 中断门( Interrupt-gate)描述符,主要用于描述中断处理程序的入口。
• 陷阱门(Trap-gate)描述符,主要用于描述异常处理程序的入口。(Windows 64 位下,系统本身的运行没有使用任务门)
02 08 12项就是任务门的处理过程,其他项是陷阱门的处理过程,在一些没有显示的内容中包含了中断门的处理过程
异常处理的准备工作
当有中断或异常发生时,CPU 会根据中断类型号(这里其实把异常也视为一种中断)转而执行对应的中断处理程序,对异常来说就是上面看到的 KiTrapXX 函数。例如,中断号03对应于一个断点异常,当该异常发生时,CPU就会执行 nt!KiTrap03 函数来处理该异常。各个异常处理函数除了针对本异常的特定处理之外,通常会将异常信息进行封装,以便进行后续处理。
封装的内容主要有两部分:一部分是异常记录,包含本次异常的信息,该结构定义如下。
这个结构体其实就是 SEH 处理函数的第一个参数。ExceptionCode 可以自己定义,自定义代码在 RaiseException 函数中使用
另一部分被封装的内容称为陷阱帧,它精确描述了发生异常时线程的状态( Windows 的任务调度是基于线程的)。该结构与处理器高度相关,因此在不同的平台上(Intel x86/x64、MIPS、Alpha 和 PowerPC 处理器等)有不同的定义。在常见的 x86 平台上,该结构定义如下。
typedef struct _KTRAP_FRAME
{ // 以下四项仅为调试系统服务
ULONG DbgEbp; //用户EBP指针的拷贝,用于支持栈回溯命令KB
ULONG DbgEip; //用于 系统调用时的 EIP 同上,用于 KB 命令
ULONG DbgArgMark; //标记显示这里没有参数
ULONG DbgArgPointer; //指向实际参数 // 当需要调整栈帧时使用以下值作为临时变量
WORD TempSegCs;
UCHAR Logging;
UCHAR Reserved;
ULONG TempEsp; // 调试寄存器
ULONG Dr0;
ULONG Dr1;
ULONG Dr2;
ULONG Dr3;
ULONG Dr6;
ULONG Dr7; // 段寄存器
ULONG SegGs;
ULONG SegEs;
ULONG SegDs; // 易失寄存器
ULONG Edx;
ULONG Ecx;
ULONG Eax; // 调试系统使用
ULONG PreviousPreviousMode;
PEXCEPTION_REGISTRATION_RECORD ExceptionList;
ULONG SegFs; // 非易失寄存器
ULONG Edi;
ULONG Esi;
ULONG Ebx;
ULONG Ebp; // 控制寄存器
ULONG ErrCode;
ULONG Eip;
ULONG SegCs;
ULONG EFlags; // 其它特殊变量
ULONG HardwareEsp;
ULONG HardwareSegSs;
ULONG V86Es;
ULONG V86Ds;
ULONG V86Fs;
ULONG V86Gs;
} KTRAP_FRAME, *PKTRAP_FRAME;
可以看到,上述结构中包含每个寄存器的状态,但该结构一般仅供系统内核自身或者调试系统使用。
当需要把控制权交给用户注册的异常处理程序时,会将上述结构转换成一个名为 CONTEXT 的结构,它包含线程运行时处理器各主要寄存器的完整镜像,用于保存线程运行环境。
typedef struct _CONTEXT
{ // 调试寄存器
DWORD ContextFlags +00h
DWORD Dr0 +04h
DWORD Dr1 +08h
DWORD Dr2 +0Ch
DWORD Dr3 +10h
DWORD Dr6 +14h
DWORD Dr7 +18h
FLOATING_SAVE_AREA FloatSave; //浮点寄存器区 +1Ch~~~88h
// 段寄存器
DWORD SegGs +8Ch
DWORD SegFs +90h
DWORD SegEs +94h
DWORD SegDs +98h
// 通用寄存器
DWORD Edi +9Ch
DWORD Esi +A0h
DWORD Ebx +A4h
DWORD Edx +A8h
DWORD Ecx +ACh
DWORD Eax +B0h
// 控制寄存器
DWORD Ebp +B4h
DWORD Eip +B8h
DWORD SegCs +BCh
DWORD EFlag +C0h
DWORD Esp +C4h
DWORD SegSs +C8h
BYTE ExtendedRegisters[MAXIMUM_SUPPORTED_EXTENSION];
} CONTEXT;
typedef CONTEXT *PCONTEXT;
#define MAXIMUM_SUPPORTED_EXTENSION 512
第一个域 ContexFlags 表示该结构体中的哪些域有效,恢复信息时可有选择的更新数据。
包装完毕,异常处理函数会进一步调用系统内核的 nt!KiDispatchException 函数来处理异常。因此,只有深入分析 KiDispatchException 函数的执行过程,才能理解异常是如何被处理的。该函数原型及各参数的含义如下,其第1个和第3个参数正是上面封装的两个结构。
在这个函数中,系统会根据是否存在内核调试器,用户态调试器以及调试器对异常的干预过程而进行不同的处理过程
内核态的异常处理过程
当 PreviousMode 为 KernelMode 时,表示是内核模式下产生的异常,此时 KiDispatchException 会按以下步骤分发异常。
① 检测当前系统是否正在被内核调试器调试。如果内核调试器不存在,就跳过本步骤。如果内核调试器存在,系统就会把异常处理的控制权转交给内核调试器,并注明是第1次处理机会( FirstChance )。内核调试器取得控制权之后,会根据用户对异常处理的设置来确定是否要处理该异常。如果无法确定该异常是否需要处理,就会发生中断,把控制权交给用户,由用户决定是否处理。如果调试器正确处理了该异常,那么发生异常的线程就会回到原来产生异常的位置继续执行。
② 如果不存在内核调试器,或者在第1次处理机会出现时调试器选择不处理该异常,系统就会调用 nt!RtIDispatchException 函数,根据线程注册的结构化异常处理(Structured Exception Handling,SEH )过程来处理该异常。
③ 如果 nt!RtIDispatchException 函数没有处理该异常,系统会给调试器第2次处理机会( SecondChance ),此时调试器可以再次取得对异常的处理权。
④如果不存在内核调试器,或者在第2次机会调试器仍不处理,系统就认为在这种情况下不能继续运行了。为了避免引起更加严重的、不可预知的错误,系统会直接调用 KeBugCheckEx 产生一个错误码为“KERNEL_MODE_EXCEPTION_NOT_HANDLED”(其值为 0x0000008E )的 BSOD (俗称蓝屏错误)。
用户态的异常处理过程
当 PreviousMode 为 UserMode 时,表示是用户模式下产生的异常。此时 KiDispatchException 函数仍然会检测内核调试器是否存在。如果内核调试器存在,会优先把控制权交给内核调试器进行处理。所以,使用内核调试器调试用户态程序是完全可行的,并且不依赖进程的调试端口。在大多数情况下,内核调试器对用户态的异常不感兴趣,也就不会去处理它,此时 nt!KiDispatchException 函数仍然像处理内核态异常一样按两次处理机会进行分发,主要过程如下。
① 如果发生异常的程序正在被调试,那么将异常信息发送给正在调试它的用户态调试器,给调试器第1次处理机会;如果没有被调试,跳过本步。
② 如果不存在用户态调试器或调试器未处理该异常,那么在栈上放置 EXCEPTION_RECORD 和 CONTEXT 两个结构,并将控制权返回用户态 ntdll.dll 中的 KiUserExceptionDispatcher 函数,由它调用 ntdll!RtIDispatchException 函数进行用户态的异常处理。这一部分涉及 SEH 和 VEH 两种异常处理机制。其中,S**EH 部分包括应用程序调用 API 函数 SetUnhandledExceptionFilter 设置的顶级异常处理,但如果有调试器存在,顶级异常处理会被跳过,进入下一阶段的处理,**否则将由顶级异常处理程序进行终结处理(通常是显示一个应用程序错误对话框并根据用户的选择决定是终止程序还是附加到调试器)。如果没有调试器能附加于其上或调试器还是处理不了异常,系统就调用 ExitProcess 函数来终结程序。
③ 如果 ntdlRtIDispatchException 函数在调用用户态的异常处理过程中未能处理该异常,那么异常处理过程会再次返回 nt!KiDispatchException,它将再次把异常信息发送给用户态的调试器,给调试器第2次处理机会。如果没有调试器存在,则不会进行第2次分发,而是直接结束进程。
④ 如果第2次机会调试器仍不处理, nt!KiDispatchException 会再次尝试把异常分发给进程的异常端口进行处理。该端口通常由子系统进程 csrss.exe 进行监听。子系统监听到该错误后,通常会显示一个“应用程序错误”对话框,如果没有调试器能附加于其上,或者调试器还是处理不了异常,系统就调用 ExitProcess 函数来终结程序。
⑤ 在终结程序之前,系统会再次调用发生异常的线程中的所有异常处理过程,这是线程异常处理过程所获得的清理未释放资源的最后机会,此后程序就终结了。
SEH
在没有调试器参与的情况下,系统主要依靠SEH机制(用户态内核态都可使用),VEH(仅支持用户模式)
TIB
TIB (Thread Information Block,线程信息块)是保存线程基本信息的数据结构。在用户模式下,它位于 TEB (Thread Environment Block,线程环境块)的头部,而TEB是操作系统为了保存每个线程的私有数据创建的,每个线程都有自己的TEB。在Windows 2000 DDK中、TIB的定义如下。
fs:[0]
即是ExceptionList的地址
在x64平台上的异常处理 这个关系变成了gs:[0]
_EXCEPTION_REGISTRATION_RECORD结构
TEB
偏移量为0的_EXCEPTION_REGISTRATION_RECORD
主要用于描述线程异常处理的地址,多个该结构的链表描述了多个线程异常处理过程的嵌套层次关系,其定义如下
typedef struct _EXCEPTION_REGISTRATION_RECORD{
struct _EXCEPTION_REGISTRATION_RECORD *Next; //指向下一个结构的指针
PEXCEPTION_ROUTINE Handler;
}EXCEPTION_REGISTRATION_RECORD;
其中,Next
是指向下一个 _EXCEPTION_REGISTRATION_RECORD
(简称ERR)的指针,形成一链状结构,而链表头就存放在fs:[0]
指向的TEB中
Handler
指向异常处理回调函数
当程序运行过程中产生异常,系统的异常分发器就会从fs:[0]
处取得异常处理的链表头,然后查找异常处理链表并一次调用各个链表节点中的异常处理回调函数。由于TEB
是线程的私有数据结构,相应地,每个线程也都有自己的异常处理链表,即SEH机制的作用范围仅限于当前线程
从数据结构的角度,SEH
链是一个只允许在链表头部进行增加和删除节点操作的单向链表,且头部永远保存在fs:[0]
的TEB结构中
EXCEPTION_RECORD结构和CONTEXT结构
这两个结构分别描述了异常发生的异常相关信息和线程状态信息
__EXCEPTION_POINTERS 结构
当一个异常发生时,在没有调试器干预的情况下,操作系统会将异常信息转交给用户态的异常处理过程。实际上,由于同一个线程在用户态和内核态使用的是两个不同的栈,为了让用户态的异常处理程序能够访问与异常相关的数据,操作系统必须把与本次异常相关联的 EXCEPTION_RECORD 结构和 CONTEXT 结构放到用户态栈中,同时在栈中放置一个 _EXCEPTION_POINTERS 结构,它包含两个指针,一个指向 EXCEPTION_RECORD 结构,另一个指向 CONTEXT 结构,示例如下。
这样用户态的异常处理程序就能够取得异常的具体信息和发生异常时线程的状态信息,并根据具体情况进行处理
SEH处理程序的安装和卸载
从汇编的角度来看
fs:[0]
总是指向当前异常处理程序的链表头,当程序需要安装一个新的SEH异常处理程序时,只要填写一个新的EXCEPTION_REGISTRATION_RECORD
结构,并将其插入SEH链表的头部,根据SEH的设计要求,SEH的作用返回与安装它的函数相同,因此需要在函数头部安装SEH异常程序 然后在函数返回前卸载,因此SEH是基于栈帧的异常处理机制
assume fs:nothing
push offset SEHandler
push fs:[0]
mov fs:[0],esp
例如上,后面三行是注册回调函数
前两行相继向栈中压入了Handler
和目前的SEH链表头
,这两个元素构成了一个新的_EXCEPTION_REGISTRATION_rECORD
结构,此时位置正在栈顶
然后把esp
的值保存在fs:0,就相当于向链表中插入了一个新节点
再看如何卸载SEH
我们只需要将原来的fs:[0]的原始值给回复并且恢复栈的平衡即可
相当于删除SEH链的一个节点
mov esp,dword ptr fs:[0]
pop dword ptr fs:[0]
SEH异常处理程序原理以及设计
在实际应用中,大部分情况没有调试器的存在,此时异常将如何分发是我们关注的重点
异常分发的详细过程
异常处理的实际过程实际上是系统将异常发送到各个异常处置单元中进行处理的过程,这个过程叫做异常的分发。
下面是用户态的异常分发
首先用户态的异常分发从ntdll!KiUserExceptionDispatcher
函数开始
此时栈中有EXCEPTION_RECORD
和CONTEXT
两个结构
从上述代码可以看出
-
ntdll!RtlDispatchException
函数用来具体分发异常 -
当异常被处理后,会使用
NtCountinue
服务恢复该线程的执行 -
如果异常没被处理,就调用
NtRaiseException
函数引发第二次异常(其中第三个参数BOOLEAN FirstChance FALSE 表示不是第一次机会)
btdll!RtlDispatchException
的实现和细节很复杂
代码太长就不贴了
大部分都是各类安全验证,抛开核心验证,只看核心流程
RtlDispatchException 函数总体的流程是:
① 首先调用 VEH 异常处理,返回值不是 EXCEPTION_CONTINUE_SEARCH 就结束异常分发,继续调用SEH部分的处理
② 返回值符合要求,则查询 SEHOP 是否启用,未开启则会进行校验(对每个 Record 结构体都会进行验证)
③ 对 Handler 进行增强验证,即 SafeSEH 机制(通过调用函数 RtlIsValidHandler 来实现)
④ 开始依次执行 Handler,并对返回值进行对比(Switch-Case),执行相应的操作
- 对
ExceptionContinueExecution
结束遍历并返回(对标记EXCEPTION_NONCONTINTINUABLE的异常不允许再次恢复执行,会调用RtlRaiseException) - 对
ExceptionContinueSearch
继续遍历下一个节点 - 对
ExceptionNestedException
从指定的新异常开始继续遍历
⑤ 必会执行的最后一步,调用 RtlCallVectoredContinueHandlers 函数,然后程序返回
线程异常处理
线程异常处理就是指SEH异常处理
SEH的整体设计思路就是基于线程的
一般来说A西安餐发生的异常无法被B线程的异常处理程序捕获
线程异常处理的工作细节
在上文,我们已经知道如何安装和卸载SEH异常回调函数
实际上,回调函数的原型是这样定义
Handler proc C pExcept,pFrame,pContext,pDispatch
pExcept
指前面介绍的包含异常处理信息的Exception_Record
结构的地址pFrame
指向SEH
链中当前_EXCEPTION_REGISTARTION
结构的地址pContext
指向与线程相关的寄存器映像CONTEXT
结构的地址pDispatch
该域用于内嵌异常处理
相应的C格式声明
typedef enum _EXCEPTION_DISPOSITION{
ExceptionContinueExecution,//0
ExceptionContinueSearch,//1
ExceptionNestedException,//2
ExceptionCollidedUnwind //3
}EXCEPTION_DISPOSITION;
EXCEPTION_DISPOSITION _cdecl _Except_handler(
struct _EXCEPTION_RECORD *ExceptionRecord,
void * EstablisherFrame
struct _CONTEXT *ContextRecord,
void * DispatcherContext);
各个参数的意义很明确,回调函数要做的就是通过EXCEPTION_rECORD
结构中的信息判断当前异常自己是否能处理,如果能,就要根据异常产生的原因进行修正,必要时修改CONTEXT结构,然后恢复执行,如果不能就去寻找下一个处理程序
系统会根据每个回调函数的返回值进行相应动作
(1) ExceptionContinueExecution
异常已处理,根据 Contex 结构体的相关信息恢复线程的执行
(2) ExceptionContinueSearch
回调函数不能处理该异常,需要交由链表中的下一个函数来处理
(3) ExceptionNestedException
回调函数在处理该异常时也发生了异常,即嵌套异常。在内核中发生将会停止系统的运行
(4) ExceptionCollidedUnwind
回调函数在进行异常展开操作时发生了异常。展开操作可以理解为恢复发生异常的第一现场,并在恢复过程中的系统资源进行回收。展开操作由系统在处理异常时进行,用户自定义的回调函数通常不返回这个值。
后两个返回值一般只见于系统内部的处理过程,用户自定义的回调函数只返回前两个值。
SEH 链的最后一个函数是系统设置的终结处理函数。
异常处理函数的栈展开(RtlUnwind)
什么是栈展开
传递给回调函数的EXCEPTIO_RECORD
结构的ExceptionFlags
有三个可选值
分别是012,0表示可修复异常,1表示不可修复异常,2代表展开操作
当所有异常回调函数都不处理异常时,系统在终结程序之前会给发生异常的线程中所有注册的回调函数一个调用。不同的是,在调用之前要将EXCEPTION_RECORD
中的EXCEPTIONFLAGS
域置2,将ExceptionCode
域置为STATUS_UNWIND
这个回调的目的是给他们一个清理的机会,例如释放重要的系统资源,保存异常发生时关键变量的值等善后工作
进行栈展开的另一个目的是,如果不进行栈展开操作,就有可能引发未知的错误,
为什么要进行栈展开
一般情况下,只有在系统总结程序之前,栈展开才会发生,目的是给程序清理未释放资源的机会,如果决定要自己处理大部分异常,并在处理后继续正常执行,也可以参照系统的设计自己进行栈展开,给异常回调函数链表上的其他回调函数清理的机会
ExceptionRecord 结构的 ExceptionFlags 成员有三个值 0,1,2,分别代表 可修复异常,不可修复异常,栈展开
栈展开:所有回调函数都不处理异常时,系统在终结程序之前会调用发生异常的线程中所有注册的回调函数(被调用的函数执行自定义的操作,调用顺序是:从Fs:[0]开始依次到目前正在执行的异常处理函数之前,不包括当前回调函数)。在开始调用前,ExceptionFlags 会被置为 2,ExceptionCode 会被置为 STATUS_UNWIND(0x0C0000027),回调的目的是给程序一个清理占用的资源以及保存关键变量的值等等操作。利用汇编代码写 SEH 时可以不执行这个函数。
栈展开的另一个目的是,如果在调用了多重函数并在内层函数遇到异常,返回到最外层函数后,经过一系列的 Push 操作,而再次遇到异常时,由于 Fs:[0] 的值指向的还是原来的栈地址,会造成程序出现问题。
如何进行栈展开
微软提供了现成的API函数RtlUnwind
调用方式如下
RtlUnwind(VirtualTargetFrame,TargetPC,ExceptionRecord,ReturnValue)
VirtualTargetFrame
:展开时,最后在SEH链上停止于回调函数所对应的EXCEPTION_REGISTRATION
的指针,即希望在哪个回调函数前展开调用停止,其对应的EXCEPTION_REGISTRATION
结构的指针就作为该参数使用(在大部分情况下是引发调用的回调函数所对应的EXCEPTION_REGISTRATION
结构的指针TargetPC
:调用RtlUnwind
返回后应执行指令的地址。如果为0,则自然返回RtlUnwind
调用后的下一条指令,与正常的API调用相同ExceptionRecord
:当前异常的EXCEPTION_RECORD
结构,可以直接使用在异常中传递给回调函数的该参数ReturnValue
:返回值,通常不使用
MSC编译器对线程异常处理的增强
上面那些例子都是用汇编语言写的。但是,这个操作过程是极其不便的,尤其对高级语言来说,直接操作寄存器、读写fs[0]并不合适且非常烦琐。为此,各主流编译器都对 SEH 机制进行了扩充和增强,使程序员能更简便地使用异常处理机制。所以,在现实程序设计中,除了保护壳、反调试等特殊用途,基本上没有直接使用系统的SEH 机制,而是使用编译器提供的增强版本。C语言是Windows操作系统的开发语言,下面就看看微软的 C 编译器(MSC)提供的增强版本异常处理机制。
typedef struct _EXCEPTION_REGISTRATION PEXCEPTION_REGISTRATION;
//异常回调函数原型
typedef EXCEPTION_DISPOSITION(__cdecl *PEXCEPTION_ROUTINE)(
struct _EXCEPTION_RECORD *__ExceptionRecord, void *_EstablisherFrame,
struct _cONTEXT *_ContextRecord, void *_DispatcherContext
);
//C/C++运行库使用的_SCOPETABLE_ENTRY 结构typdef struct_SCOPETABLE_ ENTRY {
DWORD EnclosingLevel; //上一层__try块
PVOID FilterFunc; //过滤表达式
PVOID HandlerFunc; //_except 块代码或 _finally 块代码
}SCOPETABLE_ENTRY, *PSCOPETABLE_ENTRY;
struct _EH3_EXCEPTION_REGISTRATION
{ struct _EH3_EXCEPTION_REGISTRATION *Next; PVOID ExceptionHandler; PSCOPETABLE_ENTRY scopeTable; DWORD TryLevel;};
// C/C++编译器扩展 SEH 的异常帧结构
struct CPPEH_RECORD{ DWORD old_esp: EXCEPTION_POINTERS *exc_ptr; struct_EH3_EXCEPTION_REGISTRATION registration;
};
编译器真正使用的结构是 CPPEH_RECORD,其成员 _EH3_EXCEPTION_RECISTRATION 结构是对原始的 SEH结构 _EXCEPTION_RECISTRATION_RECORD 的扩充。该结构在原始版本的基础上增加了4个域成员(ScopeTable、Trylevel、old_esp、**exc_ptr )**来支持它的增强功能。
__try
{
/*可能产生异常的代码*/
}
__except( /*异常筛选代码*/ FilterFunc() }
{
/*异常处理代码*/
ExceptionHandler();
}
__try{ /*可能产生异常的代码*/}__fina1ly
{ /*终结处理代码*/
FinallyHandler();
}
__try / __finally 暂时可以看成 __try /__except 模型的一个特例,它本身不能处理异常,但是不管有没有发生异常,__finally 块总会被执行,通常用来进行一些收尾和清理工作。
现在程序员再进行异常处理就非常简单了,只要把可能产生异常的代码用 __try 包裹起来,然后在 FilterFunc 中判断是不是预料之中的异常即可。如果是,在 ExceptionHandler 中进行处理就可以了。而且,__try / __except ( __finally ) 结构可以进行多层嵌套,每层相应地只处理自己关心的异常。
这里的 FilterFunc 称为异常筛选器,它实际上是一个逗号表达式(注意,逗号表达式最后的值取决于最右边的表达式值,因为逗号表达式从左至右计算)。我们可以在这里完成任何工作——哪怕是计算圆周率,只要最后返回符合要求的结果就可以了。
正常来说__except 的表达式内的函数通过调用 GetExceptionCode 或 GetExceptionInformation 函数获取异常的详细信息,对特定的异常进行处理返回不同的值。通常有3种返回值,示例如下。
#define EXCEPTION_EXECUTE_HANDLER 1 表示该异常在预料之中,请直接执行下面的 Exception
#define EXCEPTION_CONTINUE_SEARCH 0 表示不处理该异常,请继续寻找其他处理程序
#define EXCEPTION_CONTINUE_EXECUTION -1 表示该异常已被修复,请回到异常现场再次执行
编译器的SEH增强设计
按照原始版本的设计,每一对“触发异常/处理异常”都有一个注册信息,即 EXCEPTION_REGISTRATION_RECORD (以下简称“ERR结构”)。也就是说,按照原始设计,每一个**__tryl_except(_finally)都应该对应于一个 ERR 结构。但是,而 MSC 编译器的实现是:每个使用_tryl_except (_finally)的函数,不管其内部嵌套或反复使用多少_tryl_exeept ( _finally),都只注册1遍,即只将1个ERR 结构挂入当前线程的异常链表中**。每对 __try / _except(_finally) 称为一个 Try 块。对于递归函数,每次调用都会创建一个ERR结构,并将其挂入线程的异常链表。
对于多个 Try 块而只有一个 Handler, MSC 提供一个代理函数,即 _EH3_EXCEPTION_RECISTRATION:ExceptionHandler 被设置为 MSC 的某个库函数。开发人员提供的多个 __exeept 块被存储在 _EH3_EXCEPTION_REGISTRATION::ScopeTable 数组中。
在由 VC6.0 编译生成的程序中,这个由编译器提供的异常处理函数叫作 _except_handler3 ,它实际上是公共库的一部分,系统的每个 DLL 里都有这个函数的实现。根据编译设置的不同,它的具体实现可能出现在 msvcrt.dl、kernel32.dll 这样的公共库中,也可能直接内联到 exe 本身,但具体代码都是一样的。这样,当异常发生时,系统会根据 SEH 链表找到**_except_handler3** 函数并执行它,_except_handler3 再根据编译时生成的 ScopeTable 执行开发人员提供的 FilterFunc 和 HandlerFunc ,也就是说,编译器提供的异常处理函数实现了一层代理的工作。
编译器的实际工作
① 把 FilterFunc 和 HandlerFunc 编译成单独的函数(FilterFunc 代码块和 Finally 代码块的尾部都有 retn 指令;而 HandlerFunc 尾部有 Jmp 指令,它跳转到 Try 块的结束;它们都是以函数的形式被调用的),并按照 Try 块出现的顺序及嵌套关系生成正确的 ScopeTable。
编译器对 SCOPETABLE_ENTRY 是这样定义的:对 __try / __except 组合来说,其中的 FilterFunc 就是过滤表达式代码块,HandlerFunc 就是异常处理代码块;对 __try / __finally 组合来说,FilterFunc 被置为 “NULL”, HandlerFunc 就是终结处理代码块。
对于函数中的每一个 Try 块,编译器都会生成一个 SCOPETABLE_ENTRY ,并按照 Try 块的出现顺序确定各个 Try 块的索引,索引值遵从C语言的习惯(从0开始)。在执行对应的 Try 块之前,编译器会把当前 Try 块的索引保存到 _EH3_EXCEPTION_RECISTRATION 结构的 TryLevel 成员中,它是函数中当前 Try 块的索引。每个 SCOPETABLE_ENTRY 结构中的 EnclosingLevel 则表示:如果当前 Try 块未能处理异常,那么要寻找下一级 Try 块(也就是包含当前 Try 块的父 Try 块)的索引。如果它的值是 -1,表示当前块已经没有父块了,当前函数已经不能处理该异常了。同时,-1也是 TryLevel 的默认值。
② 在函数开头布置 CPPEH_RECORD 结构,并安装 SEH 异常处理函数。在由 VC 6.0 编译的程序中,这个函数的名字是 _except_handler3。如果想详细分析 _except_handler3 函数,了解栈中的数据布局是非常有必要的。
③ 在进入每个 Try 块之后,先设置当前 Try 块的 TryLevel 值,再执行 Try 块内的代码。退出 Try 块保护区域后,恢复为原来的 TryLevel.
④ 在函数返回前,卸载 SEH 异常处理函数。
VOID TwoException()
{
int *pValue = NULL ;
__try
{
printf("In Try0.\n");
__try
{
printf("In Try1.\n");
*pValue = 0x55555555;
}
__except(printf("In Filter1\n"),EXCEPTION_CONTINUE_SEARCH)
{
printf("In Handler1.\n");
}
}
__except(printf("In Filter0\n"),EXCEPTION_EXECUTE_HANDLER)
{
printf("In Handler0.\n");
}
printf("After All Trys.\n");
}
Two Exceptions
.text:00401200 sub_401200 proc near ; CODE XREF: _main+F↑p
.text:00401200
.text:00401200 ms_exc= CPPEH_RECORD ptr -18h
.text:00401200
.text:00401200 ; __unwind { // __except_handler3
.text:00401200 55 push ebp
.text:00401201 8B EC mov ebp, esp
.text:00401203 6A FF push 0FFFFFFFFh
.text:00401205 68 10 71 40 00 push offset stru_407110
.text:0040120A 68 30 17 40 00 push offset __except_handler3
.text:0040120F 64 A1 00 00 00 00 mov eax, large fs:0
.text:00401215 50 push eax
.text:00401216 64 89 25 00 00 00+mov large fs:0, esp
.text:0040121D 83 EC 0C sub esp, 0Ch
.text:00401220 53 push ebx
.text:00401221 56 push esi
.text:00401222 57 push edi
.text:00401223 89 65 E8 mov [ebp+ms_exc.old_esp], esp
.text:00401226 33 F6 xor esi, esi
.text:00401228 ; __try { // __except at loc_401293
.text:00401228 89 75 FC mov [ebp+ms_exc.registration.TryLevel], esi
.text:0040122B 68 B0 82 40 00 push offset aInTry0 ; "In Try0.\n"
.text:00401230 E8 D1 03 00 00 call sub_401606
.text:00401235 83 C4 04 add esp, 4
.text:00401235 ; } // starts at 401228
.text:00401238 ; __try { // __except at loc_401267
.text:00401238 ; __try { // __except at loc_401293
.text:00401238 C7 45 FC 01 00 00+mov [ebp+ms_exc.registration.TryLevel], 1
.text:0040123F 68 A4 82 40 00 push offset aInTry1 ; "In Try1.\n"
.text:00401244 E8 BD 03 00 00 call sub_401606
.text:00401249 83 C4 04 add esp, 4
.text:0040124C C7 06 55 55 55 55 mov dword ptr [esi], 55555555h
.text:0040124C ; } // starts at 401238
.text:0040124C ; } // starts at 401238
.text:00401252 ; __try { // __except at loc_401293
.text:00401252 89 75 FC mov [ebp+ms_exc.registration.TryLevel], esi
.text:00401255 EB 4C jmp short loc_4012A3
.text:00401257 ; ---------------------------------------------------------------------------
.text:00401257
.text:00401257 loc_401257: ; DATA XREF: .rdata:stru_407110↓o
.text:00401257 ; __except filter // owned by 401238
.text:00401257 68 98 82 40 00 push offset aInFilter1 ; "In Filter1\n"
.text:0040125C E8 A5 03 00 00 call sub_401606
.text:00401261 83 C4 04 add esp, 4
.text:00401264 33 C0 xor eax, eax
.text:00401266 C3 retn
.text:00401267 ; ---------------------------------------------------------------------------
.text:00401267
.text:00401267 loc_401267: ; DATA XREF: .rdata:stru_407110↓o
.text:00401267 ; __except(loc_401257) // owned by 401238
.text:00401267 8B 65 E8 mov esp, [ebp+ms_exc.old_esp]
.text:0040126A 68 88 82 40 00 push offset aInHandler1 ; "In Handler1.\n"
.text:0040126F E8 92 03 00 00 call sub_401606
.text:00401274 83 C4 04 add esp, 4
.text:00401274 ; } // starts at 401252
.text:00401277 ; __try { // __except at loc_401293
.text:00401277 C7 45 FC 00 00 00+mov [ebp+ms_exc.registration.TryLevel], 0
.text:0040127E EB 23 jmp short loc_4012A3
.text:00401280 ; ---------------------------------------------------------------------------
.text:00401280
.text:00401280 loc_401280: ; DATA XREF: .rdata:stru_407110↓o
.text:00401280 ; __except filter // owned by 401228
.text:00401280 ; __except filter // owned by 401238
.text:00401280 ; __except filter // owned by 401252
.text:00401280 ; __except filter // owned by 401277
.text:00401280 68 7C 82 40 00 push offset aInFilter0 ; "In Filter0\n"
.text:00401285 E8 7C 03 00 00 call sub_401606
.text:0040128A 83 C4 04 add esp, 4
.text:0040128D B8 01 00 00 00 mov eax, 1
.text:00401292 C3 retn
.text:00401293 ; ---------------------------------------------------------------------------
.text:00401293
.text:00401293 loc_401293: ; DATA XREF: .rdata:stru_407110↓o
.text:00401293 ; __except(loc_401280) // owned by 401228
.text:00401293 ; __except(loc_401280) // owned by 401238
.text:00401293 ; __except(loc_401280) // owned by 401252
.text:00401293 ; __except(loc_401280) // owned by 401277
.text:00401293 8B 65 E8 mov esp, [ebp+ms_exc.old_esp]
.text:00401296 68 6C 82 40 00 push offset aInHandler0 ; "In Handler0.\n"
.text:0040129B E8 66 03 00 00 call sub_401606
.text:004012A0 83 C4 04 add esp, 4
.text:004012A0 ; } // starts at 401277
.text:004012A3
.text:004012A3 loc_4012A3: ; CODE XREF: sub_401200+55↑j
.text:004012A3 ; sub_401200+7E↑j
.text:004012A3 C7 45 FC FF FF FF+mov [ebp+ms_exc.registration.TryLevel], 0FFFFFFFFh
.text:004012AA 68 58 82 40 00 push offset aAfterAllTrys ; "After All Trys.\n"
.text:004012AF E8 52 03 00 00 call sub_401606
.text:004012B4 83 C4 04 add esp, 4
.text:004012B7 8B 4D F0 mov ecx, [ebp+ms_exc.registration.Next]
.text:004012BA 64 89 0D 00 00 00+mov large fs:0, ecx
.text:004012C1 5F pop edi
.text:004012C2 5E pop esi
.text:004012C3 5B pop ebx
.text:004012C4 8B E5 mov esp, ebp
.text:004012C6 5D pop ebp
.text:004012C7 C3 retn
.text:004012C7 ; } // starts at 401200
.text:004012C7 sub_401200 endp
反编译得到的代码
要注意,最外层的异常筛选器以及处理函数从属的代码段
他们分别覆盖了__except 代码段和编译器自己加的代码,对 TryLevel 进行赋值时也会检测其是否产生异常
而内层的异常筛选器以及处理函数:
只包含了 __except 代码段:
__except_handler3 函数流程解析
__except_handler3函数主要按以下流程工作。
① 在栈上生成一个EXCEPTION_POINTES结构,并将其保存到 [ebp-10] 处。
② 获取当前的 TryLevel,判断其值是否等于 -1。若等于,则表示当前不在 Try 块中(嵌套的里面也没有能处理的),返回 ExceptionContinueSearch,继续寻找其他异常处理程序。
③ 若 TryLevel 的值不等于-1,并根据 TryLevel 在 ScopeTable 中找到相应的 SCOPTETABLE_ENTRY,判断 FilterFunc 是否为“NULL”。若为“NULL”,说明是 __try / __finally组合。因为该组合不直接处理异常,所以也返回 ExceptionContinueSearch。
④ 若FilterFunc 不为“NULL”,说明是 __try / __except 组合,那么执行 FilterFunc,然后判断其返回值(也就是前面讲到的3种返回值),根据返回值的不同执行不同的动作。EXCEPTION_CONTINUE_SEARCH 和 EXCEPTION_CONTINUE_EXECUTION 的意义比较明确,就不多介绍了。若返回值是 EXCEPTION_EXECUTE_HANDLER,就是去执行 HandlerFunc,执行完毕会跳转到当前 Try 块的结束位置,同时表示本次异常处理结束,此时 _except_handler3 将不返回。
⑤ 如果异常没有被处理,最后会由系统默认的异常处理函数进行处理,它在展开时会调用 finally 块的代码。
因为几乎所有由 MSC 编译生成的 sys、dll 、exe文件都需要使用 _except_handler3 异常处理函数,并且都需要进行 SEH 的安装和卸载,所以编译器把这部分代码提取出来,形成了两个独立的函数,分别叫作 _SEH_prolog 和 _SEH_epilog。它们的主要作用就是把 _except_handler3 安装为 SEH 处理函数及卸载,这也是在反汇编那些使用了 SEH 的系统 API 时总会看到如下代码的原因。
更新一点版本的编译器增加了 SecurityCookie ,防止缓冲区溢出而设置的栈验证机制(即GS 保护机制),在函数开头会对栈中的 ScopeTable 使用 Cookie 进行加密,异常函数也变成了 _except_handler4。
C++ 的异常处理
多个 Catch 用于捕获多个异常
上面代码中的 stru_40DE48 实际指向了一个非常复杂的结构,它主要包含各个 try 块、 catch 块的位置信息、异常类型信息等,而 _CxxFrameHandler 的工作与 _except_handler3有很多相似之处,也需要定位发生异常的 try 块、匹配异常类型并执行相应的 catch 块。C++ 涉及各种对象的操作,复杂度比 C 要高很多。
顶层异常处理
顶层(Top-level)异常处理是系统设置的一个默认异常处理程序,所有在线程中发生的异常,只要没有被线程异常处理过程或调试器处理,最终均交由顶层异常回调函数处理。
Win XP 系统中:
进程的实际启动位置是 kemel32!BaseProcessStartTunk ,然后才跳转到 kernel32!BaseProcessStart ,它的反汇编结果如下。
.text:70817054 ; int __stdcall BaseProcessstart (PVOID ThreadStartAddress)
.text:7C817054 uExitCode dword ptr -1Ch
.text:7C817054 ms_exc = CPPEH_RECORD ptr -18h
.text:7C817054 ThreadStartAddress = dword ptr 8
.text:7C817054 push 0ch
.text:7C817056 push offset stru_7C817080
.text:7C81705B call__SEH_prolog
.text:7C817060 and [ebp+ ms_exc.registration.TryLevel],0
.text:7C817064 push 4 ; ThreadInformationLength
.text:7C817066 lea eax, [ebp+ ThreadstartAddress]
.text:7C817069 push eax ; ThreadInformation
.text:7C81706A push 9 ; ThreadInformationclass
.text:7C81706C push 0FFFFFFEEh ; ThreadHandle
.text:7C81706E call ds: NtSetInformationThread(x, x, x, x)
.text:7C817074 call [ebp+ThreadStartAddress]
.text:7C817077 push eax ; dwExitcode
.text:7C817078 call ExitThread (x)
.text:7C817078 __stdcall BaseProcessStart(x) endp
在使用 CreatThread 函数创建线程时,线程运行的起点是 kernel!BaseStartThunk ,而后跳转到 kernel32!BaseThreadStart,并由该函数执行 ThreadProc 。BaseThreadStart 也包含上述的异常处理代码,并且二者的 FilterFunc 都是 kernel!UnhandledExceptionFilter 。操作系统在执行任意一个用户线程(不管是不是主线程)之前,都已经为它安装了一个默认的 SEH 处理程序,这是该线程的第1个 SEH 处理程序,即顶层异常处理程序。
(1)对预定错误的预处理
①检测当前异常中是否是嵌套了异常,即异常处理的过程中是否又产生了异常。由于在这种情况下已经很难恢复现场和执行后续的异常处理过程了,UEF 函数会直接调用 NtTerminateProcess 结束当前进程。这大概解释了为什么明明设置了错误报告但是某些程序在出错退出时却依然悄无声息这个问题。
② 检测异常代码是不是 EXCEPTION_ACCESS_VIOLATION (0xc0000005),以及引起异常的操作是不是写操作。如果是,会进一步检测要写入的内存位置是否在资源段中,然后通过改变页属性来尝试修复该错误。
③ 检测当前进程是否正在被调试,这是通过查询当前进程的 DebugPort 实现的。如果进程正在被调试,那么 UEF 函数会打印一些调试信息并返回 EXCEPTION_CONTINUE_ SEARCH,也就是不进行后续的终结处理。由于这已经是最后一个异常处理程序了,该返回值会导致异常进行第2次分发。如果想调试后面的代码,在这里必须通过调试器干预 UEF 的查询结果,使它认为调试器不存在。例如,使 NtQueryInformationProcess 函数返回失败,或者使查询到的 ProcessDebugPort 值为0。
(2)调用用户设置的回调函数
为了在 UEF 阶段给用户一个干预的机会、微软提供了一个API函数 SetUnhandledExceptionFilter 。用户设置一个顶层异常过滤回调函数,在 kernel32!UnhandledExceptionFilter 中会调用它并根据它的返回值进行相应的操作,平时所说的“顶层异常回调函数”指的就是这个回调函数,而不是 UEF 函数。该API原型及参数类型定义如下。
API 函数 kernel32!SetUnhandledExceptionFilter 实际上把用户设置的回调函数地址加密并保存在一个全局变量 kernel32!BasepCurrentTopLevelFilter 中,因此:
① 不管调用这个 API 多少次,只有最后一次设置的结果才是有效的,所以在同一时刻每个进程只可能有一个有效的顶层回调函数。有些程序为了保证自己设置的异常处理过滤函数不会被其他模块覆盖,会在调用该函数后对其入口进行Patch,使它不再执行实际功能,这样就保证了不会有其他模块能够修改这个回调函数。
② 因为系统在创建用户线程时总会安装顶层异常处理过程,并把 UEF 函数作为异常过滤函数,所以该全局变量不仅对所有已经创建了的线程有效,对那些尚未“出生”的线程同样有效。这就为什么顶层异常处理是基于 SEH 和线程的,而它的有效范围却是整个进程。
UEF 函数会判断用户有没有设置回调函数,如果设置了就会进行调用。由于实际的异常过滤函数是 UEF 函数,用户设置的回调函数只是它的一个子函数调用,回调函数的返回值只在某些情况下等于UEF 函数的返回值。异常过滤函数有3种有效的返回值。
• EXCEPTION_EXECUTE_HANDLER:表示异常已经被顶层异常处理过程处理了,这会使异常处理程序执行 HandlerFunc,也就是退出当前线程(服务程序)或进程(非服务进程),操作系统不会出现非法操作框。如果在回调函数中已经做了必要的收尾工作,可利用返回该值来优雅地结束程序。
• EXCEPTION_CONTINUE_EXECUTION:表示顶层异常处理过程处理了异常,程序应该从原异常发生的指令处继续执行(Contex 结构体)。如果回调函数要这么做,那么在返回之前应该做出必要的修复异常现场的动作,这一点与普通的SEH处理程序是一样的。
• EXCEPTION_CONTINUE_SEARCH:表示顶层异常处理过程不能处理异常,需要将异常交给其他异常处理过程继续处理,这一般会导致调用操作系统默认的异常处理过程,也就是第3阶段的终结处理过程。
(3)终结处理如何进行严重依赖用户的设置
① 检查应用程序是否使用 API SetErrorMode() 设置了 SEM_NOGPFAULTERRORBOX 标志。如果设置了,就不会出现任何错误提示,直接返回 EXCEPTION_EXECUTE_HANDLER 以结束进程。
② 判断当前进程是否在 Job 中。如果在且设置了有未处理异常时结束,将直接结束进程
③ 读取用户关于JIT调试器(Just-In-Time,即时调试器)的设置,它保存在注册表中。设置相对应的值可以带命令行参数的将调试器附加到出错的进程上
④ 如果经过查询不需要,会加载 faultrep.dll,以异常信息为参数调用 ReportFault 函数,根据组策略的设置的不同,弹出不同类型的提示窗口。如果根据设置,不需要启动错误报告程序,ReportFault 会直接返回。这时,会调用系统服务 NtRaiseHardError,由子系统进程 csrss.exe 进行相关操作
顶层异常处理的典型应用模式
在程序设计中,开发人员普遍使用 SEH 机制来捕获可能产生的异常。但 SEH 不是万能的,总会有一些无法预料的情况发生,却不能被 SEH 处理。所以,一般的使用模式是:使用 SEH 捕获异常,并对那些预料之中的异常进行处理,其他无法处理的异常都会到达 UEF 函数处,由用户设置的回调函数进行收尾处理。
于是可以把异常现场的所有信息保存下来,形成一个快照文件,作为分析异常的依据(Dump文件)。用户可以手动把这个文件发送给开发人员,或者将文件自动上传到专门用于收集 Dump 的服务器中(通常还会生成一个文本类的文件用于保存本次异常的一些概要信息和那些无法保存到 Dump 文件中的信息)。“拍照”时的参数不同,生成的 Dump 文件所包含信息的丰富。
下面是一段典型 的在UEF回调中生成Dump
文件的代码
// WriteDump.cpp : Defines the entry point for the console application.
// Author:achillis
// 本程序用于演示顶层异常处理的使用及生成Dump技术
//
/*-----------------------------------------------------------------------
第8章 Windows下的异常处理
《加密与解密(第四版)》
(c) 看雪学院 www.kanxue.com 2000-2018
-----------------------------------------------------------------------*/
#include "stdafx.h"
#include <WINDOWS.H>
#include <DbgHelp.h>
#pragma comment(lib,"Dbghelp.lib")
LONG WINAPI TopLevelExceptionFilter(
struct _EXCEPTION_POINTERS* ExceptionInfo
);
int main(int argc, char* argv[])
{
//安装顶层异常处理回调
SetUnhandledExceptionFilter(TopLevelExceptionFilter);
int *pValue = NULL;
*pValue = 5 ; //引发内存访问异常
return 0;
}
LONG WINAPI TopLevelExceptionFilter(
struct _EXCEPTION_POINTERS* ExceptionInfo
)
{
printf("Exception Catched, Code = 0x%08X EIP = 0x%p\n",
ExceptionInfo->ExceptionRecord->ExceptionCode,
ExceptionInfo->ExceptionRecord->ExceptionAddress);
printf(".exr = 0x%p\n",ExceptionInfo->ExceptionRecord);
printf(".cxr = 0x%p\n",ExceptionInfo->ContextRecord);
HANDLE hDumpFile = CreateFile("Dump.dmp",
GENERIC_WRITE,
FILE_SHARE_READ,
NULL,
CREATE_ALWAYS,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hDumpFile == INVALID_HANDLE_VALUE)
{
return EXCEPTION_CONTINUE_SEARCH;
}
MINIDUMP_EXCEPTION_INFORMATION MinidumpExpInfo;
ZeroMemory(&MinidumpExpInfo,sizeof(MINIDUMP_EXCEPTION_INFORMATION));
MinidumpExpInfo.ThreadId = GetCurrentThreadId();
MinidumpExpInfo.ExceptionPointers = ExceptionInfo;
MinidumpExpInfo.ClientPointers = TRUE ;
BOOL bResult = MiniDumpWriteDump(GetCurrentProcess(),
GetCurrentProcessId(),
hDumpFile,
MiniDumpWithProcessThreadData,
&MinidumpExpInfo,
NULL,
NULL
);
printf("Write Dump File %s .\n",bResult ? "Success":"Failed");
CloseHandle(hDumpFile);
//Dump文件生成完毕,可以结束进程了
return EXCEPTION_EXECUTE_HANDLER;
}
异常处理程序的安全性
SEH的结构是存储在栈中
由于栈中数据的安全性有时无法得到保证,如溢出攻击时,栈中的SEHandler可能被覆盖为非法过程,从而执行攻击者预设的功能代码,为了防止此类的攻击,微软提供了SafeSEH
机制和SEHOP
机制,防止非法的SEHandler
程序执行
SafeSEH机制
这是从WindowsXP SP2
开始引入的一种安全机制,由操作系统和编译器联合提供,即由编译器提供SEH基础数据,由操作系统在产生异常时进行验证
-
编译器的工作
从VisualStudio 开始,在编译PE文件时加入了一个
SafeSEH
开关。如果编译时打开了这个开关,就会在PE偷的DLLCharacteristics
加入一个标志并在编译阶段提取所有异常处理程序的RVA,将它放入一个表。这个表的位置是由PE头部的`IMAGE_OPTIONAL_HEADER结构中数据目录的第10项决定的
SEHandlerTable
是指向一个SEH处理函数的RVA的表格,SEHandlercount
是这个表格的项数,指出了有几个有效的SEHandler当PE被载入,PE的基址,大小,SEHandlerTable(表格的地址),SEHandlerCount(长度)会保存在
ntdll.dll
的一个表格中。当异常发生时,系统会根据每个PE的机制和大小检查当前的SEHandler
处理函数属于哪一个PE模块,然后取出相应的表格地址和长度。在载入时就已经取出,载入后SEHandlerTable
和SEHandlerCount
就没有用处了,所以对它进行修改不会影响系统对SEHandler
的验证结果 -
操作系统的验证
我们在分析应用层异常分发的主要流程。系统对栈以及栈中的
EXCEPTION_REGISTRATION_RECORD
结构进行初步验证后,会调用RtlIsValidHandler
对异常回调函数的有效性进行验证伪代码如下
伪代码里面的
ExecuteDispatchEnable
和ImageDispatchEnable
位标志是内核KPROCESS
结构的一部分,用于控制当异常处理函数在不可执行内存或者不在异常模块的IMAGE内是否执行异常处理函数。这两个位的值可以在运行时修改。不过,在默认情况下,如果进程的DEP(data Execution Prevention数据执行保护)处于关闭状态,则这两个位置1,如果进程的DEP处于开启状态,则这两个位置0在进程的DEP开启的情况下,如下两种异常处理函数被异常分发器认为是有效的
- 异常处理函数在进程Image的
SafeSEH
表中,没有NO_SEH
标志 - 异常处理函数在进程映像的可执行页中,没有
NO_SEH
标志,没有SafeSEH
表,也没有.NET
的ILonly
标志
在进程DEP关闭的情况下,如下3中异常处理函数被异常分发器认为是有效的
- 异常处理函数在进程Image的
SafeSEH
表中,没有NO_SEH
标志 - 异常处理函数在进程Image的可执行页中,没有
NO_SEH
标志,没有SafeSEH
表,也没有.NET
的ILonly
标志 - 异常处理函数不在当前进程的映像里,也不在当前线程的栈上
- 异常处理函数在进程Image的
-
如果SEHandler处于动态申请的内存中,因为它不处于任何一个PE Image内,所以SEH是没有任何限制的;否则,不在相应的表格中,会导致SEH部分的异常处理终端,跳过后面所有的SEH节点遍历。
-
在
visualC++
中使用的try
和except
和finally
以及C++的try
,catch
以及finally
等SEH处理函数,都会被编译器自动放入该表格 -
但如果使用
inline asm
对fs:[0]
进行操作设置的SEH就是无效的
SEHOP机制
SEHOP是微软为了进一步增强SEH处理程序的安全性从2009年开始在Windows Server 2008 SP0
,Windows Vista SP1
和Windows 7
以及后续版本中加入的一种保护机制,全称是
Structured Exception Handling Overwrite Protection
SEH覆写保护机制,即可作为SEH的拓展,用于检测SEH是否被覆写
初识SEHOP
SEHOP的核心检测主要包括如下两点
- 检测SEH链的完整性,即每一个节点都必须在栈中并且都可以正常访问
- 检测最后一个节点的异常处理函数是不是位于
ntdll
汇总的ntdll!FinalExceptionHandler()
一般使用SEH攻击执行Shellcode时,通常是用jmp 06 pop pop ret
等命令来覆盖SEH结构,此时从当前SEH节点已经无法正确指向下一个SEH节点,更不用说到达最后那个特殊的SEH节点。
因此通过SEHOP的检测就可以判断SEH结构链表的完整性是否遭到破坏,
从而阻止Shellcode的执行
SEHOP默认在Server中是开启的,而在Win7是默认关闭的
SEHOP与顶层异常处理
开启SEHOP保护后,在SEH链的最后增加了一个节点
此时倒数第二个节点才是前面介绍的线程顶层异常处理函数所在的节点
似乎与顶层异常处理机制矛盾
其实不然
当系统沿SEH链查找异常处理程序时,在倒数第2个节点会执行kernel32!UnhandledExceptionHandler
函数进行终结处理,此时最后一个节点的异常处理函数根本不会发挥作用
最后一个节点实际上只在对SEHandler进行验证时起到辅助作用
即使基于某些原因或者在某些特殊的线程中,执行到最后一个节点的异常处理函数
ntdll!FinalExceptionHandler
,该函数内部依然会调用kernel32.dll
中的UEF
函数或ntdll.dll
自己的UEF
函数,
所以这与顶层异常处理的过程并不矛盾
SEHOP的保护是系统层的,很难绕过,而且不用修改原有的应用程序,而且对性能的影响很小
常见的绕过思路是,攻击者能够实现操纵栈中的数据,同时可以伪造节点以保证整个SEH链有效,保证最终到达Safe节点