关于windows异常处理的探究。
异常执行流程
CPU检测到异常 -> 查中断表执行处理函数 -> CommonDispatchException -> KiDispatchException -> KiUserExceptionDispatcher -> RtlDispatchException ->VEH -> SEH
异常产生后,首先是要记录异常信息(异常的类型、异常发生的位置等),然后要寻找异常的处理函数,我们称为异常的分发,最后找到异常处理函数并调用,我们称为异常处理。
异常的分类
CPU产生的异常
软件模拟产生的异常
CPU异常
1 | CPU指令检测到异常(例:除0) |

找到IDT表的0号中断

首先保存现场

然后向下走,但是并没有直接异常处理的代码,这里有一个跳转跟进去。为什么操作系统没有直接将异常处理写进去,这是因为操作系统希望我们自己首先能够将异常给处理掉

跟进去后发现调用了CommonDispatchException函数

CommonDispatchException主要是把一些异常的信息存储到了自己的结构体_EXCEPTION_RECORD里面,结构如下
1 | type struct _EXCEPTION_RECORD |

然后通过KiDispatchException去找到异常处理函数

在前面的跳转中,带过去了两个寄存器eax、ebx,eax我们可以发现它的值为0c000094,这个值是操作系统定义的


然后再是ebx,ebp指向的是_Trap_Frame结构体的栈顶,+68指向的就是eip


这两个值就对应了结构里面的ExceptionCode和ExceptionAddress

再看ExceptionFlags,CPU导致的异常这个值为0,软件调试导致的异常这个值为1

CPU异常执行的流程:
1、CPU指令检测到异常
2、查IDT表,执行中断处理函数
3、调用CommonDispatchException(构建EXCEPTION_RECORD)
4、KiDispatchException(分发异常:目的是找到异常的处理函数)


模拟异常记录
调用过程
1 | CxxThrowException |
首先手动抛出异常

然后去到返回表发现调用了CxxThrowException

CxxThrowException
1 | __CxxThrowException@8: |
该代码所做的事情如下:
① 先从内存中拷贝一段0x20字节的固定结构体到堆栈中;
② 将ExceptionList也拷贝到堆栈中(该结构体内部)
③ 传入有关参数调用RaiseException函数。
注意,ThrowCode虽然从用户代码传入进来,但分析其函数并没有用到,而是直接调用一段固定的异常码。而&ThrowCode以及异常链被作为其参数存储,这样通过分析就可以轻易找到其ThrowCode值,其作为参考之后来处理SEH。

RaiseException
跟进去调用了Kernel32.dll的RaiseException


这里跟CPU异常不同的是,CPU异常会将错误代码跟着寄存器一起传入,但是软件异常并没有,这里看一下

这里的edx为E06D7363就是软件调试的错误代码,这里注意,随着语言和版本的不同,这里的EDX即错误代码并不固定,取决于编译环境

第二个差异就是CPU异常存储的是发生异常的地址,软件异常则是存储RaiseException函数的地址


RtlRaiseException
该函数分析如下,其在三环进入零环前在堆栈中保存了一个_CONTEXT结构体,我们之前在用户APC分析过,返回三环时要在零环向三环堆栈中写入一个CONTEXT用于保存。
来自用户层的异常我们确定其必须返回,则进零环之前就直接在三环中预先保存了一个_CONTEXT结构体,至于其如何使用,我们在异常的处理中会详细分析到。
这里我们要关注CONTEXT几个比较重要的点:eip与esp,因为其是程序的重要落脚点,发现其是该函数的返回地址与上一个函数的堆栈图(本质就是kernel!RaiseException调用时的现场)

上面我们经过分析ntdll!RtlRaiseException,发现其调用ZwRaiseException函数进内核,其对应nt!NtRaiseException函数,其分析如下图
该函数如下所示,其中值得注意的一点是:KThread.TrapFrame保存着TrapFrame.ebx,进零环时,mov edx,esp,其esp保存着call的返回地址,即ebx保存着三环栈顶,也是三环的返回地址

KiRaiseException
EXCEPTION_RECORD.ExceptionCode最高位清零 用于区分CPU异常
调用KiDispatchException 开始分发异常

KiRaiseException 可以被看作 “处理前的最后一次异常记录”,异常记录的目的是:完善异常处理信息,在派发时根据这些信息来进行处理。
无论是三环异常,还是零环异常,进入内核中都会经过这里。因此对于两者有不同的记录处理。
如下图,对于三环,多了一个部分,备份CONTEXT_FRAME与EXCEPTION_RECORD

我们之前在三环生成了ExceptionRecord与Context,但是我们要在零环使用,其如何使用的呢?
在nt!KiRaiseException函数中调用完成的两者的复制,之后将Context转换为TrapFrame其KiDispatchException要使用。
之前我们存在一个疑问,为什么不能使用三环进零环的一个TrapFrame而非得从三环拿过来一个Context转换?
猜想:因为该TrapFrame必须是异常现场的TrapFrame,而CPU中断直接调用IDT异常表很容易保存,但是用户模拟的必须是从三环到零环的,其TrapFrame是关于系统调用的。
因此必须调用ntdll!RtlRaiseException三环保存的Context,这是直接记录异常现场的
备注:在调用KiDispatchException上方,其存在一句代码:ExceptionCode &= EFu,其表示将用户模拟异常的位置0,因此用户模拟异常最高位不可能为0,CPU异常,比如c0000094,区分。

CPU/模拟异常调用过程

内核层异常处理流程
前面我们分析过,存在两种异常,CPU异常与用户模拟异常,其异常触发时收集的线路是不同的,但是其最终走经过KiDispatchException函数。
当走到KiDispatchException,CPU异常与用户模拟异常唯一的区别是CPU异常最高位置1(nt!KiRaiseException异常派发时的上一行代码),其余记录的都是一样的。
而KiDispatchException的处理是按照其先前模式来处理的,也就是内核异常与用户异常两种,而不是按照CPU异常与用户模拟异常来进行处理。
1 | 1) _KeContextFromKframes 将Trap_frame备份到context 为返回3环做准备 |
KiDispatchException
首先定位到KiDispatchException函数

首先备份Trap_Frame结构,如果是用户层的异常则需要返回3环堆栈

首先通过判断先前模式的值来识别是内核异常还是用户层异常,这里有一个是否第一次调用该函数的判断,这是因为这个函数会被调用很多次,如果不是第一次调用则直接跳转

这个函数的最后一个参数就是表示这个函数是第几次被调用

然后继续判断有没有内核调试器的存在(如windbg)

如果有内核调试器的存在就走下面的KiDebugRoutine函数

如果内核调试器没有处理返回失败的话就跳转

RtlDispatchException调用异常处理函数

跟进到RtlDispatchException

又调用了RtlGetRegistrationHead

跟进去发现取的是fs:[0]

_EXCEPTION_REGISTRATION_RECORD
我们知道0环的fs:[0]指向KPCR,KPCR的第一个结构是_NT_TIB,_NT_TIB的第一个成员是ExceptionList,是一个_EXCEPTION_REGISTRATION_RECORD类型的结构体

_EXCEPTION_REGISTRATION_RECORD结构如下
1 | typedef struct _EXCEPTION_REGISTRATION_RECORD { |
_EXCEPTION_REGISTRATION_RECORD里面有两个成员,*Next是一个指针指向下一个_EXCEPTION_REGISTRATION_RECORD结构,而第二个成员Handler指向的就是一个异常处理函数
RtlDispatchException的作用如下:
遍历异常链表,调用异常处理函数,如果异常被正确处理了,该函数返回1
如果当前异常处理函数不能处理该异常,那么调用下一个,以此类推。
如果到最后也没有处理这个异常,返回0。

调用异常处理函数得到返回值后跳转到地址

然后判断返回值是否为1,1的话就是处理成功,跳转

异常被处理成功则把Context结构放回Trap_Frame里面

如果没有被处理成功则继续往下走进行有无内核调试器的判断,如果有内核调试器则调用KiDebugRoutine

如果没有内核调试器或者有内核调试器但是没有处理异常,则跳转到下面的地方

操作系统蓝屏

伪代码分析

用户层异常处理流程
定位到KiDispatchException,进入用户异常的函数

进入函数首先判断是不是第一次调用,然后继续往下走,如果有内核调试器则直接跳转,没有的话继续往下走

然后进行异常的处理,调用DbgkForwardException,这个函数的作用是调用3环的调试器 ,再进行判断有无3环的调试器接收异常,如果没有则返回3环处理

然后进行结构体的修改,这里同用户APC执行的修改过程

然后修改EIP的值为KeUserExceptionDispatcher函数的地址,这时候EIP的值已经是函数地址,这时候再回到原函数


注意这里并没有直接进行返回3环的操作,而是KiDispatchException这个函数执行结束过后回到原函数,用不同的方法回到3环
1 | CPU异常:CPU检测到异常 -> 查IDT执行处理函数 -> CommonDispatchException <-> KiDispatchException |
无论通过那种方式,但线程再次回到3环时,将执行KiUserExceptionDispatcher 函数
1)其调用内核调试器还是存在条件的:
存在KiDebugRoutine;DebugPort不为空;并且如果是int3断点异常其必须所带的第一个参数不为零,这才会引发内核调试器。
其实你在实际操作的情况也有这种感觉,明明开着内核调试器windbg,但OD等操作windbg却接收不到,就是这个KdIsThisAkdTrap函数作怪。
2)构造三环栈中数据返回三环:
如下图所示,其存在四次复制,赋值的信息比较明显,形成的堆栈图也比较清晰,其返回三环是ntdll!KeUserExceptionDispatcher函数,其负责对异常的处理。

KiUserExceptionDispatcher
返回三环后,可以看到其调用一个RtlDispatchException。注意,在处理内核异常时,也有一个同名的RtlDispatchException,那是内核模块,这是三环模块。
RtlDispatchException可以认为是异常的核心,区别是如果在内核模块,则处理零环,如果在ntdll模块,则处理三环。

RtlDispatchException
其处理两种异常,一种VEH异常,一种SEH异常。VEH异常相当于一个全局变量的异常链表,其通过全局变量查找该张表。SEH异常相当于局部异常,其Try··catch··就是向这里面添加异常,TEB、KPCR第一个成员都是这个ExceptionList。
