Windows下的异常处理
0x01 异常处理的基本概念
中断(Interrupt)和异常(Exception)。中断是由外部硬件设备或者异步事件产生的,异常是由内部事件产生的。
1.1异常列表
异常是应用程序在执行过程中发现的不正常事件。由CPU引发的异常称为硬件异常,如访问无效的内存地址。由操作系统或者应用程序引发的异常成为软件异常。
常见异常如下:
除了CPU能捕获一个事件并引发一个硬件异常外,在代码中可以调用RaiseException函数主动引发一个软件异常。
void RaiseException(
DWORD dwExceptionCode,
DWORD dwExceptionFlags,
DWORD nNumberOfArguments,
const ULONG_PTR *lpArguments
);
其中,参数dwExceptionCode标识引发异常的代码,参数dwExceptionFlags用于指定是否继续执行,nNumberOfArguments表示要传递给异常处理程序的参数个数,lpArguments则指向传递参数的数组。
1.2 异常处理过程
在中断或异常发生时,CPU会通过中断描述符表(Interrupt Descrptor Table,IDT)来寻找处理函数。因此,IDT表是CPU和操作系统交接中断和异常的关口。
1.2.1 IDT表
IDT是一个位于物理内存中的线性表,共有256项,32位下每一项8字节,64位则为16字节。操作系统在启动阶段初始化这个表,系统中的每个CPU都有一份IDT的拷贝。下面是讨论32位IDT。
IDT的位置和长度是由CPU的IDTR寄存器描述的,IDTR寄存器有48位,高32位是表的基址,低16位是表的长度。可以使用SIDT和LIDT来读写该寄存器,但LIDT只能在ring0下执行。
IDT的每一项是一个门结构,它是发生中断或异常时CPU转移控制权的必经之路(这里我认为的转移控制权就是从原异常中断处转移到了异常处理程序),包括下面三种门描述符:
- 任务门(task-gate)主要用于CPU的任务切换(TSS功能)
- 终端门(interrupt-gate),主要用于描述中断处理程序的入口
- 陷阱门(trap-gate)主要用于描述异常处理程序的入口
1.2.2 异常处理准备工作
中断或异常发生时,CPU会根据中断类型号转而执行相对应的中断处理程序。异常处理函数除了针对本异常的特定处理外还会对异常信息进行封装,方便后续处理。
封装的内容主要有两部分。一部分是异常记录,包含本次异常的信息:
typedef struct _EXCEPTION_RECORD {
NSTATUS ExceptionCode; //异常代码
ULONG ExceptionFlags; //一场标志
struct _EXCEPTION_RECORD *ExceptionRecord; //指向另一个RECORD的指针
PVOID ExceptionAddress; //异常发生的地址
ULONG NumberParameters; //下列参数个数
ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS]; //附加信息
} EXCEPTION_RECORD, *PEXCEPTION_RECORD;
ExceptionCode定义了一场产生原因。
另一部分被封装的内容被称为陷阱帧,它描述了发生异常时线程的状态(Windows的任务调度是基于线程的)。由于和处理器密切相关,所以在不同的平台上有不同的定义。
该结构包含每个寄存器的状态,但该结构一般仅供系统内核自身或者调试系统使用。当需要把控制权交给用户注册的异常处理函数的时候,会将上述结构转换成一个名为CONTEXT的结构,它包含线程运行时处理器各主要寄存器的完整镜像,用于保存线程运行环境。
CONTEXT结构有一个标志位,根据其标志不同恢复执行的时候更新的域也不同,可以有选择性的更新部分域。
包装完毕后,异常处理函数会调用系统内核的nt!KiDispatchException函数来处理异常。
DECLSPEC_NORETURN
VOID
NTAPI
KiDispatchException(
_In_ PEXCEPTION_RECORD ExceptionRecord, //异常结构信息
_In_ PKEXCEPTION_FRAME ExceptionFrame, //对NT386系统总为NULL
_In_ PKTRAP_FRAME TrapFrame, //发生异常时候的陷阱帧
_In_ KPROCESSOR_MODE PreviousMode, //发生异常的时候CPU模式是内核模式还是用户模式
_In_ BOOLEAN FirstChance //是否第一次处理异常
);
在该函数中,系统会根据是否存在内核调试器,用户态调试器以及调试器对异常的干预结果完成不同的处理过程。
1.2.3 内核态的异常处理过程
当PreviousMode为KernelMode时,表示内核模式下产生的异常,此时KiDipatchException会按以下步骤处理异常。
- 检查当前系统是否被内核调试器调试。如果不存在跳过此步骤。存在,系统将控制权交给调试器,并注明是第一次处理机会。内核调试器获取控制权后,根据用户的设置确定是否处理异常。无法处理则发生中断,把控制权交给用户。如果调试器正确处理,发生异常的线程回到产生的异常位置继续执行。
- 如果不存在内核调试器,或者第一次处理机会出现的时候调试器选择不处理该异常,系统就会调用nt!RtlDispatchException函数,根据线程注册的SEH过程来处理异常。
- 如果nt!RtlDispatchException没有处理该异常,系统给调试器第二次处理机会,处理器再次获取处理权
- 如果不存在内核调试器,或者第二次调试器仍不处理,系统认为不能再继续运行。其直接调用KeBugCheckEx产生KERNEL_MODE_EXCEPTION_NOT_HANDLED(值为0x0000008E)的BSOD(俗称蓝屏错误)。
1.2.4 用户态的处理过程
当PreviousMode为UserMode时,表示用户模式下产生的异常,此时KiDipatchException仍会检测内核调试器是否存在。如果存在,优先将控制权交给内核调试器。在大多数情况下,内核调试器对用户态的异常不感兴趣,不会进行处理。此时会类似处理内核态异常的两次处理机会分发。
- 如果发生异常的程序被调试,将异常信息发送给调试它的用户态调试器,表明这是第一次处理机会,如果没有调试器。跳过本步。
- 如果不存在用户态调试器或者调试器未处理该异常,那么在栈上放置EXCEPTION_RECORD和CONTEXT两个结构,并将控制权返回用户态ntdll.dll中的KiUserExceptionDispatcher函数,有它调用ntdll!RtlDispatchException函数进行用户态的异常处理。如果使用的是SEH异常处理机制,那么当有调试器存在,应用使用SetUnhandledExceptionFilter设置的顶级异常处理会被跳过进行下一阶段处理。没有调试器存在的时候通常会显示一个弹窗选择终止程序还是附加调试器,如果调试器处理不了异常或者没有调试器附加上,就调用ExitProcess终结程序。
- 如果ntdll!RtlDispatchException调用用户态的异常处理过程没有处理异常,那么久返回到nt!KiDispatchExcepiton,它会将异常信息再次发送给用户态的调试器,如果没有调试器,结束进程。
- 如果第二次机会调试器仍不处理,nt!KiDispatchExcepiton会再次尝试把异常分发给进程的异常端口进行处理。该端口通常由子系统进程csrss.exe进行监听。子系统监听到该错误后,显示一个错误框。
- 在终结程序之前,系统会再次调用发生异常的线程中的所有异常处理过程,这是线程异常处理所获得的清理未释放资源的最后机会,此后程序终结。
0x02 SEH的概念及基本知识
在没有调试器的参与下,系统主要靠SEH(用户模式,内核模式下均可使用)和VEH机制(仅支持用户模式)进行异常处理。
2.1 SEH的相关数据结构
2.1.1 TIB结构
TIB(Thread Information Block线程信息块)是保存线程基本信息的数据结构。在用户模式下,它位于TEB(Thread Environment Block,线程环境块)的头部,TEB是操作系统保存每个线程的私有数据创建的,每个线程都有自己的TEB,TIB定义如下。
typedef struct _NT_TIB {
struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;
PVOID StackBase;
PVOID StackLimit;
PVOID SubSystemTib;
union {
PVOID FiberData;
DWORD Version;
};
PVOID ArbitraryUserPointer;
struct _NT_TIB *Self;
} NT_TIB, *PNT_TIB;
- ExceptionList: 指向异常处理链表的指针。
- StackBase: 栈的基地址。
- StackLimit: 栈的限制地址。
- SubSystemTib: 子系统 TIB 的指针。
- FiberData 或 Version: 用于纤程(Fiber)相关的字段或版本号。
- ArbitraryUserPointer: 一个用于用户指定目的的任意指针。
- Self: 指向 TIB 自身的指针。
与异常处理相关的是其指向_EXCEPTION_REGISTRATION_RECORD的指针ExceptionList,它位于TIB的偏移0处,同时也在TEB的偏移0处。在x86平台下,Windows将FS段选择器指向当前线程的TEB数据,即TEB总是由fs:[0]指向(x64上,gs:[0]。)
2.1.2 _EXCEPTION_REGISTRATION_RECORD结构
typedef struct _EXCEPTION_REGISTRATION_RECORD {
struct _EXCEPTION_REGISTRATION_RECORD* Next;
PEXCEPTION_ROUTINE Handler;
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;
- Next:指向下一个异常处理记录的指针。它形成一个链表,用于链接多个异常处理程序。
- Handler:指向异常处理程序的指针,它是一个异常回调函数,用于处理特定类型的异常。
程序产生异常时会依次调用各个链表节点的异常处理函数。由于TEB是线程的私有数据,每个线程也都有自己异常处理链表,所以SEH机制作用范围仅限于线程。
从数据结构的角度,SEH就是一个只允许从头部添加删除节点的单向链表,且链表头部永远保存在fs:[0]的TEB结构中。
2.1.3 _EXCEPTION_POINTERS结构
typedef struct _EXCEPTION_POINTERS {
PEXCEPTION_RECORD ExceptionRecord;
PCONTEXT ContextRecord;
} EXCEPTION_POINTERS, *PEXCEPTION_POINTERS;
- ExceptionRecord:指向 EXCEPTION_RECORD 结构的指针,该结构描述了发生的异常的详细信息,如异常代码、异常地址、异常参数等。
- ContextRecord:指向 CONTEXT 结构的指针,该结构保存了异常发生时的线程上下文信息,包括寄存器的值、堆栈指针等。
在没有调试器的情况下,操作系统会将异常信息交给用户态的异常处理过程。由于同一个线程在用户态和内核态使用的是两个不同的栈,为了让用户态的异常处理程序能访问到异常相关的数据,操作系统会将EXCEPTION_RECORD和CONTEXT结构放在用户态栈中,同时在栈中放一个_EXCEPTION_POINTERS结构。
这样,用户态的异常处理程序就能获取异常的具体信息和发生异常时线程的状态信息。
2.2 SEH处理程序的安装和卸载
2.2.1 安装
push offset ExceptionHandler ; 将异常处理程序的地址推入栈中
push dword ptr fs:[0] ; 将当前的异常处理链指针推入栈中
mov dword ptr fs:[0], esp ; 将栈顶设置为新的异常处理链指针
ExceptionHandler:
; 在此处编写异常处理程序的代码
2.2.2 卸载
mov esp, fs:[0] ; 将当前的异常处理链指针加载到 eax 寄存器中
pop fs:[0] ; 将异常处理链中的下一个处理程序的地址写入异常处理链指针中
2.3 SEH跟踪实例
详细看实验。
0x03 SEH异常处理程序原理与设计
3.1 异常分发的详细过程
用户态的异常分发是从ntdll!KiUserExceptionDispatcher函数开始的。此时栈中有EXCEPTION_RECORD和CONTEXT两个结构。该函数的主要流程可以用以下代码表示。
ntdll!RtlDispatchException函数用来具体分发异常,当异常处理以后,会使用NtContinue服务恢复线程的运行,如果异常没有处理,就调用NtRaiseException函数引发第二次异常。
这里的不会返回类似jmp,所以调试器F7,F8会跟丢。
ntdll!RtlDispatchException的函数细节较为复杂,代码篇幅太长请看书。过程大致如下。
- 调用VEH异常处理,返回继续执行就直接返回,否则进行SEH。
- 遍历异常处理链表,逐一调用RtlpExcecuteHandlerForException,该函数调用SEH异常处理函数,根据返回值再进行不同的处理
- ExceptionContinueExecution,结束遍历并返回(对EXCEPTION_NONCONTINUEABLE的异常不允许再次恢复执行,会调用RtlRaiseException)
- ExceptionContinueSearch,继续遍历下一节点
- ExceptionNestedException,从指定的新异常开始遍历
- 调用VEHContinueHandler进行异常处理
3.2 线程异常处理
SEH的的整体设计思路是基于线程的,所以线程异常处理是指SEH异常处理。当异常发生时,异常现场被保存在了发生异常的线程上,系统从异常线程的TIB中取得了SEH链表的表头,并遍历查找能处理异常的处理程序。所以A线程的异常是无法被B线程捕获的。
3.2.1 线程异常处理的细节
异常处理程序返回值:
typedef enum _EXCEPTION_DISPOSITION {
ExceptionContinueExecution,
ExceptionContinueSearch,
ExceptionNestedException,
ExceptionCollidedUnwind
} EXCEPTION_DISPOSITION;
异常回调函数原型:
_EXCEPTION_DISPOSITION __cdecl _except_handler(
_EXCEPTION_RECORD* ExceptionRecord,
void* EstablishedFrame,
_CONTEXT* ContextRecord,
void* DispathcerContext
);
回调函数通过EXCEPTION_RECORD结构中的信息判断当前异常自己是否能够处理。能就根据异常原因进行修正,必要的时候修改CONTEXT结构,随后恢复运行。不能就去找下一个异常程序。
返回值的定义和含义如下:
- ExceptionContinueExecution
- 返回该值表示回调函数处理了异常,可以从异常发生处继续执行。恢复执行时,系统通过加载其传递给异常回调函数的CONTEXT相关信息来恢复线程的执行。
- ExceptionContinueSearch
- 返回该值表示回调函数处理不了该异常,需要遍历后续节点看看能不能处理。
- ExceptionNestedException
- 返回该值表示在处理该异常的时候自己发生了异常,如果这种情况出现在内核就会停止系统运行。发生在应用层系统会尝试从新异常的事发地点开始重新分发和处理嵌套异常。
- ExceptionCollidedUnwind
- 返回该值表示回调函数进行异常展开操作时再次发生了异常,展开操作可以理解为恢复发生事故的第一现场,并在恢复过程中对系统资源进行回收。
3.2.2 线程异常回调函数的嵌套及链表结构
fs:[0]总是指向最晚注册的异常回调。异常发生时,先调用该回调函数,随后看返回值。返回ExceptionContinueExecution就将控制权返回CONTEXT的EIP继续执行。如果返回ExceptionContinueSearch,从当前的EXCEPTION_REGISTRATION_RECORD结构的Next于中找到下一个EXCEPTION_REGISTRATION_RECORD结构,然后迭代下去。如果Next的值为0xFFFFFFFF,表示是最后一个节点,这个节点的handler是一个i系统设置的终结处理函数。
0x03 异常处理的栈展开
栈展开(Stack Unwind)。
3.1 什么是栈展开
传递给回调函数的EXCEPTION_RECORD结构的ExceptionFlags域有三个可选值,分别是0,1和2.0表示可修复异常,1表示不可修复异常,这种情况在应用程序中不多见,只有在异常处理中又发生了异常或系统内核发生了严重错误才可能导致,2代表展开操作。
当程序中所有的异常回调函数(包括顶层)都不处理异常,系统在终结程序之前会给发生异常的线程中所有注册的回调函数一个调用。在调用之前将EXCEPTION_RECORD结构中的Exceptionflags域置为2,将ExceptionCode置为STATUS_UNWIND(0x0C0000027)。这个回调的目的时给它们一个清理掉机会,如释放系统资源,保存遗产发生时关键变量的值等善后工作。
3.2 为什么要进行栈展开
一般情况下,只有系统终结程序之前,栈展开才会发生,其主要的目的是给程序清理未释放资源的机会。如果决定要自己处理大部分异常,并在处理后继续执行,可以参考系统的设计自己进行栈展开。给异常回调函数链表上的异常回调函数清理掉机会。进行栈展开的另一个理由是,如果不进行栈展开,就可能引发未知的错误,这取决于具体的设计实现。
如,SEH使用者习惯于在处理某些错误后转到安全地址继续运行程序。为了转到安全地址,需要保存安全的ESP指针,以便在处理错误后恢复正确的栈。通常会将安全的ESP值和EXCEPTION_REGISTRATION结构一起保存在栈中。
如果Fun1调用Fun2调用Fun3。Func3产生异常,Hanlder3,Handler2无法处理,一直到Handler1,Handler1处理了,然后EIP转到了Safe处。但是如果Safe处push破坏了Handler3或Handler2,再次发生一次就无法预料结果如何了。所以在外层回调函数改变了EIP及栈指针的情况下,为了消除潜在的错误,需要进行栈展开。步骤如下:
- 将EXCEPTION_RECORD结构中的ExceptionFlags置为2,将ExceptionCode域置为STATUS_UNWIND。并从fs:[0]开始依次调用各回调函数,到引发调用的SEH回调函数为止(不包括该回调函数)。让他们完成清理工作,包括转储必须的关键运行时参数值及错误映像,以便进行后面的调试等操作。
- 随后将fs:[0]调整为引发调用的回调函数对应的EXCEPTION_REGISTRATION结构。
3.3 如何进行栈展开
WindowsAPI函数RtlUnwind
RtlUnwind(
VirtualTargetFrame,
TargetPC,
ExceptionRecord,
ReturnValue
);
- VirtualTargetFrame:展开式,最后在SEH链停止回调函数所对应的EXCEPTION_REGISTRATION指针,大部分情况时引发调用的回调函数。
- TargetIp:调用RtlUnwind返回后应执行指令的地址。如果为0,则自然返回RtlUnwind调用后的下一条指令。
- ExceptionRecord:当前异常的EXCEPTION_RECORD结构,可直接使用在异常中传递给回调函数该参数。
- ReturnValue:返回值,通常不使用。
与其他API自动保存ebx,esi和edi寄存器不同,RtlUnwind不自动保存这些寄存器,所以使用该函数前注意保护。
书上的代码栈展开跟踪大致情况如下:
异常STATUS_ILLEGAL_INSTRUCTION前几个无法处理,到了最后的异常处理程序,它调用的Rtlnwind再次调用每一个异常处理程序去进行Unwind操作,释放一些资源,最后恢复到现状。
0x04 MSC编译器对县城异常处理的增强
各主流编译器都对SEH机制进行了扩充和增强。现实程序设计时,除了保护壳和反调试等特殊用途,基本上没有直接使用系统的SEH机制。
4.1 增强的数据结构及定义
在增强版本中,编译器对_EXCEPTION_REGISTRATION_RECORD结构进行了扩展,不同版本的编译器的具体实现不一样。VC6.0相关结构体的定义如下。
编译器所使用的是CPPEH_RECORD,_EH3_EXCEPTION_REGISTRATION结构是对SEH结构_EXCEPTION_REGISTRATION_RECORD的扩充。在原始版本上增加了四个成员(ScopeTable,Trylevel,old_esp,exc_ptr)来增强功能。
异常处理模型如下:
FilterFunc成为异常筛选器,它实际上是一个逗号表达式,可以在这里完成任何工作。通常可以调用GetExceptionCode或GetExceptionInformation函数获取异常的详细信息,来判断是否能进行处理,然后根据判断结果返回不同的值。通常由三种返回值:
#define EXCEPTION_EXECUTE_HADNLER 1
#define EXCEPTION_CONTINUE_SEARCH 0
#deifne EXCEPTION_CONTINUE_EXECUTION -1
- EXCEPTION_EXECUTE_HADNLER表示异常在预料之中,请直接执行下面的ExceptionHandler。
- EXCEPTION_CONTINUE_SEARCH表示不处理该异常,请继续寻找其他的异常处理程序。
- EXCEPTION_CONTINUE_EXECUTION表示该异常已经被修复,请回到异常现场再次执行。
这里FilterFunc的返回值和之前异常回调函数的返回值是两码事。
4.2 编译器的SEH增强设计
按照原始版本设计,每一对触发异常/处理异常都有一个注册信息,即EXECEPTION_REGISTRATION_RECORD(简称ERR结构)。每一个__try/__except(__finally)都对应着一个ERR结构,但是MSC编译器不这么设计。每个使用__try/__except(__finally)的函数,不管内部嵌套或反复使用多少次,都只注册一个ERR结构。
MSC提供一个代理函数,即将_EH3_EXCEPTION_REGISTRATION::ExceptionHandler设置成MSC的某个库函数,而不是开发人员提供的__except代码块。开发人员提供的若干个__except块被存储在_EH3_EXCEPTION_REGISTRATION::ScopeTable数组中。
在VC6.0编译生成的程序中,这个由编译器提供的异常处理函数叫做_except_handler3,它实际上是公共库的一部分,系统的每个DLL里都有这个函数的实现。根据编译设置的不同,它的具体实现可能在msvcrt.dll,kernel32.dll这样的公共库中,也可能直接内联到exe本身,但具体代码都是一样的。
4.3 编译器的实际工作
为了实现SEH增强功能,编译器在生成代码的时增加了一些工作,具体如下。
- 准备工作,将FilterFunc和HandlerFunc编译成单独的函数(FilterFunc代码块和Finally代码块尾部有retn指令;HandlerFunc的尾部有jmp指令,它跳转到Try块结束;它们都是以函数的形式被调用的),并按照Try块出现的顺序和嵌套关系生成正确的SCOPETABLE
- 对于函数中的每一个try块,编译器都会生成一个SCOPETABLE_ENTRY,并按照try块的出现顺序确定各个try块的索引,索引从0开始。在执行对应的try块前,编译器会把当前try块的索引保存到_EH3_EXCEPTION_REGISTRATION结构的tryLevel(索引)成员中。每个SCOPETABLE_ENTRY结构中的EnclosingLevel则表示:如果当前的try块未能处理异常,就找下一级try块(也就是包含了当前try块的父try块)。如果它的值时-1,则表示当前块没有父块了,当前函数已经无法处理异常了。(这里很容易想到用数组实现树的数据结构那种寻找父节点的过程)
- 在函数开头布置CPPEH_RECORD结构,并安装SEH异常处理函数。在由VC6.0编译的程序中,这个函数的名字时_except_handler3。相关数据结构布置完不,栈布局如下。
- 在进入每个try块后,先设置当前try块的tryLevel值,在执行Try块内的代码。退出Try块保护区域后,恢复为原来的TryLevel。
- 在函数返回前,卸载SEH异常处理函数。
4.4 __except_handler3函数流程解析
- 在栈上生成一个EXCEPTION_POINTERS结构,将其保存在[ebp-10]处
- 获取当前的TryLevel,判断其中值是否等于-1。若等于,表示当前不在try块中,返回ExceptionContinueSearch,继续寻找其他异常处理程序。
- 若不等于-1,根据TryLevel在ScopeTable中找ENTRY,判断是否FilterFunc为NULL,若为NULL表示时try/finally组合,由于该组合不直接处理异常,所以也返回ExceptionContinueSearch。
- 若不为NULL,根据FilterFunc返回值执行不同的行为。
- 如果异常没有被处理,最后由系统默认的异常处理函数处理,它在展开时会调用__finally块代码。
MSC编译器将SEH的安装和卸载单独提出来作为了两个函数:_SEH_prolog和_SEH_epilog。
0x05 顶层异常处理
所有在线程中没有被线程异常过程或调试器处理,最终均交给顶层回调函数处理。
5.1 顶层异常处理的由来
在创建进程的时候,每个进程的exe都有一个入口点,系统在创建进程时,先由ntdll做了一系列的准备工作,然后才从系统模块提供的启动函数开始运行。在xp系统中,进程的实际启动位置是kernel32!BaseProcessStartTrunk,然后才跳转到kernel32!BaseProcessStart,它的反汇编如下。
可以看到函数使用了__try/__ecept结构,还原FilterFunc和HandlerFunc后的C代码如下。
在使用CreateThread函数创建线程的时候,线程也不是直接从线程函数处开始运行的,它的起点是kernel32!BaseThreadStartTrunk,然后跳转到kernel32!BaseThreadStart,并且由该函数执行ThreadProc。BaseThreadStart包含的异常处理代码几乎与上面一样。它们的FilterFunc都是kernel32!UnhandledExceptionFilter。
5.2 UnhandledExceptionFilter函数
kernel32!UnhandledExceptionFilter函数(以下简称”UEF函数“)流程大致分为如下3个阶段。
5.2.1 对预定错误的预处理
- 检测当前异常中是否嵌套了异常,即异常处理的过程中是否又产生了异常。这种情况下河南恢复现场和执行后续的异常处理过程,UEF函数会直接调用NtTerminateProcess结束掉当前进程。
- 检查异常代码是不是EXCEPTION_ACCESS_VIOLATION(0xC0000005),以及引起异常的操作是不是写操作。如果是,会进一步金策要写入的内存位置是否在资源段中,然后通过改变页属性修复该错误
- 检测当前进程是否正在被调试,这是通过查询当前进程的DebugPort实现的。如果进程正在被调试,那么UEF函数会打印一些调试信息并返回EXCEPTION_CONTINUE_SEARCH,也就是不进行后续的终结处理。由于这已经是最后一个异常处理程序,该返回值会导致异常的第二次分发,如果想要调试后续的代码,就需要干预UEF的查询结果,使它认为调试器不存在。如使NtQueryInformationProcess函数返回失败,或者使ProcessDebugPort的值为0。
5.2.2 调用用户设置的回调函数
为了在UEF阶段给用户一个干预的机会,微软提供了一个API函数SetUnhandledExceptionFilter。用户设置一个顶层异常过滤回调函数,在kernel32!UnhandledExceptionFilter中会调用它并根据它的返回值进行相应的操作,平时说的“顶层异常回调函数”指的就是这个回调函数,而不是UEF函数。该API原型及参数类型定义如下:
回调函数的参数的结构有异常发生的所有线程信息。
API函数kernel32!SetUnhandledExceptionFilter实际上把用户设置的回调函数地址加密并保存在一个全局变量kernel32!BasepCurrentTopLevelFilter中,这个造成了以下两个结果。
- 不管调用API多少次,只有最后一次设置的结果是有效的,所以在同一时刻的每个进程只可能有一个有效的顶层回调函数。有些程序为了保证自己设置的异常过滤函数不会被其他模块覆盖,会在调用该函数后对其入口进行Patch,使它不再执行实际功能,这样就保证了不会有其他模块修改这个回调函数。
- 由于系统在创建用户线程时总会安装顶层异常处理过程,并把UEF函数作为异常过滤函数,所以该全局变量不仅对已经创建的线程有效,对尚未创建的线程同意有效。
UEF函数会判断用户有没有设置异常回调函数,如果设置了就会进行调用。由于实际的异常过滤函数是UEF函数,用户设置的回调函数只是它的一个子函数调用。回调函数的返回值只在某些情况下等于UEF函数的返回值。异常过滤函数三种返回值如下:
#define EXCEPTION_EXECUTE_HADNLER 1
#define EXCEPTION_CONTINUE_SEARCH 0
#deifne EXCEPTION_CONTINUE_EXECUTION -1
用户的回调函数同样可以返回这三个值。
- EXCEPTION_EXECUTE_HADNLER表示异常已经被顶层异常处理了。随后异常处理程序执行HandlerFunc,退出当前线程(服务程序)或进程(非服务程序),不会出现非法操作框。
- EXCEPTION_CONTINUE_SEARCH表示顶层异常处理过程处理了异常,程序应该从原异常指令处继续执行。
对以上两种返回值,UEF函数会直接返回。
EXCEPTION_CONTINUE_SEARCH表示顶层处理过程不能处理异常,需要交给其他异常处理过程继续处理,这一版会导致调用操作系统的异常处理过程,也就是第三阶段的终结处理过程。
5.2.3 进行终结处理
中介处理如何进行,严重依赖用户的设置,主要有如下几个步骤
- 检查应用程序是否使用APISetErrorMode设置了SEM_NOGPFAULTERRORBOX标志。如果设置了就不出现任何错误提示,直接返回EXCEPTION_EXECUTE_HANDLER结束进程。
- 判断当前进程是否在Job中。如果在且设置了有未处理异常时结束,将直接结束进程。
- 读取用户关于JIT调试器的设置,接下来的一切交给调试器处理。
- 如果不需要自动调用调试器,就加载系统目录下的faulttrep.dll以异常信息作为参数调用它的ReportFault函数。
ReportFault函数会读取用户关于错误报告的设置,并根据设置的不同,以及是否设置了JIT调试器,调用DwWin.exe弹出不同类型的提示窗口。
5.3 WindowsVista后顶层异常处理的变化
WindwosVista后,线程的实际入口变成了ntdll!RtlUserThreadStart。该函数直接跳转到ntdll!_RtlUserThreadStart,其内部调用了RtlInitializeExceptionChain函数,该函数与SEHOP保护机制有关。
线程的起始地址变成了ntdll.dll中的地址,所以顶层异常处理也是由ntdll.dll来安装和执行的,但是设置回调函数的API仍然是kernel32!SetUnhandledExceptionFilter。
从WindowsVista开始,UEF的设置变成了两级设置,相应的由两级接口,分贝是kernel32!SetUnhandledExceptionFilter和ntdll!RtlSetUnhandledExceptionFilter。由于ntdll.dll接口不是公开的,用户仍然只能能用kernel32的API设置顶层回调函数。kernel32.dll在加载的时候会将调用ntdll!RtlSetUnhandledExceptionFilter将回调函数设置成自己模块内的UnhandledExceptionFilter函数。因此异常到达顶层异常处理程序后,会执行ntdll.dll中的filterFunc,而他会继续调用kernel32!UnhandledExceptionFilter。kernel32!UnhandledExceptionFilter再调用用户设置的顶层异常回调函数。如果某个Native程序没有加载kernel32.dll,那么ntdll.dll本身忍让然会提供一个与kernel32!UnhandledExceptionFilter功能相似的ntdll!RtlUnhandledExceptionFilter函数来完成终结处理功能。
5.4 顶层异常处理的典型应用模式
一般到顶层都处理不了,所以将异常现场的所有信息保存下来形成一个快照文件(这个快照文件叫做dump文件)
0x06 异常处理程序的安全机制
6.1 SafeSEH机制
6.1.1 编译器工作
VS .NET2003开始,编译PE文件的时候加入了一个SafeSEH开关,如果打开了开关,就会在PE头的DllCharacteristic中加入一个标志,并在编译阶段提取所有异常处理程序的RVA,放入一个表。
PE文件载入时,PE的基址,大小,SEHandlerTable(表格的地址),SEHandlerCount(长度)会保存在ntdll.dll的一个表格中。
6.1.2 操作系统的验证
系统在对栈及栈中的EXCEPTION_REGISTRATION_RECORD结构进行初步验证后,会调用RtlIsValidHandler对异常回调函数的有效性进行验证。伪代码如下:
6.2 SEHOP机制
6.2.1 初识SEHOP
核心检测包括以下两点
- 检查SEH链完整性,即每一个节点都必须在栈中,并且都可以正常访问
- 检查最后一个节点是不是位于ntdll中的ntdll!FinalExceptionHandler
6.2.2 开启SEHOP功能
6.2.3 SEHOP与顶层异常处理
SEHOP保护之后,SEH链的最后增加了一个节点,倒数第二个是顶层异常处理。
文章评论