这篇文章写于14年10月,彼时正对Windows的SEH机制感兴趣,且国庆假期时间比较充裕,便记了几篇相关的笔记。后来逐渐忙于课业便没继续分析下去,3年来多次想完成这个系列,但每次刚一开头总会被各种琐事打断,一直未能如愿。恰逢近来重读《Windows核心编程》,决心分析清楚Visual C++异常处理机制的工作原理,并在此基础上对比Delphi的异常处理机制。由于此二者的异常处理机制都是建立在Windows用户级结构化异常处理机制的基础上,因此将笔记整理后贴出,希望这一次能如愿以偿地完成分析吧。
Windows系统的用户级结构化异常处理机制(SEH)是基于线程的,因此可为不同的线程指派不同的异常处理函数。且由于其形成了链结构,还可据此为一个线程指定多个异常处理函数,异常发生后,操作系统会沿此链表调用各异常处理函数,直到某个异常处理函数修复了该异常或已达末尾。通常处理完异常后,需要执行展开(Unwind)操作,该操作先通知目标结点前的各异常处理函数释放资源,然后将之前的SEH链全部删除。该操作通常由各高级语言Rtl模块来完成,Win32汇编操作时既可以不展开,也可以手工展开,还可以使用RtlUnwind函数展开。
SEH的基础知识
在Win32环境下,FS寄存器总指向线程环境块_TEB,_TEB的首项中存放的是线程信息块NT_TIB,其首项中即为SHE链的第一个节点地址。下面用Windbg查看以上两个结构。
_TEB结构:
0:000> dt _TEB
ntdll!_TEB
+0x000 NtTib : _NT_TIB //指向_NT_TIB结构
+0x01c EnvironmentPointer : Ptr32 Void
+0x020 ClientId : _CLIENT_ID
+0x028 ActiveRpcHandle : Ptr32 Void
+0x02c ThreadLocalStoragePointer : Ptr32 Void
+0x030 ProcessEnvironmentBlock : Ptr32 _PEB
+0x034 LastErrorValue : Uint4B
.....
_NT_TIB结构:
0:000> dt _NT_TIB
ConsoleApplication1!_NT_TIB
+0x000 ExceptionList : Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x004 StackBase : Ptr32 Void
+0x008 StackLimit : Ptr32 Void
+0x00c SubSystemTib : Ptr32 Void
+0x010 FiberData : Ptr32 Void
+0x010 Version : Uint4B
+0x014 ArbitraryUserPointer : Ptr32 Void
+0x018 Self : Ptr32 _NT_TIB
故可用fs:[0]来索引SEH链的第一个节点。注意,fs:[4]和fs:[8]分别是线程的栈基址和栈顶限制地址,只有当StackLimit<地址<StackBase时,该地址才位于栈上。这在后面的SafeSEH机制中将被用作合法性检验。
SEH的每个节点都是一个_EXCEPTION_REGISTRATION_RECORD结构,标准结构定义如下:
0:000> dt _EXCEPTION_REGISTRATION_RECORD
ConsoleApplication1!_EXCEPTION_REGISTRATION_RECORD
+0x000 Next : Ptr32 _EXCEPTION_REGISTRATION_RECORD //指向下一个_EXCEPTION_REGISTRATION_RECORD结构
+0x004 Handler : Ptr32 _EXCEPTION_DISPOSITION //指向异常处理函数,其返回值为_EXCEPTION_DISPOSITION枚举结构
_EXCEPTION_DISPOSITION枚举结构定义如下:
0:000> dt _EXCEPTION_DISPOSITION
ntdll!_EXCEPTION_DISPOSITION
ExceptionContinueExecution = 0n0 //已处理异常,程序可继续执行
ExceptionContinueSearch = 0n1 //函数不处理该异常,继续搜寻异常处理函数链
ExceptionNestedException = 0n2 //在处理异常的过程中产生了新的异常
ExceptionCollidedUnwind = 0n3 //嵌套展开
异常处理函数定义如下:
EXCEPTION_DISPOSITION __cdecl except_handler (
struct _EXCEPTION_RECORD *_ExceptionRecord, //指向_EXCEPTION_RECORD
void * _EstablisherFrame, //指向_EXCEPTION_REGISTRATION_RECORD
struct _CONTEXT *_ContextRecord, //指向_CONTEXT
void * _DispatcherContext);
- 其第一个参数指向一个_EXCEPTION_RECORD结构,该结构将传入异常信息:
0:000> dt _EXCEPTION_RECORD
ConsoleApplication1!_EXCEPTION_RECORD
+0x000 ExceptionCode : Uint4B //异常类型
+0x004 ExceptionFlags : Uint4B //异常标志
+0x008 ExceptionRecord : Ptr32 _EXCEPTION_RECORD //辅助结构
+0x00c ExceptionAddress : Ptr32 Void //异常发生的地址
+0x010 NumberParameters : Uint4B //下面传了几个参数
+0x014 ExceptionInformation : [15] Uint4B //参数具体意义——后两个参数主要适用于RaiseException
/*ExceptionCode 异常类型,最可能遇到的几种类型如下:
C0000005h----读写内存冲突
C0000094h----非法除0
C00000FDh----堆栈溢出或者说越界
80000001h----由Virtual Alloc建立起来的属性页冲突
C0000025h----不可持续异常,程序无法恢复执行,异常处理例程不应处理这个异常
C0000026h----在异常处理过程中系统使用的代码,如果系统从某个例程莫名奇妙的返回,则出现此代码,例如调用RtlUnwind时没有Exception Record参数时产生的异常填入的就是这个代码
80000003h----调试时因代码中int3中断
80000004h----处于被单步调试状态
位: 31~30 29~28 27~16 15~0
_____________________________________________________________________+
含义: 严重程度 29位 功能代码 异常代码
0==成功 0==Mcrosoft MICROSOFT定义 用户定义
1==通知 1==客户
2==警告 28位
3==错误 被保留必须为0
ExceptionFlags 异常标志
0 可修复异常
#define EXCEPTION_NONCONTINUABLE 0x1 // Noncontinuable exception
#define EXCEPTION_UNWINDING 0x2 // Unwind is in progress
#define EXCEPTION_EXIT_UNWIND 0x4 // Exit unwind is in progress
#define EXCEPTION_STACK_INVALID 0x8 // Stack out of limits or unaligned
#define EXCEPTION_NESTED_CALL 0x10 // Nested exception handler call
#define EXCEPTION_TARGET_UNWIND 0x20 // Target unwind in progress
#define EXCEPTION_COLLIDED_UNWIND 0x40 // Collided exception handler call
#define EXCEPTION_UNWIND (EXCEPTION_UNWINDING | EXCEPTION_EXIT_UNWIND | \
EXCEPTION_TARGET_UNWIND | EXCEPTION_COLLIDED_UNWIND) */
第二个参数指向我们自己定义的_EXCEPTION_REGISTRATION_RECORD结构,除了上述介绍的标准结构的之外,通常我们还会附加一些数据,不同的环境如VC和SDK以及DDK中,都有不同的附加数据。但前两个元素类型一定是固定的。
第三个参数指向一个CONTEXT结构,该结构储存有异常发生时各寄存器的值,可以修改其中的某些寄存器诸如EIP的值,来控制异常成功被处理后程序的执行流程,或清空DR0-4以躲避调试器。
最后一个参数主要用于Unwinding操作。
当异常发生后,Windows将沿着SEH链按次序访问各_EXCEPTION_REGISTRATION_RECORD结构,取得异常处理函数地址后调用它,直到到达链尾或某个异常处理函数报告它已解决了该异常。
SEH的使用和Unwinding的必要性
我们通常在栈中构建SEH的_EXCEPTION_REGISTRATION_RECORD结构,示例代码如下:
push 一堆附加数据
push offset _Handler
push fs:[0]
mov fs:[0],esp
首先在栈中构造了一个_EXCEPTION_REGISTRATION_RECORD,然后将其地址(esp指向栈顶)传给fs:[0],这样,便向SEH链首插入了一个节点。
在该函数中,我们可以对_EXCEPTION_RECORD的ExceptionCode和ExceptionFlags进行判断,如果为预期的异常类型和异常标志,则进行处理然后返回ExceptionContinueExecution;若不为预期的异常则直接返回ExceptionContinueSearch。
下面我们来谈一谈SEH链的展开操作,首先,为什么要展开呢?
我们假设在程序中,主函数调用了func1(),func1又调用了func2();func1()注册了名为SEH1的异常处理函数,该函数会将执行流程修正到func1()的某处;func2()注册了名为SEH2的异常处理函数,该函数会将执行流程修正到func2()的某处。我们假设在执行到func2()函数的某条语句时发生了异常,且只有SEH1能处理该异常。
一方面,假设func2()函数中在异常发生前分配了资源,由于SEH1在处理该异常时直接将地址修正到func1的领空,将造成资源泄露;另外一种情况,若在面向对象编程中,则可能无法执行某个类的析构函数。
另一方面,我们来看看当时的堆栈情况:
可以看出,如果SEH1将esp和ebp修正到func1栈桢上后,func2的栈桢将变为未使用状态,若后续再调用其他函数或使用局部变量,可能会覆盖func2注册的_EXCEPTION_REGISTRATION_RECORD结构。当下次再出现异常,系统仍会在改地址取得一个不存在的异常处理函数地址并执行,这是很危险的!
因此,在通常情况下,应调用SEH链上之前的所有异常处理函数,通知其进行清理收尾工作;另一方面,将本节点地址传给fs:[0]。我们把这个过程称为展开(Unwind)操作。
Unwind操作的步骤
Unwind操作分为两种,一种是局部展开,另一种是全局展开(也称退出展开)。
局部展开见诸于某个异常处理函数成功解决了异常后从FS:[0]指向的_EXCEPTION_REGISTRATION_RECORD开始,依次以
EXCEPTION_RECORD.ExceptionCode = 0xC0000027
EXCEPTION_RECORD.ExceptionFlags = 0x2(EXCEPTION_UNWINDING)
调用SEH链上位置在它前面的那些函数。这个操作需要由异常处理函数自己完成。
全局展开则由系统自动进行,发生于SEH链上的所有函数都拒绝处理异常的情况下。此时,系统按SEH链次序以
EXCEPTION_RECORD.ExceptionCode = 0xC0000027
EXCEPTION_RECORD.ExceptionFlags = 0x6(EXCEPTION_UNWINDING | EXCEPTION_EXIT_UNWIND)
调用各异常处理函数。Unwind操作中,被调用的函数在完成了清理动作后应返回ExceptionContinueSearch。
如果确定异常处理函数要响应Unwind操作,则应在函数内增加相应的代码。下图为罗云彬前辈给出的一个完整的异常处理函数框架:
好在Windows已经为我们提供了实现展开的API函数RtlUnwind,其原型如下:
void WINAPI RtlUnwind(
_In_opt_ PVOID TargetFrame, //目标SEH节点地址,输入则为局部展开;NULL为退出展开
_In_opt_ PVOID TargetIp, //局部展开后EIP值
_In_opt_ PEXCEPTION_RECORD ExceptionRecord, //附加的ExceptionRecord记录,外部调用一般不需要
_In_ PVOID ReturnValue //返回值,留空即可
);
在下一篇中,我们将分析Windows提供的RtlUnwind函数的实现机制,进而引出SafeSEH机制。