Windows XP 异常处理(四)未处理异常

ʕ •̀ o •́ ʔ

1 未处理异常

这里的未处理异常是进入二次分发的用户异常,并不是顶层异常处理

实际上,系统在创建进程时,先由 ntdll.dll 做了一系列的准备工作,然后才从系统模块提供的启动函数开始运行。例如,在 Windows XP 系统中,进程的实际启动位置是 kernel32!BaseProcessStartThunk,然后才跳转到 kernel32!BaseProcessStart

同样,在使用 CreateThread 函数创建线程的时候,线程也不是直接从线程函数处开始运行的,它的起点是 kernel32!BaseThreadStartThunk,而后跳转到 kernel32!Base ThreadStart,并由该函数执行ThreadProcBaseThreadStart 函数也包括异常处理代码,与 BaseProcessStart 的代码几乎一样。

操作系统在执行任意一个用户线程(不管是不是主线程)之前,都已经为它安装了一个默认的 SEH 处理程序,这是该线程的第 1 个 SEH 处理程序。根据SEH链表的结构和操作规定,不管用户线程开始执行之后有没有再安装其他 SEH,系统默认的这个 SEH 处理程序一定是最后一个。如果用户线程没有安装异常处理程序,或者安装的所有异常处理程序都没有处理该异常,异常就会交由系统安装的这个默认 SEH 处理程序进行终结处理,即由系统来收拾异常发生后的“烂摊子”(也叫做最后一道防线)。这个由系统安装的默认异常处理程序就是本节要介绍的顶层异常处理程序。显然,它也是一个标准的 SEH 处理程序,只不过是由系统安装的而已。

注意所有线程顶层异常处理的Hander函数使用同一个。

当一个进程中无论是哪一个线程产生异常,如果在此之前没有任何一个 SEH 来解决相关的异常,那在用户空间中,这个最后一道防线的 SEH 一定会来解决这个异常,除非当前有调试器,则会进行第二次异常分发进入 0 环,如果此时并没有调试器,那用户空间的异常一定不会进行二次异常分发再进入 0 环,秘密就在异常过滤器的 UnhandledExceptionFilter 函数,后面将会介绍。

1.1 最后一道防线举例

在 VC6 中如下只写有空白 main 函数的代码:

1
2
3
4
5
6
7
8
#include "stdafx.h"

int main(int argc, char* argv[])
{

getchar();
return 0;
}
  1. 拖入OD中,程序停在模块入口处(还未进入 mainCRTStartup() 启动函数),在命令框使用 dd fs:[0] 查看一下当前的 FS:[0] 链条,此时链条上已经挂入了第一个 EXCEPTION_REGISTR_RECORD,对应的异常处理函数地址在 0x7C839AC0

    28.png

  2. 如上图进入 mainCRTStartup() 启动函数以后的第一个动作就是向 FS:[0] 链条插入一个 EXCEPTION_REGISTR_RECORD,异常处理回调函数为 __except_handler3

  3. 找到 main 函数单击选中然后 F4 运行到这里(还未进入到 main),此时查看 FS:[0] 链条,FS:[0] 链条上已经挂入了第二个 EXCEPTION_REGISTR_RECORD

    29.png

    进入 main 以后,链条上并没有插入新的 SEH。

    至此,在 kernel32.dllmainCRTStartup() 启动函数中分别向 FS:[0] 链条挂入了 EXCEPTION_REGISTR_RECORDkernel32 中挂入的也叫做程序最后一道防线SEHOP,其为 kernel32.dllBaseProcessStart函数(BaseProcessStart用于进程中的第一个线程,BaseThreadStart用于随后的线程。)。

试验一下是否是所有线程最后一道防线的异常处理函数都是同一个。

例2,如下代码在 VC6下进行编译生成:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
#include "stdafx.h"
#include <windows.h>

typedef struct _EXCEPTION_REGISTR_RECORD
{
struct _EXCEPTION_REGISTR_RECORD* Next;
void* Handler;
}EXCEPTION_REGISTR_RECORD,*PEXCEPTION_REGISTR_RECORD;

void ShowException(char* pFunctionName)
{
EXCEPTION_REGISTR_RECORD *pExc_Reg_Record = NULL;
DWORD Temp = 0;
__asm
{
mov eax, fs:[0];
mov Temp, eax;
}
pExc_Reg_Record = (EXCEPTION_REGISTR_RECORD*)Temp;

printf("---------------------------------------------\n");
printf("%s:\n",pFunctionName);
int i = 0;
do
{
i++;
printf("Function%d,RcordAddr = %08X, Next = %08X, Hander = %08X\n",i, pExc_Reg_Record,
pExc_Reg_Record->Next, pExc_Reg_Record->Handler);
pExc_Reg_Record = pExc_Reg_Record->Next;
}while(pExc_Reg_Record != (EXCEPTION_REGISTR_RECORD*)-1);
printf("---------------------------------------------\n");
}

DWORD __stdcall ThreadProc1(PVOID p)
{
ShowException("ThreadProc1");
return 0;
}

DWORD __stdcall ThreadProc(PVOID p)
{
ShowException("ThreadProc");
CreateThread(NULL, 0, ThreadProc1, NULL, 0, NULL);
return 0;
}

int main(int argc, char* argv[])
{
ShowException("main");

CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

getchar();
return 0;
}

30.png

FS:[0] 链条是跟线程相关的,因为每个线程的KPCR 不同。三个线程 FS:[0] 链条:

  • main 函数的主线程:第一个 SEH 是在 mainCRTStartup 中一开始挂入的(在OD中单步分析的)。
  • 可以看到三个线程有一个相同的 SEH,通过分析这里的地址 0x7c839ac0kernel32!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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
VOID __stdcall BaseProcessStart(
PPROCESS_START_ROUTINE lpStartAddress
)
/*++
Routine Description:
This function is called to start a Win32 process. Its purpose is to
call the initial thread of the process, and if the thread returns,
to terminate the thread and delete its stack.
Arguments:
lpStartAddress - Supplies the starting address of the new thread. The
address is logically a procedure that never returns.
Return Value: None.
--*/
{
try {
DWORD ExitCode;
NtSetInformationThread( NtCurrentThread(),
ThreadQuerySetWin32StartAddress,
&lpStartAddress,
sizeof( lpStartAddress )
);
ExitCode = lpStartAddress();
ExitThread(ExitCode);
}
except(UnhandledExceptionFilter( GetExceptionInformation() )) {
if ( !BaseRunningInServerProcess ) {
ExitProcess(GetExceptionCode());
}
else {
ExitThread(GetExceptionCode());
}
}
}

逆向分析如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
.text:7C817044 // =============== S U B R O U T I N E =======================================
.text:7C817044
.text:7C817044 // Attributes: noreturn bp-based frame
.text:7C817044
.text:7C817044 // __stdcall BaseProcessStart(x)
.text:7C817044 _BaseProcessStart@4 proc near // CODE XREF: BaseProcessStartThunk(x,x)+5↑j
.text:7C817044
.text:7C817044 uExitCode = dword ptr -1Ch
.text:7C817044 ms_exc = CPPEH_RECORD ptr -18h
.text:7C817044 lpStartAddress = dword ptr 8
.text:7C817044
.text:7C817044 // FUNCTION CHUNK AT .text:7C843882 SIZE 00000011 BYTES
.text:7C817044 // FUNCTION CHUNK AT .text:7C843898 SIZE 00000018 BYTES
.text:7C817044
.text:7C817044 // __unwind { // __SEH_prolog
.text:7C817044 push 0Ch
.text:7C817046 push offset stru_7C817070 // scopetable
.text:7C81704B call __SEH_prolog
.text:7C817050 // __try { // __except at loc_7C843898
.text:7C817050 and [ebp+ms_exc.registration.TryLevel], 0
.text:7C817054 push 4 // ThreadInformationLength
.text:7C817056 lea eax, [ebp+lpStartAddress]
.text:7C817059 push eax // &lpStartAddress
.text:7C81705A push 9 // ThreadInformationClass
.text:7C81705C push 0FFFFFFFEh // ThreadHandle
.text:7C81705E call ds:__imp__NtSetInformationThread@16 // NtSetInformationThread(x,x,x,x)
.text:7C817064 call [ebp+lpStartAddress]
.text:7C817067 push eax // dwExitCode
.text:7C817068
.text:7C817068 loc_7C817068: // CODE XREF: BaseProcessStart(x)+2C861↓j
.text:7C817068 call _ExitThread@4 // ExitThread(x)
.text:7C817068 // } // starts at 7C817050
.text:7C817068 // } // starts at 7C817044
.text:7C817068 _BaseProcessStart@4 endp
.text:7C817068
.text:7C817068 // ---------------------------------------------------------------------------
.text:7C81706D align 10h
.text:7C817070 stru_7C817070 _SCOPETABLE_ENTRY <0FFFFFFFFh, offset loc_7C843882, \
.text:7C817070 // DATA XREF: BaseProcessStart(x)+2↑o
.text:7C817070 offset loc_7C843898> // SEH scope table for function 7C817044
.text:7C81707C db 5 dup(90h)

该函数调用 __SEH_prologFS:[0] 链条挂入最后一道防线,然后调用 NtSetInformationThread 给进程的主线程做一些设置,随后调用线程的起始地址 call lpStartAddress 开始运行第一个线程。

当一个进程中无论是哪一个线程产生异常,如果在此之前没有任何一个 SEH 来解决相关的异常,那在用户空间中,这个最后一道防线的 SEH 一定会来解决这个异常,除非当前有调试器,则会进行第二次异常分发进入 0 环,如果此时并没有调试器,那用户空间的异常一定不会进行二次异常分发再进入 0 环,秘密就在异常过滤器的 UnhandledExceptionFilter 函数,后面将会介绍。

查看 scopetable异常处理过滤器及异常处理块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
//处理过滤器
.text:7C843882 loc_7C843882: // DATA XREF: .text:stru_7C817070↑o
.text:7C843882 // __unwind { // __SEH_prolog
.text:7C843882 // __except filter // owned by 7C817050
.text:7C843882 mov eax, [ebp+ms_exc.exc_ptr]
.text:7C843885 mov ecx, [eax] // ecx = EXCEPTION_POINTERS->ExceptionRecord
.text:7C843887 mov ecx, [ecx] // ecx = EXCEPTION_POINTERS->ExceptionRecord->ExceptionCode
.text:7C843889 mov [ebp+uExitCode], ecx
.text:7C84388C push eax // ExceptionInfo
.text:7C84388D call _UnhandledExceptionFilter@4 // UnhandledExceptionFilter(x)
.text:7C843892 retn
.text:7C843892 // } // starts at 7C843882
.text:7C843892 // END OF FUNCTION CHUNK FOR _BaseProcessStart@4
.text:7C843892 // ---------------------------------------------------------------------------
.text:7C843893 align 8
.text:7C843898 // START OF FUNCTION CHUNK FOR _BaseProcessStart@4
.text:7C843898

//异常处理块
.text:7C843898 loc_7C843898: // DATA XREF: .text:stru_7C817070↑o
.text:7C843898 // __unwind { // __SEH_prolog
.text:7C843898 // __except(loc_7C843882) // owned by 7C817050
.text:7C843898 mov esp, [ebp+ms_exc.old_esp]
.text:7C84389B push [ebp+uExitCode] // uExitCode
.text:7C84389E cmp _BaseRunningInServerProcess, 0
.text:7C8438A5 jnz loc_7C817068
.text:7C8438AB call _ExitProcess@4 // ExitProcess(x)
...
.text:7C817068 loc_7C817068: // CODE XREF: BaseProcessStart(x)+2C861↓j
.text:7C817068 call _ExitThread@4 // ExitThread(x)

34.png

1.3 UnhandledExceptionFilter

函数 _UnhandledExceptionFilter 的功能至关重要,具体实现可以参考《加密与解密第四版8.3.4 P352》,概述如下:

UnhandledExceptionFilter 的执行流程:

  1. 通过 NtQueryInformationProcess 查询当前进程是否正在被调试,如果是,返回 EXCEPTION_CONTINUE_SEARCH,此时会进入第二轮分发。如果当前进程没有被调试,那过滤表达式**一定不会返回 EXCEPTION_CONTINUE_SEARCH**,这个时候才会出现未处理异常。
  2. 如果没有被调试:
    查询是否通过 SetUnhandledExceptionFilter 注册顶层异常处理函数,如果有就调用。
    如果没有通过 SetUnhandledExceptionFilter 注册处理函数,弹出窗口,让用户选择终止程序还是启动即时调试器。
    如果用户没有启用即时调试器,那么该函数返回 EXCEPTION_EXECUTE_HANDER 去执行异常处理块(终止进程)。

只有程序被调试时,才会存在未处理异常(进入二次分发)。

简单了解完 UnhandledExceptionFilter 函数后,可以对用户异常的处理有进一步的认识,通常情况下,用户异常不会进入第二轮分发,在第一轮分发时,若线程堆栈中的SEH未对异常进行处理,那么系统帮忙注册的最后一道防线会对异常进行处理(即终止进程/线程);只有存在调试器的情况下,才会进入第二轮分发。

1.4 SetUnhandledExceptionFilter

微软提供了一个 API 函数 SetUnhandledExceptionFiter 来让用户设置一个顶层异常过滤回调函数,在条件满足时会在 kernel32!UnhandledExceptionFilter 中会调用它并根据它的返回值进行相应的操作,平时所说的顶层异常回调函数指的就是这个回调函数,而不是UnhandledExceptionFilter 函数。该 API 原型及参数类型定义如下。

1
2
3
long __stdcall callback(_EXCEPTION_POINTERS* excp)
{
}

API 函数 kernel32!SetUnhandledExceptionFilter 实际上把用户设置的回调函数地址加密并保存在一个全局变量 kernel32!BasepCurrentTopLevelFilter 中。

举一个反调试的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#include "stdafx.h"

long _stdcall callback(_EXCEPTION_POINTERS* excp)
{
excp->ContextRecord->Ecx = 1;
return EXCEPTION_CONTINUE_EXECUTION;
}

int main(int argc, char* argv[])
{
//注册一个最顶层异常处理函数
SetUnhandledExceptionFilter(callback);

//除0异常
_asm
{
xor edx,edx
xor ecx,ecx
mov eax,0x10
idiv ecx
}

//程序正常执行
printf("程序执行");

getchar();
return 0;
}

构造一个除0异常,然后将异常修复的代码通过SetUnhandledExceptionFilter注册为顶层的异常处理函数。这样,如果程序被调试,那么顶层的异常处理函数就得不到执行,程序就会报错退出(二次异常时退出的),这样就达到了反调试的目的。这里注意一点,不要在VC++6.0编译器内运行程序,这样会报除零异常,进入该项目的文件夹,双击.exe文件,即程序能够正常执行;若拖入调试器中,则无法正常执行。

1.5 Vista之后的顶层处理变化

从 Windows Vista 开始,线程的实际入口点变成了 ntdll!RtlUserThreadStart (不再位于 kernel32.dll 中)。该函数直接跳转到了 ntdll!_RtlUserThreadStart,其内部调用了 RtlInitializeExceptionChain 函数,该函数与 SEHOP 保护机制有关,然后再调用 __RtlUserThreadStart。(注意 _RtlUserThreadStart 不同于 __RtlUserThreadStart

1
2
3
4
5
6
7
8
9
10
VOID _RtlUserThreadstart(
LPTHREAD_START_ROUTINE lpThreadstartaddr,
LPVOID lpThreadParm
)
{
EXCEPTION_REGISTRATION_RECORD FinalSEH;
RtlInitializeExceptionChain(&FinalSEH); //注册第一个SEH
__RtluserThreadstart(lpThreadstartAddr, lpThreadParm); //注册第二个SEH
__debugbreak ()
}

如6.1例2的代码在Win10+VS2019/VC6编译生成的截图如下:

35.png

总结:在 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 覆写保护机制)。