Windows内核读书笔记——Windows异常分发处理机制

本篇读书笔记主要参考自《深入解析Windows操作系统》和《软件调试》这两本书。

IDT是处理异常,实现操作系统与CPU的交互的关口。

系统在初始化阶段会去填写这个结构。

IDT的每一个表项都成为门描述符,因为IDT的功能就像大门一样,从一个空间跳到另一个空间去执行。

IDT中包含三种门描述符

  • 任务门描述符:用于任务切换
  • 中断门描述符:用于描述中断处理例程
  • 陷阱们描述符:用于描述异常处理例程

CPU如何使用IDT

cpu首先根据IDTR找到IDT,再利用向量号码找到门描述符。再去判断门描述符的类型,如果是任务描述符,CPU会执行硬件方式的任务切换,切换到描述符所定义的线程。

但是CPU的PCR结构中也会保存有IDT的基地址,这个用处我没搞懂,可能与内核初始化流程有关系,以后再来研究。

如果是陷阱或中断描述符,那么会去调用处理例程。X64架构不支持硬件方式的任务切换,也就不再存在任务门了。

 

在调用处理例程之前,CPU会把EFLAGS、CS、EIP压入栈,如果发生的异常有错误代码则把错误代码也压入堆栈。

这个过程就是所谓的构建陷阱帧(Context)和异常记录(EXCEPTION_RECORD)

windows系统使用EXCEPTION_RECORD结构描述异常。

1 typedef struct _EXCEPTION_RECORD {
2   DWORD                    ExceptionCode;//异常代码
3   DWORD                    ExceptionFlags;//异常标志
4   struct _EXCEPTION_RECORD  *ExceptionRecord;//相关的另一个异常
5   PVOID                    ExceptionAddress;//异常发生的地址
6   DWORD                    NumberParameters;//参数数组中的参数个数
7   ULONG_PTR                ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];//参数数组
8 } EXCEPTION_RECORD, *PEXCEPTION_RECORD;

异常代码,可以认为是异常的别称,有的异常有异常代码有的则没有。

在经过中断或异常后会调用IDT中的处理例程比如KiTrap03,这个处理例程会调用CommonDispatchException函数。

CommonDispatchException函数会在栈中生成一个EXCEPTION_RECORD结构,把当前的数据情况写入到这个结构中。

然后会以这个结构为参数调用KiDispatchException来分发异常。

 

上面说的是硬件异常,接下来看看软件异常,软件异常是通过直接或间接调用内核函数NtRaiseException产生的,就是说软件异常不需要经过IDT分发等等这些东西只需要调用函数,而且用户层也可调用这个函数,用户层导出了一个RaiseException函数在kernel32.dll中,供用户产生一个自定义的异常。

RaiseException函数的实现原理是把相应的参数放入EXCEPTION_RECORD结构中,调用RtlRaiseException函数,这个函数把当前的线程上下文放入CONTEXT结构中,然后调用NtRaiseException。

NtRaiseException内部实现函数是KiRaiseException,我们看下面KiRaiseException的源码会发现这个函数还是调用 KiDispatchException来实现功能的。

 1   //来自WRK1.2
 2    NTSTATUS
 3    KiRaiseException (
 4        IN PEXCEPTION_RECORD ExceptionRecord,//异常记录
 5        IN PCONTEXT ContextRecord,//线程上下文结构
 6        IN PKEXCEPTION_FRAME ExceptionFrame,//不使用,为空
 7        IN PKTRAP_FRAME TrapFrame,//栈帧基地址
 8        IN BOOLEAN FirstChance//表示是第一轮还是第二轮处理
 9       )
10   
11 {
12     //省略了部分内容
13     //把ContextRecord复制到当前线程的内核栈中
14     KeContextToKframes(TrapFrame,
15                        ExceptionFrame,
16                        ContextRecord,
17                        ContextRecord->ContextFlags,
18                        PreviousMode);
19     //把异常记录中的异常代码最高位清零,这样可以分辨出软件异常和CPU异常
20     ExceptionRecord->ExceptionCode &= ~KI_EXCEPTION_INTERNAL;
21     //调用分发异常的函数
22     KiDispatchException(ExceptionRecord,
23                         ExceptionFrame,
24                         TrapFrame,
25                         PreviousMode,
26                         FirstChance);
27 
28     return STATUS_SUCCESS;
29 }

 所有说无论是CPU异常还是软件异常都会调用KiDispatchException来分发异常。

 

异常分发过程

在Windows内核方面的圣经《Windows Internals》中有一部分章节是介绍Windows系统异常分发机制的,其实那一章节的内容就是在讲述KiDispatchException函数的处理流程(虽然作者根本没有提到这个函数,因为Windows内核是不开源的,微软也没有提供手册)。

第一次看《Windows Internals》时,我还没有读过这个函数的实现。当时对书里的各种概念如二次调试、寻找调试器等等一脸懵逼。但是看了这个函数之后会发现真正的处理流程是很清晰的。

首先异常有两大类,发生在内核态下的异常和发生在用户态下的异常。为什么要分两种,这两者是不一样的。

1    if (PreviousMode == KernelMode) 
2    {
3         //……
4    } 
5    else 
6    {
7         //……
8    }

函数一开始就把执行的流程完全的分成两部分了,可见内核异常与用户异常的分发是你走你的阳关道我走我的独木桥,两者是毫不关联的。

至于为什么要分为两个完全不同的流程,我想了一下用户态异常分发和内核态异常分发的相同点和不同点。

首先说说相同点:

1.都是基于二次调试机制

没有接触过异常分发的读者可能不明白什么叫二次调试机制。其实我们平时使用OD或是Windbg处理异常时就已经接触到二次调试机制了。比如,你用OD加载一个用户态程序,你就会接收到两次中断到调试器。其实这个是很常见的,尤其是以前玩CrackMe的时候经常可以碰到用触发异常来进行反调试的。这个时候如果你使用OD来忽略异常要连续忽略两次,这就是二次调试。

下面来做一个实验。

很简单的一个程序

1 #include "stdio.h"
2 
3 int main()
4 {
5     int num = 10 / 0;
6     printf("%d", num);
7     return 0;
8 }

触发一个除零异常

VS2015编译不了这个,因为把除零视为错误了,不知道该怎么关这个保护机制。于是用C-Free 5编译了一下。运行后就是标准的Windows异常窗口,问你要不要挂载调试器啊什么的,这个选项是可以在注册表里面改的,下面也会研究一下这个标准的Windows异常窗口是哪里来的。这里先回归主题说一下二次调试的事情。

OD设置不忽略所有的异常,注意有SrongOD插件的要把插件关了。这样OD就不会忽略异常了,如果你用shift+F8来跳过这个异常,你会发现你要按两次shift+F8这个就是异常的二次调试。其实用Windbg可以更直观的看到二次调试的现象,用Windbg加载这个示例程序,按G继续,可以看到如图的情况

这个就是二次调试。正如我们所说的,这是内核态异常分发和用户态异常分发的共同点,两者都会有二次调试。我个人认为这是一种保护机制,是一种“再给一次机会”的机制,就算没有这个二次调试机制也是无关大局的。

2.都在寻找调试器

我们知道异常是无法被“自动”处理的,举个例子,以上面那个例子程序来说,如果你不是手动指定异常处理代码(如__try)的话,那么异常是不可能自己消失的。就是说系统不会自己改错,当然也有例外的情况,比如说缺页异常就是自己处理的,应该是在中断处理例程中就处理完毕了,不会进行异常分发。但是那个是系统故意留的,根本不能称作是真正意义上的异常。

那么,所谓的异常分发就是一个寻找调试器和异常处理代码的过程。这个才是本质,也是分发的目的,找不到就进行暴力结束机制。因为不可能带着错误继续运行下去。

 

相同点说完了,那么重点就是不同点了。

不同点:

1.CPU模式的切换

我们前面说了,无论是在用户态触发异常还是在内核态触发异常都会调用到这个KiDispatchException函数,就是说当用户态触发异常后需要转入内核态。这好像是一句废话,因为很多函数都是要从用户态转入内核态的啊。比如用户层的CreateFile就会到内核层的NtCreateFile函数。但是对于用户态异常分发来说却不是这么简单,因为要执行用户态的用户指定的异常处理代码,所以进入这个KiDispatchException函数后还要再转回用户态去寻找用户态的异常处理代码。而对于内核异常分发来说是不需要CPU执行状态转来转去。

2.寻找调试器的函数不同

一个是寻找内核调试器的,一个是寻找用户态调试器的。这两者本质不同。

 

这个就是一个完整的流程图,来自《软件调试》

posted @ 2016-04-10 16:48  Ox9A82  阅读(1661)  评论(0编辑  收藏  举报