异常

如果你希望在软件调试上有所突破,或者想了解如何通过异常进行反调试,或者想自己写一个调试器,那么就必须要深入了解异常,异常与调试是紧密相连的,异常是调试的基础。

异常产生后,首先是要记录异常信息(异常的类型、异常发生的位置等),然后要寻找异常的处理函数,我们称为异常的分发,最后找到异常处理函数并调用,我们称为异常处理。我们后续的学习,也是围绕异常的、分发、处理。

异常记录

异常可以简单分为2类,即CPU产生的异常软件模拟产生的异常,如下两张图所示,我们可以看见第一张图中进行了除法运算,CPU检测到除数为0,就产生了异常;第二张图中使用了throw关键词,通过软件模拟主动产生了异常。

images/download/attachments/2949166/image2023-5-25_20-56-53.png

images/download/attachments/2949166/image2023-5-25_20-57-1.png

CPU的异常记录

我们先了解一个结构体_EXCEPTION_RECORD,它的格式及每个成员的意义如下:

typedef struct _EXCEPTION_RECORD {
DWORD ExceptionCode; // 异常状态码,在Windows中每一种状态(包括异常)都有一个状态码
DWORD ExceptionFlags; // 异常状态,0表示CPU异常,1表示软件模拟异常,8表示堆栈异常
struct _EXCEPTION_RECORD *ExceptionRecord; // 通常情况下该值为空,如果发生嵌套异常(即处理异常时又出现了异常)则指向下一个异常
PVOID ExceptionAddress; // 异常发生地址,表示异常发生时的位置
DWORD NumberParameters; // 附加参数个数
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; // 附加参数指针
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;

Windows状态码及其对应含义,我们可以在在线文档中获取:https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/596a1078-e883-4972-9bbc-49e60bebca55,如下图所示我们可以看见,整数除0时的异常状态码为0xC0000094。

images/download/attachments/2949166/image2023-5-25_21-36-30.png

接着我们可以通过除0的例子来看一下CPU的异常记录过程,它的大致过程就是:CPU指令检测到异常→查IDT表执行中断处理函数→执行CommonDispatchException→执行KiDispatchException。我们可以通过IDA打开Ntoskrnl.exe先找到IDT表,通过ALT+T快捷键全局搜索_IDT,找到IDT表。

images/download/attachments/2949166/image2023-5-25_21-47-45.png

根据下图所示的中断描述符表我们可以知道除0异常对应的0号中断处理函数,因此我们就可以在IDA中进入对应的处理函数:

images/download/attachments/2949166/image2023-5-25_21-50-9.png

images/download/attachments/2949166/image2023-5-25_21-51-19.png

该函数的前面一部分代码和KiSystemService函数(系统调用API进0环)的代码一样,都是用来保存现场的:

images/download/attachments/2949166/image2023-5-25_21-54-40.png

接着向下可以看到,该中断处理函数直至执行结束都没有对异常进行处理(微软在设计时,希望程序员自己能够对异常进行处理,因此在中断处理函数中并没有对异常进行处理),反而是有多处的跳转,调用了另一个函数CommonDispatchException。

images/download/attachments/2949166/image2023-5-25_22-0-10.png

跟进CommonDispatchException函数,我们可以看见它开辟了一块大小为0x50的空间,用于存放_EXCEPTION_RECORD结构体,并且给结构体的每个成员赋值,最终执行KiDispatchException函数,该函数通常用来分发异常,目的是找到异常的处理函数。

images/download/attachments/2949166/image2023-5-25_22-8-38.png

其中异常状态码是来自上层的EAX,这点我们通过之前的流程就可以知道,接着我们来看异常发生地址(即ExceptionAddress)是来自上层的EBX,而EBX又来自[EBP+0x68],这里实际上指的是Trap_Frame结构体0x68偏移位Eip成员,它是用来记录中断发生地址的,这是因为在保存现场结束之后,ESP指向Trap_Frame的顶部,而EBP也与ESP一样

images/download/attachments/2949166/image2023-5-25_22-22-29.png

软件模拟的异常记录

我们接着来看一下throw关键词触发的软件模拟异常记录过程,在关键词处下断点,然后运行通过反汇编代码我们可以知道它调用的就是CxxThrowException函数:

images/download/attachments/2949166/image2023-5-26_12-30-18.png

跟进该函数,会发现它也是调用了另外一个函数RaiseException,并通过栈的方式压入了几个传参:

images/download/attachments/2949166/image2023-5-26_12-36-14.png

接着我们跟进RaiseException函数发现,正是这几个传参填充了_EXCEPTION_RECORD结构体,在这里我们会发现两个比较关键成员的值都与CPU异常记录时的赋值内容不一致,首先是ExceptionCode,它的值很明显是一个Windows异常状态码中没有的(即0xE06D7363),这是因为在软件模拟产生的异常场景下,ExceptionCode的值是根据不同的编译环境而生成的;其次是ExceptionAddress,如下图中我们可以看见,它的值是RasieException函数的首地址,而并不是真正产生异常的那段地址。

images/download/attachments/2949166/image2023-5-26_12-45-24.png

RaiseException函数之后的流程是这样的:RtlRaiseException→NtRaiseException→KiRaiseException,在最后执行到KiRaiseException函数时,会将ExceptionCode的最高位清0,便于区分CPU/软件模拟异常

images/download/attachments/2949166/image2023-5-26_12-53-55.png

虽然模拟异常与CPU异常有一定的差异,但是在最后,两者都会去调用KiDispatchException函数,用于异常分发

images/download/attachments/2949166/image2023-5-26_12-56-19.png

异常分发与处理

异常可以发生在用户空间,也可以发生在内核空间。无论是CPU异常还是模拟异常,无论是用户空间异常还是内核空间异常,最终都要通过KiDispatchException函数进行分发,理解这个函数是学好异常的关键,这个函数比较复杂,我们以内核、用户两个角度来分析学习。

内核异常

本章我们主要分析内核异常是如何分发,如何处理的。首先我们需要来了解一下KiDispatchException函数的格式,及每个参数的作用:

VOID KiDispatchException (
PEXCEPTION_RECORD ExceptionRecord, // 异常记录结构体
PKEXCEPTION_FRAME ExceptionFrame, // X86系统下,该值为NULL
PKTRAP_FRAME TrapFrame, // 3环进0环保存现场所用的结构体
KPROCESSOR_MODE PreviousMode, // 先前模式,表示调用来自什么模式,0表示内核模式,1表示用户模式
BOOLEAN FirstChance // 判断是否是第一次分发这个异常,对于同一个异常,Windows最多分发两次
// 该值为1表示第一次分发,为0表示第二次分发
)

通过IDA直接打开Ntoskrnl.exe模块,找到KiDispatchException函数,该函数最开始就是先调用_KeContextFromKframes函数,将Trap_Frame备份到_Context中,为返回3环做准备(这里与用户APC执行过程一样,因为该函数支持内核、用户空间的异常分发和处理,因此我们不知道异常处理函数到底是在用户空间还是内核空间,所以第一件事情就是备份Trap_Frame,便于中途回到3环)。

images/download/attachments/2949166/image2023-5-26_16-45-29.png

接着我们可以看见它会去判断先前模式,如果先前模式为1则进入用户空间的异常处理逻辑,为0则接着向下判断FirstChance,即当前异常是否为第一次分发,如果是第一次则继续向下判断当前是否有内核调试器,如果没有内核调试器则跳转,有内核调试器的话优先调用内核调试器函数

images/download/attachments/2949166/image2023-5-26_17-1-49.png

接着我们向下看,会发现没有内核调试器,或者内核调试器函数返回结果为0的情况下,都会跳转至同一代码段;这里需要说明下内核调试器函数返回结果为0则表示异常未被处理为1则表示异常被处理了,然后会将_Context转换成Trap_Frame返还,异常处理过程结束,退出KiDispatchException函数。

images/download/attachments/2949166/image2023-5-29_9-32-12.png

然后我们跟进它们都跳转进的代码段会发现这里调用了RtlDispatchException函数,这个函数专门负责调用异常处理函数来处理异常,我们可以看见该函数调用时候传递了两个参数,即Context和ExceptionRecord

images/download/attachments/2949166/image2023-5-26_17-8-14.png

跟进RtlDispatchException函数(该函数是一个库函数,内核和用户异常都会使用它来进行分发,在本章简单了解一下,后续用户异常分发和处理我们详细进行分析),我们会发现其调用了RtlpGetRegistrationHead函数,它的作用就是获取FS:[0]。

images/download/attachments/2949166/image2023-5-26_17-27-33.png

根据之前的学习,我们知道在0环的FS:[0]指向的是_KPCR结构体,_KPCR的第一个成员是NtTib,而NtTib的第一个字段是ExceptionList,ExceptionList这个字段是一个指针,它指向了一个结构体_EXCEPTION_REGISTRATION_RECORD,该结构体有2个成员,第一个成员Next指向下一个_EXCEPTION_REGISTRATION_RECORD结构体地址(如果没有下一个结构体,则该值为-1),第二个成员Handler指向了异常处理函数

kd> dt _EXCEPTION_REGISTRATION_RECORD
nt!_EXCEPTION_REGISTRATION_RECORD
+0x000 Next : Ptr32 _EXCEPTION_REGISTRATION_RECORD
+0x004 Handler : Ptr32 _EXCEPTION_DISPOSITION

所以RtlDispatchException的作用就是遍历异常链表,调用异常处理函数,如果异常被正确处理了,该函数返回1;如果当前异常处理函数不能处理该异常,就调用下一个,以此类推到最后也没有异常处理函数处理这个异常,则该函数返回0

images/download/attachments/2949166/image2023-5-29_10-0-53.png

如果RtlDispatchException函数返回0,我们可以看见它又会去判断当前是否有内核调试器,和上面的流程一样,发现没有内核调试器,或者内核调试器函数返回结果为0的情况下,跳转至同一代码段,该代码段的作用就是蓝屏(KeBugCheckEx就是Windows执行崩溃的函数,作用就是使计算机蓝屏)。


images/download/attachments/2949166/image2023-5-29_10-8-8.png

用户异常

异常如果发生在内核层,处理起来比较简单,因为异常处理函数也在0环,不用切换堆栈,但是如果异常发生在3环,就意味着必须要切换堆栈,回到3环执行异常处理函数。这个堆栈切换的处理方式与用户APC的执行过程几乎是一样的,惟一的区别就是执行用户APC时返回3环后执行的函数是KiUserApcDispatcher,而异常处理时返回3环后执行的函数是KiUserExceptionDispatcher。因此本章节不再对堆栈的切换进行了解,可以自行复习一下用户APC执行过程的笔记。

我们接着来看一下KiDispatchException函数是如何对用户空间的异常进行分发处理的,还是开头的那几步:保存Trap_Frame至_Context,判断先前模式如果为1则表示来当前异常来自用户空间,跳转到对应代码段,接着判断是否是第一次分发,判断内核调试器,调用内核调试器...

images/download/attachments/2949166/image2023-5-29_14-17-2.png

这里不管有没有内核调试器,或内核调试器函数返回结果为0,都会跳转或向下执行到同一处代码段,这里调用了一个函数_DbgkForwardException,它的作用是将异常发送给3环的调试器,如果返回非0则表示有3环调试器且处理了异常,就会跳转。

images/download/attachments/2949166/image2023-5-29_14-20-36.png

反之,如果返回为0则表示没有处理该异常,继续向下执行就是为返回3环做准备,将Trap_Frame的值修改为返回3环时候的环境,详细的不再多说,其中最为重要的就是返回3环时的地址,即Trap_Frame._Eip(0x68偏移位),如下图代码所示,将_Eip修改为了KeUserExceptionDispatcher函数的地址,也就表示返回3环后就执行该函数。但是这里修改完Trap_Frame的值之后并没有返回3环,而是直接跳转,KiDispatchException函数执行结束。

images/download/attachments/2949166/image2023-5-29_14-34-0.png

由于不同类型的异常,调用KiDispatcherException的函数不同,所以会当KiDispatcherException执行完后,会返回当相应的函数继续执行。如下图所示,如果是CPU异常,KiDispatcherException函数执行完成之后会返回到CommonDispatcherException函数中,并通过IRETD返回3环(CPU是通过中断门进的0环,因此用中断返回);如果是模拟异常,KiDispatcherException执行完成之后会返回到KiRaiseException函数中,并通过系统调用(KiServiceExit)返回3环。

images/download/attachments/2949166/image2023-5-29_14-40-47.png

虽然返回3环的方式不同,但只要是用户空间的异常,当线程再次回到3环时,执行的都是KiUserExceptionDispatcher。(KiUserExceptionDispatcher函数会在后续章节中了解)

VEH

KiUserExceptionDispatcher函数存在与Ntdll.dll模块中,我们可以通过IDA找到并分析,该函数的作用很简单,首先调用RtlDispatchException函数(该函数主要用于查找并执行异常处理函数):如果RtlDispatchException返回真则表示异常处理成功,那么原代码就需要从新的地方或原来的地方开始执行,因此需要调用ZwContinue函数再次进入0环,将修正后的_Context结构体给到Trap_Frame,这样就可以在线程再次返回3环时,从修正后的位置开始执行;如果RtlDispatchException返回假则表示异常没有被处理或没有异常处理函数,则调用ZwRaiseException函数进行二次异常分发

images/download/attachments/2949166/image2023-5-31_11-5-13.png

RtlDispatchException是个库函数,在内核异常分发处理时用的也是这个函数,虽然在用户异常也是如此,但实际过程是不一样的。

我们在之前内核异常分发学习中知道RtlDispatchException函数调用了RtlpGetRegistrationHead函数来查找一个异常链表,这个异常链表也可以称之为SEH链表,而在用户异常分发的RtlDispatchException函数中首先调用了RtlpExecuteHandlerForException函数来寻找VEH链表,如果有的话则遍历该链表找到对应的异常处理函数,如果没有则继续使用RtlpGetRegistrationHead函数来寻找SEH链表。

images/download/attachments/2949166/image2023-5-31_11-37-27.png

SEH链表是一种存在堆栈中的局部链表,本章要学习的VEH链表结构与之相似,只不过VEH链表是全局链表。我们要想通过VEH来处理异常,要先创建如下回调函数来接收和处理异常:

LONG NTAPI VectoredHandler(
PEXCEPTION_POINTERS ExceptionInfo
);

该函数的参数ExceptionInfo为EXCEPTION_POINTERS结构体的指针,该结构体及其成员如下所示:

typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord; // 异常记录
PCONTEXT ContextRecord; // 异常发生时的线程上下文环境
};

回调函数VectoredHandler有两个返回值:

#define EXCEPTION_CONTINUE_SEARCH 0 // 异常未被处理,继续搜索
#define EXCEPTION_CONTINUE_EXECUTION -1 // 异常处理完毕,恢复执行

回调函数VectoredHandler创建之后,就可以将其进行注册,也就是添加到全局链表中,当遇到对应的用户异常时,就会被查找并调用。注册回调函数的格式如下:

PVOID AddVectoredExceptionHandler(
ULONG FirstHandler, // 指定注册的回调函数被调用的顺序,该值为0表示希望最后被调用,为1表示希望最先被调用,若注册了多个回调函数,且所有的FirstHandler值都为1,那么最后注册的回调函数会被最先调用
PVECTORED_EXCEPTION_HANDLER VectorerHandler // 需要注册的回调函数
);

在AddVectoredExceptionHandler函数内部,会为每个VEH准备如下一个结构体:

typedef struct _VEH_REGISTRATION{
_VEH_REGISTRATION* next; // 指向下一个VEH
_VEH_REGISTRATION* prev; // 指向上一个VEH
PVECTORED_EXCEPTION_HANDLER pfnVeh; // 指向当前VEH的回调函数
}VEH_REGISTRATION, *PVEH_REGISTRATION;

当有多个VEH时,这些VEH的_VEH_REGISTRATION结构体串联组成一个双向链表。在Ntdll.dll模块中,全局变量RtlpCalloutEntryList指向该链表的链表头(在0环中,则是通过FS:[0]找到ExceptionList再找到SEH的链表头)。

当VEH处理异常结束之后,我们可以注销VEH,即如下这个函数:

PVOID RemoveVectoredExceptionHandler(
PVECTORED_EXCEPTION_HANDLER VectorerHandler // 注册的回调函数
);

在有了以上基础的铺垫之后,我们可以编写如下代码来使用VEH处理异常,这段代码大致分为3部分:

  1. 定义了指向AddVectorExceptionHandler函数的函数指针,因为VEH异常处理在XP之前的系统中并没有,也就不存在这个函数,因此为了代码兼容性,我们不直接通过库调用,而是以动态加载DLL的方式来获取函数地址,然后用定义的函数指针指向它,再调用函数指针就可以使用相应功能了;

  2. 定义异常处理函数VectExcepHandler,根据其参数ExceptionInfo.ExceptionRecord.ExceptionCode来获取异常状态码,如果是除0异常则通过两种方式来处理异常,一是通过ExceptionInfo.ContextRecord.Eip来修改返回3环时的地址,以此来跳过异常处理的代码(EIP+2是因为idiv ecx这个指令长度就是2),二是通过ExceptionInfo.ContextRecord.Ecx来修改除数,修复异常的代码;

  3. 注册异常处理函数,并通过汇编的方式构造除0异常,这里也就将EAX作为了除数,这样就可以触发异常,从而进入异常的处理。

#include <stdio.h>
#include <windows.h>
 
// 定义指向AddVectorExceptionHandler函数的函数指针
typedef PVOID(NTAPI *FnAddVectoredExceptionHandler)(ULONG, _EXCEPTION_POINTERS*);
FnAddVectoredExceptionHandler MyAddVectoredExceptionHandler;
 
// 定义异常处理函数
LONG NTAPI VectExcepHandler(PEXCEPTION_POINTERS pExcepInfo)
{
MessageBox(NULL,"VEH异常处理函数执行了...","VEH异常",MB_OK);
// 除0异常
if (pExcepInfo->ExceptionRecord->ExceptionCode == 0xC0000094)
{
// 修改发生异常时的EIP
// pExcepInfo->ContextRecord->Eip = pExcepInfo->ContextRecord->Eip + 2;
// 修改发生异常时的ECX
pExcepInfo->ContextRecord->Ecx = 1;
// 此处返回表示异常已处理
return EXCEPTION_CONTINUE_EXECUTION;
}
// 此处返回表示异常未处理
return EXCEPTION_CONTINUE_SEARCH;
}
 
// 主函数
int main()
{
// 动态获取AddVectoredExceptionHandler函数地址,并将异常处理函数挂入VEH链表
HMODULE hModule = GetModuleHandle("Kernel32.dll");
MyAddVectoredExceptionHandler = (FnAddVectoredExceptionHandler)::GetProcAddress(hModule,"AddVectoredExceptionHandler");
// 注册
MyAddVectoredExceptionHandler(0, (_EXCEPTION_POINTERS *)&VectExcepHandler);
// 构造除0异常
_asm
{
xor edx, edx
xor ecx, ecx
mov eax, 100
idiv ecx
}
 
printf("Running ... ");
getchar();
return 0;
}

SEH

原生SEH

如果调用RtlDispatchException函数找不到VEH链表,则会去寻找SEH链表,SEH链表是一个局部链表,存储在当前线程的栈中,因此不同的线程要通过SEH来处理异常都需要在自己的堆栈中存放

SEH链表的格式如下所示,它是一个单向链表,每个成员中包含了两个成员,即Next(下一个SEH)和Handler(异常处理函数)。无论是在内核还是用户空间,我们都可以通过FS:[0]来找到该链表头

images/download/attachments/2949166/image2023-5-31_16-29-58.png

images/download/attachments/2949166/image2023-5-31_16-38-14.png

SEH与VEH不同的是,前者没法使用函数的方式去注册异常处理函数,需要我们手动的向栈中填充,然后将FS:[0]指向这块栈地址。这里我们可以自己定义一个SEH结构体,但一定需要包含2个成员,即Next、Handler,如下所示:

struct _MY_EXCEPTION
{
struct _MY_EXCEPTION *Next;
DWORD Handler;
};

我们接着来分析一下RtlDispatchException函数,以验证上述的一些观点。如下图所示在该函数内找不到VEH链表后,会去调用两个函数。

第一个函数RtlpGetStackLimits取了FS:[8]和FS:[4]的值,即_TEB._NT_TIB.StackBase和_TEB._NT_TIB.StackLimit,这两个值就是栈的基址和栈的大小,取这两个值的目的就是为了检查SEH链表是否属于当前线程的栈中

第二个函数RtlpGetRegistrationHead取FS:[0]的值,即_TEB._NT_TIB.ExceptionList,也就是SEH链表头地址。

images/download/attachments/2949166/image2023-5-31_16-55-46.png

RtlDispatchException函数再往下走,我们看见它调用了RtlIsValidHandler函数,用于验证异常处理函数是否有效,接着调用RtlpExecuteHandlerForException函数,用于调用异常处理函数,因此我们的异常处理函数并不可以完全自定义,而是需要遵循其所能执行的格式

images/download/attachments/2949166/image2023-5-31_17-2-26.png

SEH异常处理函数的格式如下,它一共有4个参数,我们只需要了解前三个参数的含义即可:

EXCEPTION_DISPOSITION _cdecl MyEexceptionHandler
(
struct _EXCEPTION_RECORD *ExceptionRecord, // 异常记录结构体
PVOID EstablisherFrame, // SEH结构体地址
struct _CONTEXT *ContextRecord, // 存储异常发生时的上下文环境
PVOID DispatcherContext
)

那么有了这些基础的铺垫之后我们可以通过编写代码来实现SEH异常处理,这段代码与VEH异常处理差不多,唯一的区别在于在我们将FS:[0]指向SEH结构体之前需要先保存原FS:[0]的值,然后在结构体的Next赋值时将原FS:[0]的值写入,因为可能在原SEH链表中是有内容的,最后在SEH异常处理函数结束,将FS:[0]的值还原

#include <stdio.h>
#include <windows.h>
 
struct _MY_EXCEPTION
{
struct _MY_EXCEPTION *Next;
DWORD Handler;
};
 
EXCEPTION_DISPOSITION _cdecl MyEexceptionHandler
(
struct _EXCEPTION_RECORD *ExceptionRecord, // 异常记录结构体
PVOID EstablisherFrame, // SEH结构体地址
struct _CONTEXT *ContextRecord, // 存储异常发生时的上下文环境
PVOID DispatcherContext
)
{
MessageBox(NULL, "SEH异常处理函数执行了...", "SEH", MB_OK);
 
if (ExceptionRecord->ExceptionCode == 0xC0000094)
{
// ContextRecord->Eip = ContextRecord->Eip + 2;
 
ContextRecord->Ecx = 1;
 
return ExceptionContinueExecution;
}
return ExceptionContinueSearch;
}
 
 
int main()
{
DWORD tmpData;
// 必须在当前线程栈中
_MY_EXCEPTION Exception;
// FS:[0] -> Exception
_asm
{
mov eax, fs:[0]
mov tmpData, eax // 保存原FS:[0]
lea ecx, Exception
mov fs:[0], ecx
}
 
// 成员赋值
Exception.Next = ( _MY_EXCEPTION *)tmpData;
Exception.Handler = (DWORD)&MyEexceptionHandler;
 
// 构造除0异常
_asm
{
xor edx, edx
xor ecx, ecx
mov eax, 1
idiv ecx
}
 
// 摘除刚插入的SEH,还原FS:[0]
_asm
{
mov eax, tmpData
mov fs:[0], eax
}
 
printf("Running ... ");
 
getchar();
return 0;
}

编译器扩展SEH

_try_except

我们在上一章学习的是Windows自带的原生SEH,除此之外实际上编译器还另外扩展了SEH,这是因为使用原生SEH实际上非常麻烦,需要自己构建结构体、异常处理函数、写入FS:[0]等等操作。

在VC6编译器下,我们可以通过如下格式的代码来处理SEH异常,即_try{...})except(...){...}格式,使用这种格式编写代码后,编译器会在编译时帮我们转换,使得最终代码能够实现挂入链表、异常过滤、异常处理。

images/download/attachments/2949166/image2023-5-31_19-14-59.png

在过滤表达式部分,我们可以直接写常量值,也可以写表达式或调用的函数,但无论什么方式,这里最终的值只能为0、1、-1,它们的含义如下:

#define EXCEPTION_EXECUTE_HANDLER 1 // 执行except内的代码
#define EXCEPTION_CONTINUE_SEARCH 0 // 寻找下一个异常处理函数
#define EXCEPTION_CONTINUE_EXECUTION -1 // 返回至出错位置重新执行

三种不同风格的表达式示例代码如下,我们可以看见当简单的异常过滤可以直接写常量,复杂一些的使用三元表达式,再复杂一些的可以使用函数的方式,并且我们可以使用GetExceptionCode、GetExceptionInformation函数来获取异常状态码、异常相关信息

#include <stdio.h>
#include <windows.h>
 
int getFilterCode(PEXCEPTION_POINTERS pExcepInfo)
{
pExcepInfo->ContextRecord->Ecx = 1;
return EXCEPTION_CONTINUE_EXECUTION;
}
 
int main()
{
_try
{
_asm
{
xor edx, edx
xor ecx, ecx
mov eax, 1
idiv ecx
}
}
// _except(EXCEPTION_EXECUTE_HANDLER)
// _except(GetExceptionCode() == 0xC0000094 ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH)
_except(getFilterCode(GetExceptionInformation()))
{
printf("Processing ...");
}
 
printf("Running ...");
 
getchar();
return 0;
}

我们可以通过反汇编来看一下使用_try_except的背后到底干了什么,如下图所示,我们可以看见与我们手动挂入链表类似,保存原FS:[0],将其作为下一SEH结构体地址,然后将FS:[0]指向当前SEH结构体的地址,并且在这里的异常处理函数为_except_handler3。

images/download/attachments/2949166/image2023-5-31_20-31-14.png

那么出现嵌套_try_except使用时会怎么样呢,按照正常逻辑来想,肯定会出现重复挂入链表的操作(设置链表头),但是我们通过反汇编看见,编译器处理实际上只挂入了一次,并且仍然只有一个异常处理函数_except_handler3。

images/download/attachments/2949166/image2023-5-31_20-40-47.png

能这样实现是因为编译器扩展了SEH结构体,从原先只有2个成员的结构体变成了5个成员:

struct _EXCEPTION_REGISTRATION{
struct _EXCEPTION_REGISTRATION *prev;
void (*handler)(PEXCEPTION_RECORD, PEXCEPTION_REGISTRATION, PCONTEXT, PEXCEPTION_RECORD);
struct scopetable_entry *scopetable;
int trylevel;
int _ebp;
};

堆栈对应也就发生了变化,如下图所示为堆栈对应结构体成员:

images/download/attachments/2949166/image2023-5-31_20-56-58.png

我们来看一下再原有SEH结构体上新增的3个成员,首先是_ebp这个成员就是栈底,其次是scopetable,它是一个结构体指针,指向了scopetable_entry结构体,该结构体及成员含义如下:

struct scopetable_entry
{
DWORD previousTryLevel // 上一个_try_except程序块编号
PDWRD lpfnFilter // 过滤函数的起始地址
PDWRD lpfnHandler // 异常处理程序的地址
}

我们可以具体来调试看一下该结构体,如下所示我们使用了1个单独的_try_except代码块和1个嵌套的_try_except代码块:

void test()
{
_try
{
 
}
_except(1)
{
printf(123);
}
 
_try
{
_try
{
 
}
_except(1)
{
printf(456);
}
}
_except(1)
{
printf(789);
}
}

通过反汇编我们可以看见,当有3个_try_except程序块时就会有三个scopetable_entry结构体,它们是对应关系。成员previousTryLevel指向上一个_try_except程序块的编号不在嵌套里就没有上一层,因此值为-1,这里我们可以看见有两个结构体的成员previousTryLevel值均为-1,第三个结构体的previousTryLevel值为1,是因为它是在嵌套_try_except程序块中,上面确实有一层_try_except程序块,因此我们可以通过这个值来判断当前在第几层嵌套中;成员lpfnFilter指向过滤函数的首地址,如图所示也就是我们的_except(过滤表达式)那个地址,通过返汇编我们可以看见这是有RET返回结果的,所以当异常发生时,代码会经过多次跳转和返回;成员lpfnHandler指向异常处理函数的首地址,也就是_except程序块内的异常处理代码,即下图所示中的printf函数部分

images/download/attachments/2949166/image2023-6-1_11-8-51.png

了解了scopetable及其对应结构体的成员之后,我们再来看一下trylevel成员,该成员表示当前在哪个_try_except程序块,我们可以通过如下这段代码来看一下该值的变化:

void test()
{
_try
{
_try
{
}
_except(1)
{
printf("123");
}
}
_except(1)
{
printf("456");
}
 
_try
{
_try
{
}
_except(1)
{
printf("789");
}
}
_except(1)
{
printf("012");
}
}

我们接着来看这段反汇编,首先将EBP压入扩展结构体,然后将EBP提升至ESP位置,再压入trylevel,初始值就是-1,我们也可以通过[EBP-4]来找到该值,接着我们会发现每个_try都会有一次的trylevel的修改,根据数值来看这就像是一共索引,第一个_try_except块trylevel值为0,第二个则值为1,以此类推,如果是嵌套的_try_except块则内嵌的块指向结束之后会将该trylevel值修改为上层的索引值,如果当上层没有_try_except块时,执行完毕就会将trylevel值修改为-1。因此我们可以看见它是动态变化的,并不是固定值

images/download/attachments/2949166/image2023-6-1_14-23-12.png

那么当前编译器环境中,异常处理函数_except_handler3就会根据trylevel选择scopetable数组中的结构体,然后找到结构体的IpfnFilter成员,即异常过滤函数地址。

_try_finally

除了_try_except程序块之外,编译器还提供了_try_finally,与前者不同的,在_finally块内的代码一定会去执行,无论你_try块内是否出错、中断、正常,都可以得到执行。

如下图所示,无论使用continue、break控制,还是return返回,亦或是触发异常,_finally块内的代码始终会被执行

images/download/attachments/2949166/image2023-6-1_18-2-36.png

我们以return的代码为例,来看一下反汇编,如下图所示,我们可以看见_try_finally程序块时,扩展SEH结构体中的scopetable成员与_try_except不一样,首先是它的lpfnFilter过滤函数地址是空的,因此我们可以通过这个成员来判断当前是否是_finally块,其次是它的lpfnHandler异常处理函数地址指向的是_finally块中的代码地址

images/download/attachments/2949166/image2023-6-1_18-13-13.png

并且我们可以看见在执行return语句之前,调用了一个名为_local_unwind2的函数(这个函数翻译成中文就是局部展开的意思,没有别的含义)。我们可以继续跟进_local_unwind2,会发现它调用了一个函数,即[ebx+esi*4+8],通过寄存器的值计算,我们查看对应地址为_finally块中的代码地址,这也就解释了为什么可以在return之前执行_finally块中的地址了(即_finally块中的代码一定得到执行)。

images/download/attachments/2949166/image2023-6-1_18-20-50.png

我们接着来看下面这段代码,它有三层_try,外层是_try_except,第二、三层都是_try_finally,发生异常的代码处于第三层,按照我们之前所学习的内容来看,一旦触发异常,except_handler3函数会根据当前trylevel的值找到对应的结构体并寻找异常处理函数lpfnFilter,从内至外,从第三层开始找,通过previousTryLevel来逐层向上寻找,但由于三层、二层都是_finally块,因此过滤函数地址那一块的值是0,最终会找到第一层,也就是最外层的_except块,此时过滤表达式为1,_except块内的代码得到执行,然后就会返回。

void test()
{
_try
{
_try
{
_try
{
*(int*)0 = 1;
}
_finally
{
printf("Finally2 ... \n");
}
}
_finally
{
printf("Finally1 ... \n");
}
}
_except(1)
{
printf("Except ... \n");
}
}

此时就会有个问题,在内层的_finally块代码是否会得到执行呢?我们可以实际运行下代码,如下图所示我们可以看见,_finally块的代码都得到了执行。
images/download/attachments/2949166/image2023-6-2_15-48-1.png

那么在这里是为什么呢,实际在每个_except块内代码执行之前,都会调用一次全局展开函数,即_global_unwind2函数,该函数会从触发异常的那个try开始,依次调用局部展开,这样就可以保证finally块语句一定会得到执行

images/download/attachments/2949166/image2023-6-2_16-3-8.png

未处理异常

如果VEH与SEH都没有对异常进行处理,这种异常我们就称之为未处理异常。因为我们根据之前分析内核空间异常知道,当没有任何方法取处理异常时会调用KeBugCheckEx函数启用蓝屏,内核未处理异常的处理结果也很简单,因此本章节所学习的是用户空间的未处理异常

我们首先编写一个简单的代码,启动一下,看它的调用栈,我们可以看见,程序启动时并不是直接从main函数开始执行的,它的上层有mainCRTStartup、KERNEL32!7c816d4f()

images/download/attachments/2949166/image2023-6-2_16-49-7.png

我们可以跟进KERNEL32!7c816d4f,最终就会发现它在这一层将SEH挂到链表上,也就说实际上在入口程序部分,也给我们加了一道异常处理的防线。因此main函数如果发生异常,且在它的SEH链表中未能查找到能够处理异常的异常处理函数,那么_except_handler3函数则会通过previousTryLevel查找最外层的异常处理函数,也就是在这挂上去的SEH结构体中的异常处理函数

images/download/attachments/2949166/image2023-6-5_11-31-46.png

在这里,这个函数实际上就是Kernel32.dll模块中的BaseProcessStart函数,在该函数内调用了另外一个函数_SEH_prolog,它的作用就是添加SEH到链表上。

images/download/attachments/2949166/image2023-6-2_17-20-35.png

那么除了入口程序以外,如果我们另起一个线程,是否也会给我们提供一个异常处理的防线呢,我们可以编写一段代码来另起一个线程:

#include <stdio.h>
#include <windows.h>
 
DWORD WINAPI ThreadProc(LPVOID lpParam)
{
int x = 1;
return 0;
}
 
void main(int argc, char* argv[])
{
CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);
getchar();
}

我们在新线程的代码中下断点,查看调用栈,回溯跟踪过去,会发现也会给它一个SEH挂入异常链表中:

images/download/attachments/2949166/image2023-6-5_11-33-19.png

我们也可以通过IDA来看这个地址对应的函数,即BaseThreadStart,它确实也调用了一个_SEH_prolog函数用于添加SEH至链表上

images/download/attachments/2949166/image2023-6-5_11-25-2.png

综上所述,我们可以得出结论大部分情况下,无论是进程的入口线程还是另起的线程,都会被添加的异常处理函数给处理掉,所以未处理异常的情况一般是不存在的

我们可以将这个最后一道防线执行过程总结为如下的伪代码,当程序有异常发生时,若原先堆栈的SEH均未处理,那么这个函数一定会执行

__try
{
 
}
__except(UnhandledExceptionFilter(GetExceptionInformation())
{
//终止线程
//终止进程
}

如果UnhandledExceptionFilter函数返回为0,即EXCEPTION_CONTINUE_SEARCH,那就真的是找不到对应的异常处理程序了,但这种情况如果你的程序在没有被调试的情况下是不会发生的,只有程序被调试时,才会存在未处理异常

UnhandledExceptionFilter函数的执行流程如下:

  1. 通过NtQueryinformationProcess函数查询当前进程是否正在被调试。

  2. 如果被调试,返回EXCEPTION_CONTINUE_SEARCH,此时会进入第二轮分发。

  3. 如果没有被调试:

    1. 查询是否通过SetUnhandledExceptionFilter注册处理函数,如果有就调用;

    2. 如果没有通过SetUnhandledExceptionFilter注册处理函数,就会弹出窗口让用户选择终止程序还是启动即时调试器;

    3. 如果用户没有启用即时调试器,那么该函数返回EXCEPTION_EXECUTE_HANDLER。

因此我们可以通过如下的代码来进行反调试,编译这个程序,正常打开是会执行printf的,但如果通过OD打开则会停止运行。

#include <stdio.h>
#include <windows.h>
 
long _stdcall callback(_EXCEPTION_POINTERS* excp)
{
excp->ContextRecord->Ecx = 1;
return EXCEPTION_CONTINUE_EXECUTION;
}
 
void main(int argc, char* argv[])
{
// 注册一个最顶层异常处理函数
SetUnhandledExceptionFilter(callback);
 
// 除0异常
_asm
{
xor edx,edx
xor ecx,ecx
mov eax,0x10
idiv ecx
}
// 程序正常执行
printf("Running ... ");
getchar();
}

如下图所示我们使用纯净版的Ollydbg调试该程序就没法执行正常的代码,如果我们使用带插件版的OD,如吾爱破解的OD则可以直接绕过反调试:

images/download/attachments/2949166/image2023-6-5_14-46-35.png

绕过反调试的原理很简单,我们知道UnhandledExceptionFilter函数是通过NtQueryInformationProcess来判断是否被调试的,而NtQueryInformationProcess是通过DebugPort的值来判断程序是否正在被调试,因此我们只需要Hook了NtQueryInformationProcess,修改DebugPort的值就可以绕过反调试了。

最后我们再来看一下KiUserExceptionDispatcher函数,它首先会调用RtlDispatchException,这个函数包括了对VEH、SEH的查找,以及查看是否存在顶层函数,以及是否被调试。全部都判断完了以后,返回一个布尔值,返回为真,调用ZwContinue再进入0环,返回为假,调用ZwRaiseException进行第二轮异常分发

images/download/attachments/2949166/image2023-6-5_14-54-10.png

最后我们就可以了解整个异常分发、处理的过程了,如下图所示:

images/download/attachments/2949166/image2023-6-5_14-59-24.png