异常主要分为软件和硬件(CPU引起的)两种。除了CPU能够捕获一个事件引发一个硬件异常以外,在代码中可以主动采用RaiseException
引发一个软件异常
异常处理基本过程
IDT
Windows正常启动后,将运行在保护模式下,当有终端或者异常发生时,CPU会通过IDT(中断描述符表)来寻找处理函数。所以,IDT是CPU(硬件)和操作系统(软件)交接中断和异常的关口。每一个中断或异常向量在这个系统表中有对应的中断或异常处理程序入口地址。
IDT包含三种类型的中断描述符:任务门、中断门、陷阱门。
任务门:主要用于CPU的任务切换(当中断信号发生时,必须取代当前进程的那个进程的TSS(任务状态描述符表)选择符存放在任务门中)。
中断门:主要用于描述中断处理程序的入口。
陷阱门:主要用于描述异常处理程序的入口。
异常处理准备工作
当中断或者异常发生时,CPU会更具中断类型号转而执行对应的中断处理程序。各个异常处理函数除了针对本异常的特定处理外,通常会将异常信息进行封装,以便后续处理。
封装内容一部分是异常记录,另一部分是陷阱帧。
异常记录的结构定义如下:其中第一个字段定义了异常产生原因,当然也可以使用RaiseException
函数自己定义
陷阱帧准确描述了发生异常时线程的状态(Windows任务调度是基于线程的),_KTRAP_FRAME
该结构与处理器高度相关,不同平台有不同定义。(每个线程在内核中都有一个 _KTrap_Frame
结构体,包含每个寄存器的状态,用于操作系统对线程的管理,该结构体时操作系统进行维护的。)
当需要把控制权交给用户注册的异常处理程序时,会将上述结构转换成一个名为CONTEXT
的结构,它包含线程运行时处理器各主要寄存器的完整镜像,用于保存线程运行环境。其中第一个变量ContextFlags
表示结构体中哪些部分(这些部分包括:控制寄存器、(整数)通用寄存器、段寄存器、浮点寄存器、调试寄存器、扩展寄存器)有效。
1 | typedef struct _CONTEXT |
异常处理函数会进一步调用内核的nt!KiDispatchException
函数来处理异常,第一个参数和第二个参数正是前面封装的两个结构
内核态的异常处理过程
当PreviousMode
为KernelMode
,KiDispatchException
按照以下步骤分发异常:
- 检测当前系统是否正在被内核调试器调试
- 如果不存在内核调试器,或者在上一步就会选择不处理该异常,系统就会调用
nt!RtlDispatchException
- 如果
nt!RtlDispatchException
没有处理该异常,系统会给调试器第2次处理机会 - 如果不存在内核调试器或者在第2次机会调试器仍然不处理,系统就会认为这种情况下不能继续运行了,系统会直接调用
KeBugCheckEx
产生错误码(0x0000008E)的BSOD(蓝屏)
当PreviousMode
为UserMode
,KiDispatchException
按照以下步骤分发异常:
检测当前系统是否正在被内核调试器调试(一般不予理会)
如果发生异常的程序正在被调试,那么将异常信息发送给正在调试它的用户态调试器;若否则跳过
如果没有用户态调试器或者没有处理该异常,那么在栈上放置
EXCEPTION_RECORD
和CONTEXT
两个结构,并将控制权返回用户态ntdll中的KiUserExceptionDispatcher
函数,由它调用ntdll!RtlDispatchException
函数进行用户态的异常处理。(涉及SEH和VEH)如果
ntdll!RtlDispatchException
未能处理该异常,那么异常处理过程会再次返回nt!KiDispatch Exception
,它将再次把异常信息发送给用户态的调试器,给调试器第2次处理机会。如果没有调试器存在,则不会进行第2次分发,而是直接结束进程。如果第2次机会调试器仍不处理,
nt!KiDispatchException
会再次尝试把异常分发给进程的异常端口进行处理在终结程序之前,系统会再次调用发生异常的线程中的所有异常处理过程,这是线程异常处理过程所获得的清理未释放资源的最后机会,此后程序就终结了。
SEH概念以及基本知识
**SEH( Structured Exception Handling,结构化异常处理)**是 Windows操作系统用于自身除错的一种机制,也是开发人员处理程序错误或异常的强有力的武器。SEH是一种错误保护和修复机制,它告诉系统当程序运行出现异常或错误时由谁来处理,给了应用程序一个改正错误的机会。从程序设计的角度来说,就是系统在终结程序之前给程序提供的一个执行其预先设定的回调函数的机会。
相关数据结构
TIB
**TIB( Thread Information Block,线程信息块)是保存线程基本信息的数据结构。在用户模式下,它位于TEB( Thread environment Block,线程环境块)**的头部,而TEB是操作系统为了保存每个线程的私有数据创建的,每个线程都有自己的TEB。
在x86平台的用户模式下,Windows将FS段选择器指向当前线程的TEB数据,即TEB总是由**fs:[0]指向的(在x64平台上,这个指向关系变成了gs:[0]。关于x64平台上的异常处理,会在85节详细讲述)。而当线程运行在内核模式下时, Windows将FS段选择器指向内核中的 KPCRB结构( Processor Control Region Block,处理器控制块)**,该结构的头部同样是上述的 NT_TIB
结构。
_EXCEPTION_REGISTRATION_RECORD结构
该结构主要用于描述线程异常处理过程的地址,多个该结构的链表描述了多个线程异常处理过程的嵌套层次关系。
异常处理链表示意图如下(其中ERR指的就是该结构)
由于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标准的回调函数,然后使用如下代码进行SEH异常处理程序的安装。
理解了SEH的安装过程,再看SEH的卸载就比较简单了,只要把刚才保存的fs:[0]的原始值填回并恢复栈的平衡即可,相当于从链表头部删掉了一个节点,示例如下。
SEH实例跟踪
因为没有找到示例的EXE,所以这里直接记录原文。
示例程序 seh.exe演示了一个结构异常处理的例子。由于 Windbg结合符号文件可以非常清楚地显示各类数据结构,这里使用 Windbg进行调试。用 Windbg打开该程序,输入“g”
命令让 seh. exe运行,程序将在0x00401038处停住,具体如下。
可以知道这是一个内存访问异常,因为程序试图从esi寄存器指向的0地址处读取数据,并且是第1次给调试器处理机会( First Chance)为了跟踪系统对异常的处理过程,需要在系统的异常处理代码处下断点,示例如下。
然后,在 WinDbg的命令窗口中输入“g”
,继续执行。四命令表示不处理异常并继续执行。接下来,异常会被交给用户态的 ntdll!KiUserExceptionDispatcher
函数进行进一步分发,所以在这个函数的开头会发生中断,示例如下。
可以看到,此时栈顶就是 EXCEPTION_POINTERS
结构,它包含 EXCEPTION_RECORD
和CONTEXT
两个数据成员,具体如下。
Exception_Record =0012fcd00
Context Record = 0012fcec
程序当前的esp为00012c8,可以看出它们之间的位置关系如下。
esp指向_ EXCEPTION_ POINTERS结构,其大小为8字节。
Exception Record=esp+8,也就是说,
EXCEPTION_POINTERS
结构之后紧跟EXCEPTION_RECORD
。观察EXCEPTION_RECORD
结构,具体如下。
该结构已经明确指出了异常代码( Exception Code)和发生异常的位置( Exceptionaddress)。
EXCEPTION_RECORD
结构的最后一个元素 Exceptioninformation
的大小是不固定的,实际上这是由前一个元素 Numberparameters
决定的,因此,在当前例子中, EXCEPTION RECORD的实际大小如下。o
0x14 + 2 * sizeof(ULONG)=0x1c
ExceptionRecord + 0x1C = 0012fcd0 + 0x1c = 0012fcec ( ContextRecord的位置)
下面看一下
CONTEXT
结构,其中esi和eip与上面图中一样接下来从源代码分析一下(手脱yoda最开始有这样的程序例子)
重新加载程序,执行到0x00401036处时,SEH异常回调函数安装完毕。观察此时的TIB结构,具体如下。
可以看到,当前SEH链的第1个节点在0x0012ffb8处,该节点的 Handler为0x00401000。对0x00401000设断,然后输入“g”命令让程序运行。当执行0x00401038一句读取线性地址0时会产生异常,程序跳转到0xo0401000( Handler)处继续执行。因此,只有提前在 Handler地址处设置断点,才能正常跟踪SEH代码的运行,否则代码就有可能跟“飞”了。
SEH异常处理程序原理及设计
异常分发详细过程
异常处理的过程实际上是系统将异常发送到各个异常处置单元进行处理的过程,也叫异常分发。
用户态的异常分发是从 ntdll!KiUserException_Dispatcher
函数开始的。此时,栈中有 EXCEPTION_RECORD
和 CONTEXT
两个结构。该函数的主要流程可以用以下代码表示。
可以看到ntdll!RtlDispatchException
函数用来具体分发异常,我们详细看一下这个函数的代码
①调用VEH ExceptionHandler进行异常处理,若返回继续执行,则直接返回,否则继续进SEH部分的处理。
②遍历当前线程的异常链表,逐一调用 RtlpExecuteHandlerForException
。该函数会调用SEH异常处理函数,根据不同的返回值进行不同的处理。
对
ExceptionContinueExecution
,结束遍历并返回(对标记为EXCEPTION_NONCONTINUABL
的异常不允许再次恢复执行,会调用RtlRaiseException
)对
ExceptionContinuesearch
,继续遍历下一个节点。对
ExceptionNestedException
,从指定的新异常开始继续遍历。只有正确处理ExceptionContinueExecution
才会返回“TRUE”,其他情况都返回“ FALSE”。③调用 VEH ContinueHandler进行异常处理。
线程异常处理
其实就是指SEH异常处理,因为SEH的整体设计思路就是基于线程的。
回调函数原型定义如下
pExcept:指向前面介绍的包含异常处理信息的
EXCEPTION_RECORD
结构的地址pFrame:指向SEH链中当前
EXCEPTION_REGISTRATION
结构的地址。pContext:指向与线程相关的寄存器映像
CONTEXT
结构的地址。pDispatch:该域用于内嵌异常的处理,读者可以暂时忽略它。
回调函数要做的就是通过 EXCEPTION_RECORD
结构中的信息判断当前异常是不是自己能够处理的。如果能,那么需要根据异常产生的原因进行相应的修正,必要时会修改 CONTEXT
结构,然后恢复执行;如果不能,则告诉系统去寻找下一个处理程序。
ExceptionContinueExecution:表示回调函数处理了异常,可以从异常发生出继续执行。
ExceptionContinueSearch:回调函数不能处理异常,需要SEH回调函数链表中其他回调函数来处理。
ExceptionNestedException:回调函数在试图处理该异常的时候再次发生了异常(嵌套异常)。这种情况是比较糟糕的。如果这种情况发生在内核中,则会直接 Bugcheck,停止系统的运行。如果这种情况发生在应用层,系统会尝试从嵌套异常的事发地点重新分发和处理嵌套异常。
ExceptionCollidedUnwind:返回该值表示回调函数在进行异常展开操作时再次发生了异常,其中展开操作可以简单理解为恢复发生事故的第一现场,并在恢复过程中对系统资源进行回收。与上一个返回值一样,这也是非常严重的错误。但是,由于展开操作一般是由系统在处理异常的过程中进行的,用户自定义的回调函数通常不返回这个值。
后两个返回值只见于系统内部的处理过程。
异常处理的栈展开
我们已经知道,传递给回调函数的 EXCEPTION_RECORD
结构的 Exceptionflags
域有3个可选值,分别是0、1和2。0表示可修复异常,前面的例子都是可修复异常;1代表不可修复异常,这在应用程序中不多见,只有在异常处理中又发生了异常或者系统内核发生严重错误时才可能导致这种情况;2代表展开操作。
一般情况下,只有在系统终结程序之前,栈展开才会发生,最主要目的是给程序清理未释放资源的机会。
还有一种情况会使用栈展开(如下图介绍)
Windows提供了进行栈展开的API函数RtlUnwind
- VirtualTargetFrame:展开时,最后在SEH链上停止于回调函数所对应的
EXCEPTION_REGISTRATION
的指针,即希望在哪个回调函数前展开调用停止,其对应的EXCEPTION_REGISTRATION
结构的指针就作为该参数使用(在大部分情况下是引发调用的回调函数所对应的EXCEPTION_REGISTRATION
结构的指针,也可以不是) - TargetPC:调用
Rtlunwind
返回后应执行指令的地址。如果为0,则自然返回Rtlunwind
调用后的下一条指令,与正常的AP调用相同。 - Exceptionrecord:当前异常的
EXCEPTION_RECORD
结构,可以直接使用在异常中传递给回调函数的该参数。 - Return value:返回值,通常不使用。
_except_handler3函数
①在栈上生成一个·EXCEPTION POINTES
结构,并将其保存到[ebp-10]处。
②获取当前的Trylevel
,判断其值是否等于-1。若等于,则表示当前不在Try
块中,返回ExceptionContinueSearch
,继续寻找其他异常处理程序。
③若 Try Level
的值不等于-1,并根据 Try Level
在 Scope Table中找到相应的 SCOPTETABLEENTRY
,判断 Filter Func
是否为“NULL”。若为“NUL”,说明是_try/finally
组合。因为该组合不直接处理异常,所以也返回 ExceptionContinueSearch
④若 Filterfunc
不为“NUL”,说明是_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时总会看到如下代码的原因。
不过现在已经更新,SCOPE_TABLE
增加了 Security Cookie的相关内容,这是微软为了防止缓冲区溢出而设置的栈验证机制。在函数开头会对栈中的 ScopeTable
使用 Cookie作为密钥进行加密,异常处理函数也变成了_ except_handler4
。在该函数中,除了增加了对 Security Cookie
和 ScopeTable
的验证之外,整体流程与_ except_handler3
完全一致。
顶层异常处理
顶层(Top- -level)异常处理是系统设置的一个默认异常处理程序,所有在线程中发生的异常只要没有被线程异常处理过程或调试器处理,最终均交由顶层异常回调函数处理。Q:SEH不是基于线程的吗?为什么这个 Toplevel异常处理程序能够处理所有线程的异常呢?
一般程序开始创建任意线程运行前默认设置一个顶层异常处理程序。所谓的顶层异常处理意思是,当程序的异常通过SEH链一直传递到最后一个我们自己设置的SEH链结点都没有处理此异常时,会调用这个顶层异常处理函数。
1 | _try |
- SEH是基于线程的,因为每一个线程都有自己的TEB,又因为TEB的第一个字段指向SEH链的头部,所以每一个线程都有自己的SEH链,不同线程之间的SEH链也不同。每个线程创建之前都会安装顶层异常处理过程,而每一个顶层异常处理程序的过滤函数都是
UnhandledExceptionFilter
,此函数会在内部调用全局变量kernel32!BasepCurrentTopLeveFilter
保存的函数地址,此函数可以干预UnhandleExceptionFilter
的返回值。而我们可以调用SetUnhandledExceptionFilter
函数改变此全局变量的值为自定义函数的地址。所以顶层异常处理是基于进程的(顶层异常过滤函数返回值干预函数的值是全局变量保存的)。 - 如果程序是用MSC编译器编译的程序默认的异常处理函数一般是
Kernel32!_except_handlerX
,Kernel32!_except_handlerX
会先调用UnhandleExceptionFilter
过滤函数,而如果我们在UnhandleExceptionFilter
过滤函数中已经把此异常处理了则就不会执行默认的异常处理,否则默认的异常处理就是结束应用程序。
顶层异常处理在反调试中的应用
- 为当程序产生一个异常时,首先会被系统内核捕捉然后系统内核会判断当前是否有调试器调试,有的话把异常交给调试器。如果没有调试器或者调试器处理不了此异常的话,异常会被分发给SEH链上的各个异常处理回调函数依次处理,这些回调函数的地址是通过一个链表存储。通过遍历这个链表从而调用各个异常处理回调函数。如果异常被某个回调函数正常处理了就继续从产生异常的代码处继续往下执行。如果一直遍历到链表的最后一个异常回调函数之前都没能够处理此异常的话,此异常就会交给最后一个默认的异常处理程序处理。
- 而最后一个默认异常处理函数在调用时会先调用
UnhandledExceptionFilter
过滤函数,此函数又会调用ZwQueryInformationProcess
(函数先判断是否有调试器存在,有的话会直接返回进行异常的二次分发(一般就是会结束进程)。 - 如果
ZwQueryInformationProcess
函数没有检测到调试器的存在的话其将会执行默认异常处理。而一般默认异常处理是默认的终止程序的函数。(如果没有调试器的话异常是不会进行二次分发的,这点很重要) - 但是windows提供一个函数来对
UnhandledExceptionFilter
进行干预从而修改其返回值让其正常返回,而不执行默认的异常处理终止程序。此函数就是SetUnhandledExceptionFilter
,此函数具有唯一的参数就是设置用来干预UnhandledExceptionFilter
过滤函数的回调函数的地址,UnhandledExceptionFilter
会在内部调用这个函数。(一般称这个函数为顶级异常处理函数,我认为这么称是不准确的。因为真正的顶级异常处理函数是默认的异常处理函数,此函数应该称为顶级过滤干预函数)
所以一般会利用此顶层异常处理函数的特性,主动产生异常,然后调用
SetUnhandledExceptionFilter
设置顶层过滤函数的干扰函数来处理异常,如果用户调式程序的话,因为顶层异常处理会检测到有调试器,因此不会调用顶层过滤干扰函数来处理异常,导致异常无法处理从而终止运行。(需要我们改变ZwQueryInformationProcess
函数的返回值来骗过UnhandledExceptionFilter
函数让他以为无调试器)
Detail:详细看《加密与解密》,后面准备逆向一个程序详细分析一下这个异常处理
异常处理程序的安全性
为了防止比如某些溢出攻击时,栈中的SEHandler可能被覆盖为非法过程的情况,微软提供了SafeSEH机制、SEHOP机制。
SafeSEH机制
编译器提供SEH基础数据,由操作系统在产生异常时进行验证。首先编译器在编译PE文件时加入了一个 SafeSEH
开关。如果编译时打开了这个开关,那么编译器会在PE头的 DllCharacteristics中加入一个标志,并在编译阶段提取所有异常处理程序的相对虚拟地址(RVA),将它放入一个表。这个表的位置是由PE头部 IMAGE_OPTIONAL_HEADER
结构中数据目录的第10项指定的,相关定义如下。
Sehandlertable
是指向一个SEH处理函数Rva的表格, Sehandlercount
是这个表格的项数,它指出了有几个有效的 SEHandler。当PE被载人时,PE的基址、大小、 Sehandlertable
(表格的地址)、Sehandlercount
(长度)会保存在ntdl的一个表格中。当异常发生时,系统会根据每个PE的基址和大小检查当前 Sehandler处理函数属于哪一个PE模块,然后取出相应的表格地址和长度。在载入时就已经取出,载入后 Sehandlertable
和 Sehandlercount
就没有用处了,所以对它进行修改不会影响系统对 SEHandler的验证结果。
系统在对栈及栈中的 EXCEPTION_REGISTRATION_ RECORD
结构进行初步验证之后,会调用 RtlIsValidHandler
对异常回调函数的有效性进行验证。该函数的伪代码如下。
1 | BOOL RtlIsValidHandler(handler) |
- 检查异常处理链是否位于当前程序的栈中,如果不在当前栈中,程序将终止异常处理函数的调用
- 检查异常处理函数指针是否指向当前程序的栈中。如果指向当前栈中,程序将终止异常处理函数的调用
- 在前面两项检查之后,程序调用全新的函数RtlIsValidHandler(),来对异常处理函数的有效性进行检验。
- 首先该函数判断异常处理函数地址是不是在加载模块的内存空间,如果属于加载模块的内存空间,检校函数将依次进行如下判断:
- 判断程序设置IMAGE_DLLCHARACTERISTICS_NO_SEH标识。设置了,异常就忽略,函数返回校验失败。
- 检测程序是否包含SEH表。如果包含,则将当前异常处理函数地址与该表进行匹配,匹配成功返回校验成功,否则失败。
- 判断 程序是否设置ILonly标识。设置了,标识程序只包含.NET编译人中间语言,函数直接返回校验失败
- 判断异常处理函数是否位于不可执行页(non-executable page)上。若位于,校验函数将检测DEP是否开启,如若系统未开启DEP则返回校验成功;否则程序抛出访问违例的异常
- 如果异常处理函数的地址没有包含在加载模块的内存空间。校验函数将直接执行DEP相关检测,函数将依次进行如下检验:
- 判断异常处理函数是否位于不可执行页(non-executable page)上。若位于,校验函数将检测DEP是否开启,如若系统未开启DEP则返回校验成功;否则程序抛出访问违例的异常
- 判断系统是否允许跳转到加载模块的内存空间外执行,如允许则返回校验成功;否则返回校验失败
SafeSEH不能解决的问题
- 异常处理函数位于加载模块内存范围之外,DEP关闭
- 异常处理函数位于加载模块内存范围之内,相应模块未启用SafeSEH(安全SEH表为空),同时相应模块不是纯IL
- 异常处理函数位于加载模块范围之内,相应模块启用SafeSEH(安全SEH表不为空),异常处理函数地址包含在安全SEH表中
分析三种情况的可行性:
- 只考虑SafeSEH,不考虑DEP干扰,需要在加载模块内存范围之外找到一个跳板指令就可以转入shellcode中执行
- 第二种情况中,我们可以利用未启用SafeSEH模块中的指令作为跳板,转入shellcode执行。这也是一再强调SafeSEH需要操作系统与编译器的双重支持。在加载模块中找到一个未启用SafeSEH模块不是很难
- 第三种情况下,可以考虑:a)清空安全SEH表,造成该模块未启用SafeSEH假象;b)将指令注册到安全SEH表中。由于安全SEH表的信息在内存中加密存放,所以突破它的可能性不大,放弃!!
另外突破SafeSEH的方法:
- 不攻击SEH。使用覆盖返回地址或者虚函数表等信息。
- 利用SEH的安全校验的严重缺陷——如果SEH中的异常函数指针指向堆区,即使安全校验发现SEH不可信,仍会调用其已修改过的异常处理函数。
SEHOP机制
SEH覆写保护机制,用于检测SEH是否被覆写
- 检测SEH链的完整性,即每一个节点都必须在栈中,并且都可以正常访问。
- 检测最后一个节点的异常处理函数是不是位于ntdll中的
ntdll!FinalExceptionHandler
。
突破SEHOP方法
不去攻击SEH,而是攻击函数返回地址或虚函数等
利用未启用SEHOP的模块
伪造SEH链
向量化异常处理
向量化异常处理的基本理念与SEH相似,也是注册一个回调函数,当发生异常时会被系统的异常处理过程调用。可以通过API函数 AddVectoredExceptionHandler
注册VEH回调函数,其原型如下。
VEH回调函数也形成了一个链表。若参数 FirstHandler
的值为0,则回调函数位于VEH链表的尾部;若参数 FirstHandler
为非零值,则置于VEH链表的最前端,当有多个VEH回调函数存在时,这将影响回调函数被调用的顺序。应将该函数的返回值保存下来,用于卸载回调函数。VEH回调函数所在的模块被卸载之后,系统不能自动将回调函数地址从VEH链表上移除,需要程序在退出前自己完成卸载工作,可以使用如下API实现。
该函数只有一个参数,即 Vectoredhandlerhandle
(就是前面保存的 AddVectoredExceptionHandler
的返回值)。回调函数的参数 PEXCEPTION_POINTERS
与在顶层异常处理回调函数中用到的参数是一致的,即指向 EXCEPTION_POINTERS
结构的指针。回调函数合理的返回值只有两个,分别是 EXCEPTIONCONTINUE EXECUTION
和 EXCEPTION_ CONTINUE_SEARCH
,其意义与SEH回调函数的意义相同。
VEH和SEH的异同
当异常发生时,VEH会优先于SEH获得控制权(但如果有调试器,调试器会优先于VEH回调函数),系统会自动调用 AddVectoredExceptionHandler
注册的VEH回调函数。如果回调函数修复了异常,则返回 EXCEPTION_CONTINUE_EXECUTION
,在异常发生处以 CONTEXT
指定的线程环境继续运行,此时SEH处理过程将被跳过。如果回调函数不能处理异常,回调函数应返回 EXCEPTION_CONTINUE_SEARCH
,系统会釆取与SEH大致相同的策略遍历VEH链表。如果整个链表搜索完毕,没有回调函数对异常进行处理,则将控制权转移给系统,由系统继续遍历SEH链表上注册的回调函数,其过程如SEH机制所示。
- 注册机制不同。SEH的相关信息主要保存在栈中,而且后注册的回调函数总是处于SEH链的前端,也就是说,当异常发生时,异常总是由内层回调函数优先处理,只有在内层回调函数不处理异常时,外层回调函数才有机会获得控制权。而VEH不同,它的相关信息保存在独立的链表中(实际存储在ntdll中),在注册VEH时可以指定回调函数是位于VEH链表的前端还是尾部,这就避免了我们希望在SEH中获得优先处理权却常常不能如愿的问题。
- 优先级不同。VEH优先于SEH被调用,这对某些需要先于SEH取得异常处理权的特殊程序来说非常重要。如果VEH表明自己处理了异常,那么SEH将没有机会再处理该异常。
- 作用范围不同。SEH机制是基于线程的,也就是说,同一进程内的A线程无法捕获和处理B线程产生的异常,并且对特定的SEH处理程序来说,它的作用范围更是局限在安装它的那个函数内部(除了顶层异常处理这个特殊的全局回调函数)。而VEH在整个进程范围内都是有效的,它可以捕获和处理所有线程产生的异常。
- VEH不需要栈展开。由于SEH的注册和使用依赖于函数调用的栈帧,在调用SEH回调函数时会涉及栈展开的问题,这样SEH就有2次被调用的机会。因为VEH的实现不依赖栈,所以在调用VEH回调函数前不需要进行栈展开,它只有1次被调用的机会。
X64平台上的异常处理
与X86的区别是,SEH的相关数据结构以及存储位置发生了改变。因为栈是动态变化的,很容易被缓冲区溢出等操作破坏, SafeSEH和 SEHOP机制是在必须保证兼容性的情况下不得不采取的增强保护措施,属于无奈之举。所以,到了新的x64平台上,不仅统一了函数调用约定为 _fastcall,对SEH的相关数据结构也重新进行了定义,主要设计思路如下。
在具体实施上,编译器主要做了以下工作。提取所有函数的起始地址、结束地址、函数的“序幕”操作(包括栈操作和寄存器操作)、异常处理信息等,生成两个表。其中一个表可以称作函数信息表,包含当前程序中所有函数在内存中的位置信息(除了叶函数,即那些既不调用其他函数,也不包含异常处理的函数)。这些信息被放在一个单独的区段 .pdata
中,该区段的位置可以从PE头部的数据目录 IMAGE_DIRECOTRY_EXCEPTION_DIRECOTY
(定义为3)中找到,数据定义如下.
在该表中,每一条函数的信息被称为一个 Function Entry
,所有 Function Entry
都是按照RVA的大小升序排列的,这样便于使用二分法快速查找。除了函数的起始和结束位置,上述结构中还包括 UnwindData
数据,它也是一个RVA,所指向的结构则包括函数的“序幕”操作(包括栈操作和寄存器操作)和异常处理信息等。这个表叫作**UNWIND_DATA
表**,示例如下。