📄 014.txt
字号:
push [eax + 8]
pop [edi].regEip
push [eax + 0ch]
pop [edi].regEbp
push eax
pop [edi].regEsp
assume esi:nothing,edi:nothing
popad
mov eax,ExceptionContinueExecution
ret
_Handler endp
14.3 使用SEH处理异常(3)
将_lpSEH参数放入eax后,[eax]相当于EXCEPTION_REGISTRATION结构的prev字段,[eax+4]相当于handler字段,从[eax+8]开始就是程序自定义的数据了,按照主程序中的入栈顺序,[eax+8]是“安全地址”,[eax+0ch]是程序保存的ebp值,程序中并没有单独设置一个自定义字段来保存esp,因为在这个例子中,_lpSEH本身就相当于正确的esp。经过这样的处理后,整个异常处理过程将不使用任何全局变量。修改后的完整代码可以在本书所附光盘的Chapter14\SEH02目录中找到。
Windows下的许多高级语言都在EXCEPTION_REGISTRATION结构的后面添加自定义的数据,比如,Microsoft SDK的except.inc中是这样定义的:
__EXCEPTIONREGISTRATIONRECORD struc
prev_structure dd ?
ExceptionHandler dd ?
ExceptionFilter dd ? ;附加数据
FilterFrame dd ? ;附加数据
PExceptionInfoPtrs dd ? ;附加数据
__EXCEPTIONREGISTRATIONRECORD ends
而在VC++中是这样定义的:
struct _EXCEPTION_REGISTRATION{
struct _EXCEPTION_REGISTRATION *prev;
void (*handler)(PEXCEPTION_RECORD,
PEXCEPTION_REGISTRATION,
PCONTEXT,
PEXCEPTION_RECORD);
struct scopetable_entry *scopetable; //附加数据
int trylevel; //附加数据
int _ebp; //附加数据
PEXCEPTION_POINTERS xpointers;
};
除了上面两个以不同方式定义的结构,笔者在很多汇编源代码中也见过更多的各不相同的EXCEPTION_REGISTRATION结构定义,正是因为这些结构的定义各不相同,Microsoft又没有提供一份标准的文档,这使很多初次接触SEH的人根本搞不清楚SEH究竟是如何定义的。读者现在应该明白这种现象的由来了,回过头去看一看这些结构,就可以发现它们的前面两个字段就是基本的EXCEPTION_REGISTRATION结构!
2. 回调函数的返回值
SEH异常处理回调函数的返回值定义不同于筛选器异常处理回调函数,它可以使用下面列出的4种取值:
● ExceptionContinueExecution(等于0):回调函数返回后,系统将线程环境设置为_lpContext参数指定的CONTEXT结构并继续执行。
● ExceptionContinueSearch(等于1):回调函数拒绝处理这个异常,系统将通过EXCEPTION_REGISTRATION结构的prev字段得到前一个回调函数的地址并调用它。
● ExceptionNestedException(等于2):回调函数在执行中又发生了新的异常,即发生了嵌套的异常。
● ExceptionCollidedUnwind(等于3):发生了嵌套的展开操作(展开操作的介绍参见14.3.4小节)。
14.3.3 SEH链和异常的传递
每次定义了一个新的SEH异常处理回调函数时,EXCEPTION_REGISTRATION结构的prev字段都被要求填写为原来的EXCEPTION_REGISTRATION结构地址,随着应用程序对执行模块的调用一层层深入下去,如果有多个模块设置了回调函数,那么到最后全部的回调函数会形成一个SEH链,如图14.2所示。
当程序中有多个线程在运行的时候,每个线程中都会存在各自的SEH链,这些SEH链中指定了多个回调函数,除它们以外,系统中可能还会存在一个全局性的筛选器异常处理回调函数,再者,如果进程被调试的话,调试器进程也相当于一个异常处理程序存在。既然会同时存在这么多的回调函数,而每个函数都可能对发生的异常提出不同的处理意见,那么当一个异常发生的时候,系统究竟该听谁的意见呢?
图14.2 SEH链
在这种情况下,系统按照一定的步骤选择一个回调函数并执行它,如果这个被执行的回调函数可以处理这个异常,那么程序被修正后继续执行并且其他的回调函数不会再被执行,否则系统继续执行下一个回调函数,查找的步骤如下:
(1)系统查看产生异常的进程是否正在被调试,如果正在被调试的话,那么向调试器发送EXCEPTION_DEBUG_EVENT事件。
(2)如果进程没有被调试或者调试器不去处理这个异常,那么系统检查异常所处的线程,并在这个线程的环境中查看fs:[0]来确定是否安装有SEH异常处理回调函数,如果有的话则调用它。
(3)回调函数尝试处理这个异常,如果可以正确处理的话,则修正错误并将返回值设置为ExceptionContinueExecution,这时系统将结束整个查找过程。
(4)如果回调函数返回ExceptionContinueSearch,告知系统它无法处理这个异常,那么系统将根据SEH链中的prev字段得到上一个回调函数地址并重复步骤(3),直到链中的某个回调函数返回ExceptionContinueExecution为止,查找结束。
(5)如果到了SEH链的尾部却没有一个回调函数愿意处理这个异常,那么系统将再次检测进程是否正在被调试,如果被调试的话,则再一次通知调试器。
(6)如果调试器还是不去处理这个异常或者进程没有被调试,那么系统检查有没有安装筛选器回调函数,如果有,则去调用它,筛选器回调函数返回时,系统默认的异常处理程序根据这个返回值做相应的动作。
(7)如果没有安装筛选器回调函数,系统直接调用默认的异常处理程序终止进程。
这个过程归纳起来就是:系统按照调试器、SEH链上从新到旧的各个回调函数、筛选器回调函数的步骤一个个去调用它们,一直到某个回调函数愿意处理异常为止。如果大家都无法处理异常的话,那么最后由系统默认的异常处理程序来终止发生异常的进程。
Windows拿着一份处理异常的活挨个问每个回调函数,“你干不干?”,“不干”,“你呢?”,“我也不干”…当问到某一个的时候,他说:“那我来干好了!”,那么Windows就不会再问余下的其他人了,于是相安无事。
有时,问完了一圈以后谁都不愿干活,Windows大怒:“谁都不干,看我炒了你们!”,于是就把整个进程终止掉了,所有的回调函数随之完蛋。
14.3.4 展开操作(Unwinding)
执行上面演示的SEH例子文件,程序会在显示了如图14.3中A所示的消息框后,再显示一个“转移到安全地址”的消息框后正常退出,这一切都在我们的意料之中。
现在来看看回调函数不处理异常时会怎样,将SEH.asm修改一下,去掉回调函数中修正eip寄存器的指令并将函数的返回值改为ExceptionContinueSearch,编译执行后再执行一下。首先看到的是图14.3 中A所示的消息框,单击“确定”按钮后,程序不会再显示“转移到安全地址”的消息框,而是出现系统的错误报告对话框,到此为止也在我们的意料之中,现在,单击“确定”按钮,奇怪的事情出现了,回调函数再一次被调用并显示了如图14.3中B所示的消息框!
进一步试验可以发现,如果程序在SEH链上挂了多个回调函数,并且每个回调函数都不处理异常的话,在系统默认的显示错误的对话框出现以后,每个回调函数都会被再调用一遍,这时参数中指定的异常代码是EXCEPTION_UNWIND,异常标志的取值是2,也就是EXCEPTION_UNWINDING标志。这种调用并不是要求回调函数去处理什么异常,而是告知回调函数:“你将要被卸掉了,自己处理一些后事吧”,在这时回调函数应该进行一些卸载前的扫尾工作并且返回ExceptionContinueSearch。
图14.3 展开操作时的异常代码和标志
14.3 使用SEH处理异常(4)
对回调函数的这种调用是由展开操作(Unwinding)引起的。当SEH链上的某个回调函数进行展开操作时,它所做的事情是从SEH链上的第一个回调函数开始(也就是fs:[0]指定的回调函数),以EXCEPTION_UNWIND代码和EXCEPTION_UNWINDING标志去调用每个回调函数,一直到调用到自身所处的位置为止,然后将自身之前的所有回调函数卸载,也就是将fs:[0]直接指向描述自身位置的那个EXCEPTION_REGISTRATION结构。当进行展开操作后,发起展开操作的那个回调函数将成为SEH链上的第一个回调函数。
1. 为什么要进行展开操作
展开操作在某些情况下是必要的。原因之一是让被卸载的回调函数有机会进行扫尾操作;原因之二是为了防止某些异常情况的发生,这个原因分析起来要复杂一些。
为了程序的模块化设计,一般在堆栈中构造EXCEPTION_REGISTRATION结构来注册SEH异常处理回调函数,这种方法已经成为各种语言注册SEH异常处理程序的首选,然而,与将结构定义成全局变量相比,这种方法又带来了一个新的问题。
图14.4 堆栈中的SEH链存在情况
现在来看看一个典型的应用,如图14.4所示,假设在主程序中调用_Proc1子程序来实现某种功能,这个子程序将涉及内存操作,所以设置了一个回调函数来处理内存访问异常,在_Proc1中又会调用_Proc2子程序来对所分配的内存中的数据进行一些运算,为了检测计算中的溢出错误,_Proc2设置了一个回调函数来处理溢出或除零异常,对其他的异常将不予处理并让它在SEH链中继续传递。
当程序执行到_Proc2中间的时候,堆栈中的数据如图14.4的右边所示,最下方是_Proc2注册异常处理回调函数使用的EXCEPTION_REGISTRATION结构,现在fs:[0]指向这个结构,上面一点是_Proc2使用的局部变量、返回地址等,再上面就是注册_Proc1中异常处理回调函数使用的EXCEPTION_REGISTRATION结构了。
当_Proc2中发生溢出异常时,_Proc2的回调函数将程序修正到Safe2执行,在这里堆栈被修正到如图14.4中的B所示的位置。到_Proc2返回的时候,回调函数被卸掉,堆栈中的后一个EXCEPTION_REGISTRATION结构被丢弃且fs:[0]被恢复指向_Proc1设置的结构中,一切都很正常。
但是在_Proc2中发生内存存取异常的时候,问题就出现了,这时系统根据fs:[0]的值首先找到并调用_Proc2设置的回调函数,但这个回调函数不处理这种异常,它会要求Windows继续搜索,接下来_Proc1设置的回调函数被调用,在这里堆栈被修正到图14.4的A所示的位置,这是执行到Safe1位置时正确的堆栈位置。
问题就在这里,这时候esp指向A,在A位置以下的堆栈空间都是自由的,包括A和B之间的堆栈空间,如果_Proc1接下来进行了一些入栈出栈的操作,原先由_Proc2设置的EXCEPTION_REGISTRATION结构就会被冲掉,不要忘了这时fs:[0]还指向这个失效的结构,如果这时再发生一个异常的话,Windows就会调用一个无效的回调函数地址。
这就是要进行展开操作的第二个原因,为了防止这个意外,_Proc2的异常处理回调函数在被执行的时候应该将fs:[0]中的值重新置为本身使用的EXCEPTION_REGISTRATION结构的地址,这样即使再次发生异常,也不会有前面这种危险的情况发生,这个操作相当于将后面所设的所有异常处理回调函数都卸掉了。
2. 完整的异常处理回调函数
综上所述,一个完整的异常处理回调函数应该包括异常处理、展开操作和响应展开调用等部分,其结构示意如下:
_Handler proc _lpExceptionRecord,_lpSEH,\
_lpContext,_lpDispatcherContext
.if (异常代码 == 0c0000027h) || \
(异常标志 & EXCEPTION_UNWINDING) || \
(异常标志 & EXCEPTION_UNWINDING_FOR_EXIT)
进行释放资源等扫尾工作 ;(1)
mov eax,ExceptionContinueSearch
.elseif 异常代码 == 可以处理的异常代码
处理异常,对CONTEXT进行修正 ;(2)
进行展开操作 ;(3)
mov eax,ExceptionContinueExecution
.else ;其他无法处理的异常代码
mov eax,ExceptionContinueSearch ;(4)
.endif
ret
_Handler endp
但是在实际的应用中,并不一定要存在上面所示的全部代码,如果某个异常处理回调函数对所有的异常代码都进行处理的话,那么就不会有(4)所示的代码,这样在它以后的回调函数就不可能再被调用,这样一来,这个回调函数也不可能被其他回调函数以展开操作的异常代码调用,结果是(1)所示的代码也就不需要了。
另外,当回调函数能够确定自己是SEH链上的最后一个回调函数的话,由于不存在展开操作的对象,也就不需要(3)所示的代码。
在本章的SEH例子中,程序设置的回调函数既是最后一个异常处理回调函数,又对所有的异常代码进行处理并将程序转移到“安全地址”去执行,所以仅仅需要(2)所示的代码。
3. 如何进行展开操作
自己书写展开操作的代码并不复杂,第一步是在一个循环中以EXCEPTION_UNWINDING标志调用从fs:[0]开始到当前回调函数为止的所有回调函数,第二步是将fs:[0]重新设置一下,指向注册当前回调函数使用的EXCEPTION_REGISTRATION结构就可以了。
但是,更方便的办法是使用Win32中未公开的函数RtlUnwind,这个函数可以完成上述的功能,函数的使用方法如下所示:
invoke RtlUnwind,lpLastStackFrame,lpCodeLabel,lpExceptionRecord,dwRet
使用参数lpLastStackFrame可以有两种方法。
第一,将它指定为当前回调函数使用的EXCEPTION_REGISTRATION结构地址的话,表示对当前回调函数之后的所有其他回调函数进行展开操作,RtlUnwind函数调用每个被展开的回调函数时,异常标志中会含有EXCEPTION_UNWINDING标志位。
第二,如果这个参数指定为NULL的话,表示对SEH链上所有的回调函数进行展开操作,这时所有回调函数参数中的异常标志在带有EXCEPTION_UNWINDING标志位的同时也带有EXCEPTION_UNWINDING_FOR_EXIT标志位,这种方式的展开称为退出展开(Exit Unwind)。
lpCodeLabel指定函数返回的位置。如果这个参数指定为NULL,函数使用正常的返回方式,也就是返回到调用RtlUnwind函数的后面一条指令,否则,函数直接返回到lpCodeLable指定的地址。
lpExceptionRecord指定一个EXCEPTION_RECORD结构。这个结构将在展开操作的时候被传给每一个被调用的回调函数,一般建议使用NULL来让系统自动生成代表展开操作的EXCEPTION_RECORD结构。dwRet参数一般不被使用,可以将它指定为NULL。
本书所附光盘的Chapter14\Unwind目录中包含了一个SEH展开操作的例子,读者可以自行分析一下,由于篇幅所限,在此就不详细列出了。
使用RtlUnwind函数时要注意的是:这个函数并不像其他API函数一样保存esi,edi和ebx寄存器,在函数返回的时候这些寄存器的值可能会被改变,所以,如果程序用到了这些寄存器的话,必须自己去保存和恢复它们。
最后需要说明的是,SEH异常处理属于Win32中未公开的特征,本章中的大部分内容无法从Microsoft的正式文档中查到,它们来自于各种零星的资料(包括笔者对一些例子的分析以及编程测试的结果),所以可能与其他资料有所出入。如果读者发现存在错误或者有什么疑问,请告知笔者。
⌨️ 快捷键说明
复制代码
Ctrl + C
搜索代码
Ctrl + F
全屏模式
F11
切换主题
Ctrl + Shift + D
显示快捷键
?
增大字号
Ctrl + =
减小字号
Ctrl + -