Windows XP 异常处理(四)未处理异常
1 未处理异常
这里的未处理异常是进入二次分发的用户异常,并不是顶层异常处理。
实际上,系统在创建进程时,先由 ntdll.dll
做了一系列的准备工作,然后才从系统模块提供的启动函数开始运行。例如,在 Windows XP 系统中,进程的实际启动位置是 kernel32!BaseProcessStartThunk
,然后才跳转到 kernel32!BaseProcessStart
。
同样,在使用 CreateThread
函数创建线程的时候,线程也不是直接从线程函数处开始运行的,它的起点是 kernel32!BaseThreadStartThunk
,而后跳转到 kernel32!Base ThreadStart
,并由该函数执行ThreadProc
。 BaseThreadStart
函数也包括异常处理代码,与 BaseProcessStart
的代码几乎一样。
操作系统在执行任意一个用户线程(不管是不是主线程)之前,都已经为它安装了一个默认的 SEH 处理程序,这是该线程的第 1 个 SEH 处理程序。根据SEH链表的结构和操作规定,不管用户线程开始执行之后有没有再安装其他 SEH,系统默认的这个 SEH 处理程序一定是最后一个。如果用户线程没有安装异常处理程序,或者安装的所有异常处理程序都没有处理该异常,异常就会交由系统安装的这个默认 SEH 处理程序进行终结处理,即由系统来收拾异常发生后的“烂摊子”(也叫做最后一道防线)。这个由系统安装的默认异常处理程序就是本节要介绍的顶层异常处理程序。显然,它也是一个标准的 SEH 处理程序,只不过是由系统安装的而已。
注意:所有线程顶层异常处理的Hander函数使用同一个。
当一个进程中无论是哪一个线程产生异常,如果在此之前没有任何一个 SEH 来解决相关的异常,那在用户空间中,这个最后一道防线的 SEH 一定会来解决这个异常,除非当前有调试器,则会进行第二次异常分发进入 0 环,如果此时并没有调试器,那用户空间的异常一定不会进行二次异常分发再进入 0 环,秘密就在异常过滤器的 UnhandledExceptionFilter
函数,后面将会介绍。
1.1 最后一道防线举例
在 VC6 中如下只写有空白 main
函数的代码:
1 |
|
拖入OD中,程序停在模块入口处(还未进入
mainCRTStartup()
启动函数),在命令框使用dd fs:[0]
查看一下当前的FS:[0]
链条,此时链条上已经挂入了第一个EXCEPTION_REGISTR_RECORD
,对应的异常处理函数地址在0x7C839AC0
:如上图进入
mainCRTStartup()
启动函数以后的第一个动作就是向FS:[0]
链条插入一个EXCEPTION_REGISTR_RECORD
,异常处理回调函数为__except_handler3
。找到
main
函数单击选中然后F4
运行到这里(还未进入到main
),此时查看FS:[0]
链条,FS:[0]
链条上已经挂入了第二个EXCEPTION_REGISTR_RECORD
:进入
main
以后,链条上并没有插入新的 SEH。至此,在
kernel32.dll
及mainCRTStartup()
启动函数中分别向FS:[0]
链条挂入了EXCEPTION_REGISTR_RECORD
。kernel32
中挂入的也叫做程序最后一道防线、SEHOP,其为kernel32.dll
的BaseProcessStart函数(BaseProcessStart用于进程中的第一个线程,BaseThreadStart用于随后的线程。)。
试验一下是否是所有线程最后一道防线的异常处理函数都是同一个。
例2,如下代码在 VC6下进行编译生成:
1 |
|
FS:[0]
链条是跟线程相关的,因为每个线程的KPCR
不同。三个线程 FS:[0]
链条:
main
函数的主线程:第一个 SEH 是在mainCRTStartup
中一开始挂入的(在OD中单步分析的)。- 可以看到三个线程有一个相同的 SEH,通过分析这里的地址
0x7c839ac0
为kernel32!BaseProcessStart
函数(BaseProcessStart用于进程中的第一个线程,BaseThreadStart用于随后的线程)。它也是每个线程的最后一道防线。但从上图来看,每个Next = -1
时其EXCEPTION_REGISTR_RECORD
结构的地址不相同,是因为BaseProcessStart用于进程中的第一个线程,BaseThreadStart用于随后的线程。虽然每个结构地址不同,但是异常处理函数的地址相同。
所以每一个线程开始时候都会在 FS:[0]
链条上挂入SEH,在每个进程一开始也会挂入 SEH,所以最后一道防线是 BaseProcessStart。
参考:关于Windows创建进程的过程。
总结:在 XP 系统中,每个线程最后一道防线如下(跟编译器无关):
Windows XP | 最后一道防线、顶层处理插入位置 | 次顶层处理插入位置 | 异常处理函数 |
---|---|---|---|
主线程: | BaseProcessStart | mainCRTStartup | __SEH_prolog/__except_handler3 |
其余线程 | BaseProcessStart | - | __SEH_prolog/__except_handler3 |
1.2 BaseProcessStart分析
这部分完全可以参考《加密与解密第四版8.3.4》。
函数 BaseProcessStart
是程序创建以后的第一个线程在 3 环执行的起始地址,其余第二个、第三个…线程被创建以后在 3 环之行的起始地址为 BaseThreadStart
。
函数原型如下:
1 | VOID __stdcall BaseProcessStart( |
逆向分析如下:
1 | .text:7C817044 // =============== S U B R O U T I N E ======================================= |
该函数调用 __SEH_prolog
在 FS:[0]
链条挂入最后一道防线,然后调用 NtSetInformationThread
给进程的主线程做一些设置,随后调用线程的起始地址 call lpStartAddress
开始运行第一个线程。
当一个进程中无论是哪一个线程产生异常,如果在此之前没有任何一个 SEH 来解决相关的异常,那在用户空间中,这个最后一道防线的 SEH 一定会来解决这个异常,除非当前有调试器,则会进行第二次异常分发进入 0 环,如果此时并没有调试器,那用户空间的异常一定不会进行二次异常分发再进入 0 环,秘密就在异常过滤器的 UnhandledExceptionFilter
函数,后面将会介绍。
查看 scopetable
异常处理过滤器及异常处理块:
1 | //处理过滤器 |
1.3 UnhandledExceptionFilter
函数 _UnhandledExceptionFilter
的功能至关重要,具体实现可以参考《加密与解密第四版8.3.4 P352》,概述如下:
UnhandledExceptionFilter
的执行流程:
- 通过
NtQueryInformationProcess
查询当前进程是否正在被调试,如果是,返回EXCEPTION_CONTINUE_SEARCH
,此时会进入第二轮分发。如果当前进程没有被调试,那过滤表达式**一定不会返回EXCEPTION_CONTINUE_SEARCH
**,这个时候才会出现未处理异常。 - 如果没有被调试:
查询是否通过SetUnhandledExceptionFilter
注册顶层异常处理函数,如果有就调用。
如果没有通过SetUnhandledExceptionFilter
注册处理函数,弹出窗口,让用户选择终止程序还是启动即时调试器。
如果用户没有启用即时调试器,那么该函数返回EXCEPTION_EXECUTE_HANDER
去执行异常处理块(终止进程)。
只有程序被调试时,才会存在未处理异常(进入二次分发)。
简单了解完 UnhandledExceptionFilter
函数后,可以对用户异常的处理有进一步的认识,通常情况下,用户异常不会进入第二轮分发,在第一轮分发时,若线程堆栈中的SEH未对异常进行处理,那么系统帮忙注册的最后一道防线会对异常进行处理(即终止进程/线程);只有存在调试器的情况下,才会进入第二轮分发。
1.4 SetUnhandledExceptionFilter
微软提供了一个 API 函数 SetUnhandledExceptionFiter
来让用户设置一个顶层异常过滤回调函数,在条件满足时会在 kernel32!UnhandledExceptionFilter
中会调用它并根据它的返回值进行相应的操作,平时所说的顶层异常回调函数指的就是这个回调函数,而不是UnhandledExceptionFilter
函数。该 API 原型及参数类型定义如下。
1 | long __stdcall callback(_EXCEPTION_POINTERS* excp) |
API 函数 kernel32!SetUnhandledExceptionFilter
实际上把用户设置的回调函数地址加密并保存在一个全局变量 kernel32!BasepCurrentTopLevelFilter
中。
举一个反调试的例子:
1 |
|
构造一个除0异常,然后将异常修复的代码通过SetUnhandledExceptionFilter注册为顶层的异常处理函数。这样,如果程序被调试,那么顶层的异常处理函数就得不到执行,程序就会报错退出(二次异常时退出的),这样就达到了反调试的目的。这里注意一点,不要在VC++6.0编译器内运行程序,这样会报除零异常,进入该项目的文件夹,双击.exe文件,即程序能够正常执行;若拖入调试器中,则无法正常执行。
1.5 Vista之后的顶层处理变化
从 Windows Vista 开始,线程的实际入口点变成了 ntdll!RtlUserThreadStart
(不再位于 kernel32.dll
中)。该函数直接跳转到了 ntdll!_RtlUserThreadStart
,其内部调用了 RtlInitializeExceptionChain
函数,该函数与 SEHOP 保护机制有关,然后再调用 __RtlUserThreadStart
。(注意 _RtlUserThreadStart
不同于 __RtlUserThreadStart
)
1 | VOID _RtlUserThreadstart( |
如6.1例2的代码在Win10+VS2019/VC6编译生成的截图如下:
总结:在 Win10 系统中,每个线程最后一道防线如下(跟编译器无关):
Windows 10 | SEHOP函数(ntdll!FinalExceptionHandler)插入位置 | 最后一道防线、顶层处理插入位置 | 次顶层处理插入位置 | 异常处理函数 |
---|---|---|---|---|
主线程: | ntdll!_RtlUserThreadstart.RtlInitializeExceptionChain | ntdll!__RtluserThreadstart | mainCRTStartup | __SEH_prolog/__except_hander4 |
其余线程 | ntdll!_RtlUserThreadstart.RtlInitializeExceptionChain | ntdll!__RtluserThreadstart | - | __SEH_prolog/__except_hander4 |
前两个SEH的Hander所有线程都使用,但是第二个SEH才是顶层异常处理函数的SEH。第一个SEH->Next == 0xffffffff
时,对应的异常处理函数为 ntdll!FinalExceptionHandler
,这是SEHOP加入后的才有的,具体见下面SEHOP介绍。
具体可以参考《揭秘与解密第四版 8.3.5 P354》。
2 安全Cookie
《软件调试第二版卷2》
3 SafeSEH
SafeSEH依赖于编译器可以开启SafeSEH的功能,同时必须是XP SP2以后的操作系统才支持,必须两者同时支持。
参考《加密与解密第四版8.3.6 P357》、《0day安全11.1 P284》。
4 SEHOP
Vista以后的操作系统才支持,跟编译器无关。
SEHOP为操作系统在 ntdll!RtlInitializeExceptionChain
注册的,此时的顶层处理的 SEH 为 ntdll.__RtluserThreadstart
。
具体可以参考《加密与解密第四版 8.3.6 P359》。
SEHOP,Structured Exception Handling Overwrite Protection(SEH 覆写保护机制)。