Windows异常处理机制

1.异常分类:

       异常是CPU或者程序发生的某种错误,异常处理就是异常产生之后Windows对于产生的错误的一段处理程序。异常分为硬件异常和软件异常。

1.1 硬件异常

      硬件异常是由CPU发现的异常,比如说除零异常、内存访问异常,经常看到的错误信息类似于“0xXXXXXX指令引用的0xXXXXX内存,该内存不能为read”等。硬件异常可以分为三种:

  • fault,在处理此类异常时,操作系统会将遭遇异常时的“现场”保存下来,比如EIP、CS等寄存器的值,然后将调用相应的异常处理函数。如果异常处理成功,则恢复到原始现场、继续执行。
  • trap,在处理此类异常时,操作系统会将异常的下文保存,在处理异常后,直接执行导致异常的指令的下一条指令。例如调试使用的断点操作就是基于该类异常,当下断点时调试器会将原本指令此处对应的十六进制保存下来,然后替换第一个字节替换为0xCC的,造成异常中断。
  • abort,中止异常,主要是处理严重的硬件错误等,这类异常不会恢复执行,会强制退出。

       在windows系统中,硬件异常和中断被不加区分的存放在了一个向量表中,即IDT(interruption descriptor table),可以使用windbg的!idt指令查看IDT。下表中前面序号代表是第几个中断或异常,后面函数则是对这种异常或终端的处理函数,也即异常处理例程:

       真正的IDT是维护了多个门描述符(GD),每一项大小为8(64位为16),IDRT寄存器中保存着IDT的基地址,可利用IDTR+8*offset即可GD大致由segment selector(选择段子)、offset(选定段后的偏移)、DPL(描述符特权级)、P(段是否存在)组成。

       当windows系统启动时,winLoad辉在实模式下分配一块内存,使用CLI指令来禁止中断的使用,利用LIDT(Load IDT)指令将IDT表的位置和长度等信息交给CPU,接着系统恢复保护模式,这时的执行权交还给了入口函数,调用SIDT(set IDT)拿到之前存储的IDT的信息,并将其记录到PCR中,接着其他处理器也会进行初始化的操作。复制并修改自己的IDT,在一切准备就绪后,调用STL指令恢复中断的使用,调用链接如下:

winLoad -> kiSystemStartup -> kiInitializePcr ->keStartAllProcessors -> kiInitProcessors

1.2 软件异常

       由操作系统或应用程序抛出的异常,即异常不是由CPU触发的。比如C++关键字throw、Windows API函数的RaiseException。这类异常都是基于RaiseException这个用户态API和NtRaiseException的内核服务建立起来的。RaiseException的函数原型:

  1. void RaiseException(DWORD dwExceptionCode , DWORD dwExceptionFlags,DWORD nNumberofArguments,const DWORD* lpArguments);  

       dwException是异常状态码,可以在NtStatus.h中找到,应用程序也可以有自己的异常状态码。nNumberofArguments和lpArguments是用来定义异常的数据。

 

       函数会将异常的相关信息传入一个维护异常的结构,即EXCEPTION_RECORD,然后再去调用RtlRaiseException函数,结构如下:

typedef struct _EXCEPTION_RECORD {  
  DWORD                    ExceptionCode;      //异常状态码  
  DWORD                    ExceptionFlags;     //异常标志位  
  struct _EXCEPTION_RECORD *ExceptionRecord;   //指向下一个异常的指针  
  PVOID                    ExceptionAddress;   //异常的发生地址  
  DWORD                    NumberParameters;   //ExceptionInformation数组中参数的个数  
  ULONG_PTR                ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];    //异常的描述信息  
} EXCEPTION_RECORD; 

       之后调用的RltRaiseException会将当前的上下文保存到CONTEXT结构中,此后调用的函数会维护一个TrapFrame(即栈帧的基址)和异常的处理次数的标志,调用链如下:

  1. 用户:RaiseException -> RltRaiseException -> NtRaiseException -> KiRaiseException 
  2. 内核:RtlRaiseException -> NtRaiseException -> KiRaiseException  

2. 异常产生:

       硬件异常产生是当CPU尝试执行指令时检查到的问题,当异常产生时,CPU查询中断处理表,找到异常处理的函数(_KiTrapXX之类)-> KiTrapXX函数里面调用CommonDipatchException-> 调用KiDispatchException进行异常分发。

       软件异常的产生调用RaiseException->包装异常->NtRaiseException->进入内核-> 调用nt!NtRaiseException-> 调用KiDispatchException进行异常分发;其中包装异常的意思就是,因为这个异常是模拟的,所以这个异常的具体信息需要由程序自己来填充。

3.异常分发:

        硬件异常会通过IDT去调用异常处理例程(一般为KiTrap系列函数),而软件异常则是通过API的层层调用传递异常的信息,实际上二者最后都会靠KiDispatchException函数来进行异常的分发。

KiDisPatchException函数的函数原型如下:

void KiDispatchException (  
    IN PEXCEPTION_RECORD ExceptionRecord,  //描述异常的结构  
    IN PKEXCEPTION_FRAME ExceptionFrame,     
    IN PKTRAP_FRAME TrapFrame,              //描述发生异常时的上下文  
    IN KPROCESSOR_MODE PreviousMode,       //说明异常来自内核还是用户态  
    IN BOOLEAN FirstChance                  //说明异常是否是第一次处理  
    )  

3.1 内核异常分发

       当内核产生异常时,程序处理流程进入到KiDispatchException函数,在该函数内备份当前线程R3的TrapFrame(注意当处理完毕R3异常的时候再次调用NtContinue需要此备份的数据)。异常处理首先判断这是否是第一次异常,判断是否存在内核调试器,如果有内核调试器,则把当前的异常信息发送给内核调试器,如果没有内核调试器或者内核调试器没有处理该异常,那么则调用RtlDispatchException函数进行异常处理。如果RtlDispatchException函数没有处理该异常,那么将再次尝试将异常发送到内核调试器,如果此时内核调试器仍然不存在或者没有处理该异常,那么此时系统会直接蓝屏。

BOOLEAN RtlDispatchException(PEXCEPTION_RECORD ExceptionRecord,PCONTEXT ContextRecord)  

        RtlDispatchException函数内部通过fs:[0]来获取关于当前线程的_KPCR,它的成员_NT_TIB::ExceptionList里面存放的是当前线程的异常处理函数链表;原型如下(代码来自ReactOS):

typedef struct _KPCR {  
  union {  
    NT_TIB NtTib;  
    struct {  
      struct _EXCEPTION_REGISTRATION_RECORD *Used_ExceptionList;  
      PVOID Used_StackBase;  
      PVOID Spare2;  
      PVOID TssCopy;  
      ULONG ContextSwitches;  
      KAFFINITY SetMemberCopy;  
      PVOID Used_Self;  
    };  
  };  
  struct _KPCR *SelfPcr;  
  struct _KPRCB *Prcb;  
  KIRQL Irql;  
  ULONG IRR;  
  ULONG IrrActive;  
  ULONG IDR;  
  PVOID KdVersionBlock;  
  struct _KIDTENTRY *IDT;  
  struct _KGDTENTRY *GDT;  
  struct _KTSS *TSS;  
  USHORT MajorVersion;  
  USHORT MinorVersion;  
  KAFFINITY SetMember;  
  ULONG StallScaleFactor;  
  UCHAR SpareUnused;  
  UCHAR Number;  
  UCHAR Spare0;  
  UCHAR SecondLevelCacheAssociativity;  
  ULONG VdmAlert;  
  ULONG KernelReserved[14];  
  ULONG SecondLevelCacheSize;  
  ULONG HalReserved[16];  
} KPCR, *PKPCR;  
   
typedef struct _NT_TIB {  
  struct _EXCEPTION_REGISTRATION_RECORD *ExceptionList;  
  PVOID StackBase;  
  PVOID StackLimit;  
  PVOID SubSystemTib;  
  _ANONYMOUS_UNION union {  
    PVOID FiberData;  
    ULONG Version;  
  } DUMMYUNIONNAME;  
  PVOID ArbitraryUserPointer;  
  struct _NT_TIB *Self;  
} NT_TIB, *PNT_TIB;  
   
// 异常处理函数结构体  
typedef struct _EXCEPTION_REGISTRATION_RECORD  
{  
  struct _EXCEPTION_REGISTRATION_RECORD *Next;   // 指向下一个异常处理结构  
  PEXCEPTION_ROUTINE Handler;                    // 异常处理函数  
} EXCEPTION_REGISTRATION_RECORD, *PEXCEPTION_REGISTRATION_RECORD;  

       Prcb是指Process Control Block,实际上在操作系统将IDT的信息交付给PCR的过程中,也会交给它。IRQL也就是中断请求级别,0代表当前cpu的IRQL是内核态。IDT和GDT分别是前面提到的两个表的地址。TSS是任务段地址。CurrentThread也就是当前线程的EThread地址。NextThread是下一个准备执行的线程的地址。idleThread是一个优先级最低的线程,也可以把它叫做空闲线程,可以简单理解为它是个在“休息”的线程。

3.2 用户异常的分发

内核态的异常处理函数在内核中,用户态的异常处理函数在用户态中。当用户态异常经过KiDispatchException时,则需要切换到用户态的代码进行异常的分发和处理。

用户态异常的分发更加复杂,主要原因是处理此异常需要先切换到用户空间,然后交由用户层的异常代码再次进行分发,流程如下:

  1. 检查当前是不是第一次分发该异常,如果异常第一次没有被处理,那么第二次就不会再调用异常处理程序,而是直接尝试将异常发给调试器;
  2. 如果当前是第一次分发该异常,那么便尝试将异常发给内核调试器;
  3. 如果内核调试器不存在或者没有对该异常进行处理,那么则尝试将异常发送给用户态调试器;
  4. 如果用户态调试器不存在或者没有处理该异常,那么此时便准备一个返回ntdll!KiUserExceptionDispatcher函数的应用层调用栈,准备产生的异常的数据,然后结束本次KiDispatchException函数的运行。因为函数结束之后,会调用KiServiceExit返回用户层,此时当前的TrapFrame就是准备好的用户执行ntdll!KiUserExceptionDispatcher的环境,所以当从内核退出时,用户态线程便会从执行ntdll!KiUserExceptionDispatcher开始执行;
  5. ntdll!KiUserExceptionDispatcher调用ntdll!RtlDispatchException进行异常的分发,此处的流程和内核态nt!RtlDispatchException流程基本一致;
  6. 通过RtlCallVectoredExceotionHandlers遍历VEH链表尝试查找异常处理函数;
  7. 如果VEH没有处理函数处理该异常,则从fs[0]读取ExceptionList并开始执行SHE的函数处理;
  8. 如果到最后仍然没有处理该异常,这时便会再次主动调用NtRaiseException将该异常重新抛出来,但是此时就不是第一次机会了,此时NtRaiseException流程重新调用了nt!KiDispatchException,并再次进入用户态异常的处理分支,但是此时不再是第一次异常处理,所以此次异常不会再次发送给用户态进行分发,而是再次尝试将异常发给用户调试器(此时不会再次将异常发送给内核调试器),此时有两次机会可以让用户态调试器进行异常的处理,最后如果此异常仍然没有被用户态调试器处理,那么nt!KiDispatchException便会调用ZeTerminateProcess直接结束该进程;
  9. 如果在上一步有异常处理程序处理了该异常,那么便会调用NtContinue,将之前保存的TrapFrame还原;
  10. 当函数从NtContinue返回时,就会根据上面函数的处理结果继续执行。

应用层异常的分发可能存在找不到异常处理方案,但是应用层和内核最大的区别在于,如果内核层发生的异常没有被正确执行,那么此时就会产生蓝屏。但是用户层的异常如果找不到异常处理程序处理该异常,那么最终Windows系统会根据当前系统的设置,调用UnhandledExceptionFilter的函数,这个函数被调用之后,要么弹出一个错误框,要么启动一个调试器。也即无论如何都会存在一个异常处理函数来接管最后的异常。

4. SEH和VEH

4.1 SEH

SEH是比较特殊的异常处理链表,全名为Structed Exception Handler,SEH的注册结构体只能作为局部变量存在于当前线程的调用栈,如果一旦结构体的地址不在当前调用栈的范围中,则在进行异常分发时,将不会进入该函数。SHE描述结构的注册随着函数的调用而注册,随着函数的结束而注销。其结构如下:

typedef struct _EXCEPTION_REGISTRATION_RECORD  
{  
  struct _EXCEPTION_REGISTRATION_RECORD *Next;   //下一个SHE节点  
  PEXCEPTION_ROUTINE Handler;                     //处理该异常的函数,异常回调函数  
}EXCEPTION_REGISTRATION_RECORD  

该链表只允许头节点而该链表只允许在头节点来进行删除和增添操作,且FS的0一直指向头节点,这就说明,越新的函数越接近头节点,系统会维护链表最后的next指向0xFFFFFFFF。

SEH是基于线程的一种处理机制,且依赖于栈进行存储和查找,所以被称作是基于栈帧的异常处理机制。SEH装载代码如下:

push offset SEHandler

push fs:[0]

mov fs:[0],esp

先向栈中压入Handler和当前的节点,构成一个EXCEPTION_REGISTRATION_RECORD结构,而esp指向栈顶,正好就是新的EXCEPTION_REGISTRATION_RECORD,将他付给fs:[0]就是让SEH的头节点变成刚加入的新节点。SEH卸载过程即为恢复栈平衡的代码:

mov esp,dword ptr fs:[0]

pop dword ptr fs:[0]

SEH异常的安装实际上从main函数之前就开始了,当我们在启动一个进程时,实际的启动位置也就是kernel!32BaseProcessStartThunk,而在这个函数内就已经开始有try、catch结构了,线程的启动函数kernel!32BaseThreadStart也是如此。

VOID BaseThreadStart(PTHREAD_START_ROUTINE pfnStartAddr, PVOID pvParam) {  
    __try{  
        ExitThread((pfnStartAddr)(pvParam));  
    }  
    __except (UnhandledExceptionFilter(GetExceptionInformation())){  
        ExitProcess(GetExceptionCode());  
    }  
}  

实际上这里的try catch结构构成的异常回调函数就是top level,即顶层异常处理,它们也是SEH链的最后一部分,并且可以看到,它们的except还存在一个叫做UnhandledFilter函数,和字面上的意思相似,这是用来实现异常过滤的函数。

当异常分发到RtlDispatchException函数时,会根据线程注册的SEH来处理该异常,其伪代码如下:

if VEH异常处理例程 != Exception_continue_search  
    goto end_func  
  
else  
    limit = 栈的limit  
    seh = 借助FS寄存器获取SEH的头节点  
    while(seh!=-1):  
        if SEH节点不在栈中 || SEH节点位置没有按ULONG对齐 || Handler在栈中  
            goto end_func  
        else  
            seh = 当前seh指向的下一个seh  
    seh = 借助FS寄存器获取SEH的头节点  
    while(seh!=-1):  
        if(检查safeseh)  
            goto end_func  
        else  
            return_value = 执行该seh的handler  
            switch(return_value):  
                case 处理成功:  
                    flag=1  
                    goto end_func  
                case 没法处理:  
                    seh = 当前seh指向的下一个seh  
                case 处理时再次遭遇异常  
                    设置标记,做内嵌异常处理  
                    goto    end_func  
end_func:  
    调用VEH的continue handler  
    return  

其概括步骤如下:

  • 调用VEH ExceptionHandler进行处理,成功则结束,否则进行SEH
  • 遍历SEH节点,对每一个Handler进行RtlExceptionHandlerForException,根据返回值执行不同操作
    • ExceptionContinueExecution,表示异常已经被处理过了,接下来就可以回到之前的异常现场(Context)再执行试试了。
    • ExceptionContinueSearch,表示这个节点的handler处理不了这个异常,此时就会借助Next指针去寻找下一个节点接着去处理
    • ExceptionNestedException,意思是处理异常时又引发了一个新的异常,如果是内核态遇到了这个问题就直接蓝屏,如果是用户态的话就成了”嵌套”异常,也就是会在此处再次进行异常处理
    • ExceptionCollidedUnwind,这个和上面的类似,不过上面是异常处理时遇到了麻烦,而这个是在恢复现场的时候遇到了不测,这个”恢复现场”的过程也叫做展开。
  • 调用VEH ContinueHandler进行处理

UnhandledExceptionFilter函数

未处理异常过滤函数,简称UEF函数,时异常处理和windows error report交接的关键,其流程如下:

  • 错误的预处理,主要是对三个方面的检查:
    • 是否存在着嵌套异常。嵌套异常是一种非常难处理的情况,如果处理的不好就很难再恢复原始的状态了,于是这种情况下UEF函数会直接调用NtTerminateProcess结束当前的进程
    • 是否是违例访问。这种情况下UEF函数会尝试去通过更改页属性的方式去修复错误,当然如果访问的是绝对不该访问的页,那UEF就无法解决了。
    • DebugPort有没有。DebugPort在异常分发的过程中起到了标志着调试器是否开启的任务,一旦UEF检测到了DebugPort那它就不会处理该异常,而是返回一个ExceptionContinueSearch,而它作为最后的异常处理也没有处理该异常的话自然也就进入了第二次的异常分发,成功使调试器接手该异常
  • 进行最终处理的处理
    • 根据程序的设置直接结束进程。windows提供了SetErrorMode的api用来设置某个标志位,一旦设置了,那那就不会出现任何的错误提示,程序直接结束。判断当前进程是否在job中,如果在且设置了未处理异常时直接结束,那就直接杀掉进程。
    • 查看是否设置了JIT调试,如果是就开始进行调试。
    • 弹出异常信息。此时程序会加载faultrep.all,调用ReportFault函数来汇报错误,如果设置了错误报告或者是非常严重的错误会弹出error窗口询问用户是否要发送错误报告,而其余情况下就会弹出我们熟知的application error

4.2 VEH

VEH是一个全局链表,全名为Vectored Exception Handler,这个全局链表里面存放的异常处理函数可以过滤所有线程产生的异常,其处理函数的原型如下:

typedef LONG  
(NTAPI *PVECTORED_EXCEPTION_HANDLER)(  
    struct _EXCEPTION_POINTERS *ExceptionInfo  
);  

VEH的注册是通过API函数AddVectoredExceptionHandler进行注册的,他比SEH拥有更优先的级别过滤异常。其原型如下:

  1. WINBASEAPI PVOID WINAPI AddVectoredExceptionHandler(ULONG FirtstHandler,PVECTORED_EXCEPTION_HANDLER VectoreHandler)  

   第一个参数是一个标志位,表示注册的回调函数是在链表的头还是尾,0是插入尾部,非0是插入头部。第二个参数是回调函数的地址,返回一个VectoredHandlerHandle,用于之后卸载回调函数。回调函数原型:

LONG CALLBACK Vectorhandler()

在RltDispatchException的过程中VEH将会优先于SHE调用,若回调函数解决的问题和SEH相似,都会返回ExceptionContinueExcution表示处理完毕。然后借助CONTEXT的内容恢复上下文,跳过SEH继续执行程序,如果失败了就遍历VEH链表寻找解决方法,如果所有的回调函数都不能处理的话再将执行权归还,继续向下执行SEH的相关内容。

5.异常保护机制

5.1 Safe SHE

    SafeSEH又叫做软件DEP,是一种在软件层面实现的对SEH的保护机制,它需要操作系统和编译器的双重支持,在vs2013及以后的版本中会自动启用 /SafeSEH 链接选项来使用SafeSEH。因为该项技术使得以往简单的覆盖异常处理句柄的漏洞利用几乎失效了。

在加载PE文件时,SafeSEH将定位合法的SEH表的地址(如果该映像不支持SafeSEH的话则地址为0),然后是用共享内存中的一个随机数进行加密处理,程序中所有的异常处理函数的地址提取出来汇总放入SEH表,并将该表放入程序映像中,还会将将加密后的SEH函数表地址,IMAGE的开始地址,IMAGE的长度,合法SEH函数的个数,作为一条记录放入ntdll(ntdll模块是进行异常分发的模块)的加载模块数据内存中,每次调用异常处理函数时都会进行校验,只有二者一致才能够正常进行,该处理由RtlDispatchException() 开始,首先会经历两次检查,分别是:

检查异常处理链是否在当前的栈中,不是则终止

检查异常处理函数的指针是否指向栈,是则终止

通过两次检查后会调用RtlIsValidHandler() 来进行异常的有效性检查,08年的black hat给出了该函数的细节。

BOOL RtlIsValidHandler( handler )  
{  
    if (handler is in the loaded image)      // 是否在loaded的空间内  
    {  
        if (image has set the IMAGE_DLLCHARACTERISTICS_NO_SEH flag) //是否设置了忽略异常  
            return FALSE;                    
        if (image has a SafeSEH table)       // 是否含有SEH表  
            if (handler found in the table)  // 异常处理函数地址是否表中  
                return TRUE;  
            else  
                return FALSE;  
        if (image is a .NET assembly with the ILonl    y flag set)  
            return FALSE;                      
    }  
  
    if (handler is on non-executable page)   // handler是否在不可执行页上  
    {  
        if (ExecuteDispatchEnable bit set in the process flags) //DEP是否开启  
            return TRUE;                       
        else  
            raise ACCESS_VIOLATION;            
    }  
  
    if (handler is not in an image)          // handler是否在未加载空间  
    {  
        if (ImageDispatchEnable bit set in the process flags) //设置的标志位是否允许  
            return TRUE;                       
        else  
            return FALSE;  
    }  
    return TRUE;                             /s/ 允许执行异常处理函数  
}  

RtlIsValidHandler() 函数只会在以下几种情况执行异常处理函数

在进程的DEP是开启的情况下

  1. 异常处理函数和进程映像的SafeSEH表匹配且没有NO_SEH标志。
  2. 异常处理函数在进程映像的可执行页,并且没有NO_SEH标志,没有SafeSEH表,没有.NET的ILonly标志。

在进程的DEP关闭的情况下

     3. 异常处理函数和进程映像的SafeSEH表匹配没有NO_SEH标志。

     4. 异常处理函数在进程映像的可执行页,并且没有NO_SEH标志,没有SafeSEH表,没有.NET的ILonly标志。

     5. 异常处理函数不在当前进程的映像里面,但是不在当前线程的堆栈上。

5.2 SEHOP

全称为Structured Exception Handler Overwrite Protection(结构化异常处理覆盖保护),这是专门用来检测SEH是否被劫持的一项技术。

其检测点如下:

  1. SEH节点必须在栈上
  2. SEH节点的Handle必须不在栈上
  3. 最后的SEH节点的Handle必须是ntdll!FinalExceptionHandler,也就是咱们上面说的异常的最后一站

     4. 最后的SEH节点的Next指针必须为0xffffffff

 

参考:

异常分类: Windows调试——从0开始的异常处理(上)

https://www.anquanke.com/post/id/175293

异常分发:Windows异常处理核心原理

          https://blog.csdn.net/qq_42208826/article/details/85321301

SEH和VEH:Windows调试艺术——从0开始的异常处理

          https://www.anquanke.com/post/id/175753#h2-0

异常处理: windows 异常处理

          https://blog.csdn.net/lanuage/article/details/52225201

          Windows用户态异常处理

          https://terenceli.github.io/%E6%8A%80%E6%9C%AF/2014/03/31/windows-user-exception

  • 4
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值