avatar

Catalog
未处理异常

前言

根据前面学习的VEH,SEH,编译器对SEH的扩展等内容,我们对一个异常的处理过程了解更加深入,以用户异常为例,它进入0环后会交予KiDispatchException函数处理,该内核函数会对异常进行分发,若内核调试器与用户调试器都不存在或不处理,则会临时返回3环,交予3环的异常处理函数KiUserExceptionDispatcher进行处理。该函数会调用RtlDispatchException,先后去VEH链表与SEH链表查询可能存在的异常处理函数。那现在问题来了,如果VEH与SEH都没有对异常进行处理,那会怎么办?今天就来研究以下这个问题:未处理异常

关于内核未处理异常

对于内核态的未处理异常,如果内核调试器存在(例如Windbg),KiExceptionDispatch会给调试器第二轮处理机会(SEH不会,SEH仅在第一轮作处理);如果调试器没有处理该异常或者根本没有内核调试器,KiExceptionDispatch会调用KeBugCheckEx启用蓝屏机制,其停止码为KMODE_EXCEPTION_NOT_HANDLED。

考虑到内核态未处理异常的机制比较简单,本篇将集中讨论用户态的未处理异常,内核态仅在此概括。

最后一道防线

入口程序的最后一道防线

  1. 编写一个如下图所示的简单程序,下好断点,运行

  2. 这个程序咋一看非常简单,打开程序调用堆栈

    会发现,当前所在的这个main函数,并不是函数的开始,它是由一个mainCRTStartup()调用而来的(这部分玩逆向的都知道,程序拖进OD时会发现入口并不在程序加载的位置),而且这个mainCRTStartup函数也不是最上层的,调用它的还有一个位于Kernel32.dll中的函数。

  3. 跟进到这个Kernel32.dll的函数

  4. 这时,我们用IDA搜索一个名为BaseProcessStart的函数,对比如下

    可以看出来,这个最上层的函数,就是这个BaseProcessStart函数,是它调用了mainCRTStartup,再调用了主函数。那么了解这些有什么用呢?

  5. 先别急,注意一点,BaseProcessStart的第三条指令,调用了一个_SEH_prolog,这个调用在我们之前分析的内核函数中经常见到,但全部都忽略了,这一次,我们要进入该函数一探究竟。

    可以看到,_SEH_prolog函数有一个将SEH结构体挂到链表上的动作,可以认为,这个函数的作用就是将SEH挂到链表上。又因为这个函数被BaseProcessStart所调用,并且BaseProcessStart又是调用主函数的最上层的函数。

    这样,如果main函数发生异常,且在它的SEH链表中未能查找到能够处理异常的异常处理函数。那么_except_handler3则会通过previousTryLevel查找最外层的异常处理函数,也就是BaseProcessStart函数帮忙挂上去的SEH结构中的异常处理函数。大部分情况下,这个异常处理函数会把异常处理掉。所以说,找不到异常处理程序的情况是不存在的

线程启动的最后一道防线

前面了解了,入口程序有最后一道防线,可以保证进程在一定能在SEH中找到异常处理函数。那么如果新起一个线程(拥有自己的堆栈),那么还会有最后一道防线吗?

  1. 编译如下代码:(环境VC++6.0)

    c
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    #include "stdafx.h"
    #include

    DWORD WINAPI ThreadProc(LPVOID lpParam)
    {
    int x = 1;

    return 0;
    }


    int main(int argc, char* argv[])
    {
    CreateThread(NULL, 0, ThreadProc, NULL, 0, NULL);

    getchar();
    return 0;
    }

    在我们定义的ThreadProc中没有定义任何异常处理的代码。

  2. 在int x=1处下断点,运行程序,观察调用堆栈。

    发现,在线程竟然也不是从我们提供的函数开始执行的。

  3. 同样,打开IDA搜索一个名为BaseThreadStart的函数,与刚刚找到的上层函数进行对比。

    发现,又是一样的,并且这个BaseThreadStart函数也调用了_SEH_prolog,也就意味着它也有最后一道防线

UnhandledExceptionFilter

结合上面所讲,无论进程还是线程,都拥有最后一道防线,即编译器会给我们加入一个异常处理的结构。其执行的伪代码如下:

c
1
2
3
4
5
6
7
8
9
__try
{

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

这个伪代码最为关键的就是UnhandledExceptionFilter这个函数。当程序有异常发生时,若原先堆栈的SEH均未处理,那么这个函数一定会执行,因为_except括号内的过滤表达式一定会有一个值。UnhandledExceptionFilter的执行流程如下:

  1. 通过NtQueryInformationProcess查询当前进程是否正在被调试(判断EProcess+0xBC处的DebugPort),如果是,返回EXCEPTION_CONTINUE_SEARCH,此时会进入第二轮分发。
  2. 如果没有被调试:
    • 查询是否通过SetUnhandledExceptionFilter注册顶层处理函数
      • 如果有,就调用。
      • 如果无,则弹出窗口,让用户选择终止程序还是启动即时调试器。
    • 如果用户没有启用即时调试器,那么该函数返回EXCEPTION_EXECUTE_HANDLER,也就意味着会执行_except内的代码,终止线程或进程。

顶层异常处理同普通异常处理有所区别:顶层异常处理存储在kernel32!BaseCurrentTopLevelFilter的全局变量中。

简单了解完UnhandledExceptionFilter函数后,可以对用户异常的处理有进一步的认识,通常情况下,用户异常不会进入第二轮分发,在第一轮分发时,若线程堆栈中的SEH未对异常进行处理,那么系统帮忙注册的最后一道防线会对异常进行处理(即终止进程/线程);只有存在调试器的情况下,才会进入第二轮分发。

顶层异常处理函数

反调试与反反调试

反调试

前面提到,UnhandledExceptionFilter函数是通过NtQueryInformationProcess来查询当前进程是否在被调试的,若未被调试,程序则会判断是否通过SetUnhandledExceptionFilter注册了顶层函数。若注册了,则会调用这个顶层函数。

这样以来,也就有了一个反调试的手段,以如下程序为例:

c
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#include "stdafx.h"
#include

long _stdcall callback(_EXCEPTION_POINTERS* excp)
{
excp->ContextRecord->Ecx = 1;
return EXCEPTION_CONTINUE_EXECUTION;
}

int main(int argc, char* argv[])
{
//注册一个最顶层异常处理函数
SetUnhandledExceptionFilter(callback);

//除0异常
_asm
{
xor edx,edx
xor ecx,ecx
mov eax,0x10
idiv ecx
}

//程序正常执行
printf("程序执行");

getchar();
return 0;
}

构造一个除0异常,然后将异常修复的代码通过SetUnhandledExceptionFilter注册为顶层的异常处理函数。这样,如果程序被调试,那么顶层的异常处理函数就得不到执行,程序就会报错退出,这样就达到了反调试的目的。这里注意一点,不要在VC++6.0编译器内运行程序,这样会报除零异常,进入该项目的文件夹,双击.exe文件,即程序能够正常执行;若拖入调试器中,则无法正常执行。实验效果可以参考调试与异常中的另一个例子。

反反调试

有反调试就有反反调试,攻与防是相对的,由于UnhandledExceptionFilter函数是通过NtQueryInformationProcess来判断是否被调试的,而NtQueryInformationProcess是通过DebugPort的值来判断程序是否正在被调试,因而只需要Hook了NtQueryInformationProcess就可以针对上述的反调试手段实现反反调试。

二次分发

这里再稍微提一下,就是这个KiUserExceptionDispatcher。它会调用RtlDispatchException,这个函数包括了对VEH的查找,SEH的查找,是否存在顶层函数,以及是否被调试,都在RtlDispatchException内部调用。全部都判断完了以后,返回一个Boolean值。

  • 若为真,调用ZwContinue再进入0环
  • 若为假,调用ZwRaiseException进行第二轮异常分发。

总结

至此,关于异常的内容就结束了。主要是用户与内核两类异常的处理流程。用户的稍微复杂一些,最后还有一个未处理异常。现在回头再看第一篇贴的那张图,就好理解很多了。

参考资料

参考书籍:

  • 《软件调试 卷2:Windows平台调试》p265~p282 —— 张银奎

参考教程:

  • 海哥逆向中级班预习班

参考链接:

Author: cataLoc
Link: http://cataloc.gitee.io/blog/2020/08/28/%E6%9C%AA%E5%A4%84%E7%90%86%E5%BC%82%E5%B8%B8/
Copyright Notice: All articles in this blog are licensed under CC BY-NC-SA 4.0 unless stating additionally.
Donate
  • 微信
    微信
  • 支付寶
    支付寶