Structured Exception Handling

Windows 异常处理机制

Posted by Kennico on December 16, 2018

Content

微软靠 OS 和编译器提供了一套异常处理机制(Structured Exception Handling, SEH)。和标准 C++ 的中的 try/catch 不同, SEH 能够处理的异常类型有限而且更贴近硬件(比如整数除 0 异常和非法访问内存,考虑到Windows 是由 C 写成的也就不足为奇了)。使用了 SEH 的代码包含三个要素:try 代码块、filter 表达式和exception-handler 代码块,通常按照如下形式组织:

1
2
3
4
5
6
7
8
__try 
{
    // guarded body of code  
} 
__except (/*filter-expression*/) 
{ 
    // exception-handler block 
}

(本文是 A Crash Course on the Depths of Win32™ Structured Exception Handling 的笔记,原文作者是 Matt Pietrek。)

How does it know where to call when exception occurs

相信每一个码农都不会对异常这个词语感到陌生。在一些高级程序设计语言中,程序的异常处理都遵循以下逻辑:当异常出现时,“异常”这一信息将沿着函数调用的相反方向传播,直到这个异常在某一个代码块中得到处理,然后异常在这个代码块中继续执行( SEH 允许从引起异常的指令处重新执行)。为实现这个逻辑,SEH 机制离不开名为SEH 链(SEH chain)的数据结构(一些调试器比如 ollydbg, x64dbg, 都提供查看 SEH 链的功能)。那么如何通过这个数据结构实现类似的异常传播和异常处理?

SEH chain

SEH 链表是一个单向链接的链表。它的结点,姑且称之为 SEH 记录,其结构如下所示:

1
2
3
4
5
// The following definition is not presented in SDK.
typedef struct _EXCEPTION_REGISTRATION_RECORD {
    struct _EXCEPTION_REGISTRATION_RECORD *prev;
    PEXCEPTION_ROUTINE handler;
} EXCEPTION_REGISTRATION_RECORD;

prev 指向前一个结点,handler异常回调函数(Exception callback function) 的函数指针;需要重点说明的是,这里的 handler 指针并不是 __try/__except 的 exception-handler 代码块!

如下图所示,SEH 链表中的所有结点存储在栈上。如果把最靠近栈顶的那个结点称为头结点,那么头结点相对其它结点来说位于低地址。为了在任意时候访问 SEH 链表,TEB 块(线程信息快)的 fs:[0] 用作存储 SEH 头结点地址;这可能是因为每个线程都需要单独的互不干涉的异常处理逻辑。

Finding a structure to handling exception

SEH 链表的结点随着函数调用而动态变化。当程序的执行流进入包含 __try/__except 的函数时,

  • 首先会在栈顶创建一个 SEH 记录,使得 prev 指向上一个 SEH 记录,以及 handler 指向一个异常回调函数;
  • 接着将这个新的 SEH 记录的地址赋值给 fs:[0x0]。因此,fs:[0x0] 在任意时候都指向最靠近栈顶的 SEH 记录,也就是 SEH 链的头结点。

我们可以看出,和我们认知相符的那一套异常处理逻辑是这样实现的:简单来说,当异常发生时,程序首先通过当前线程 TEB 的 fs:[0] 找到 SEH 头结点,从头结点结点开始遍历 SEH 链表直到某个结点的异常回调函数(handler)能够正确地处理异常。可是,程序如何得知某个异常回调函数是否能够处理异常呢?

Exception callback

异常回调函数的签名如下:

1
int except_handler(PEXCEPTION_RECORD, EXCEPTION_REGISTRATION*, PCONTEXT, PEXCEPTION_RECORD);

EXCEPTION_RECORD 包含和一个具体的异常相关的必要信息,其结构如下:

1
2
3
4
5
6
7
8
typedef struct _EXCEPTION_RECORD {
    DWORD ExceptionCode;
    DWORD ExceptionFlags;
    struct _EXCEPTION_RECORD *ExceptionRecord;
    PVOID ExceptionAddress;
    DWORD NumberParameters;
    ULONG_PTR ExceptionInformation[EXCEPTION_MAXIMUM_PARAMETERS];
} EXCEPTION_RECORD;

在遍历 SEH 链表结点的时候,程序会执行当前结点的异常回调函数。函数的返回值 DISPOSITION_CONTINUE_SEARCH 表示它无法处理该异常,程序需要遍历下一个结点。当然,异常回调函数还有其它返回值。如果回调函数可以正常处理异常,那么程序控制流将不会返回到回调函数的调用者手中(想想是谁在遍历 SEH 结点?)。但是,万一所有 SEH 结点的异常回调函数都无法处理异常呢?

异常回调函数不需要由开发者实现;不同的 Windows 提供的异常回调函数也不尽相同,可以观察到的就有两个(__except_handler3__except_handler4)。在 VS2017 (默认工具集或兼容XP的工具集)编译出来的程序中,所有由用户代码 __try/__except 生成的异常回调函数指针都指向 _except_handler4();关闭 GS 选项后,这个函数指针变成了 _except_handler3()。 两个函数的实现不相同。

The last resort exception handler

SEH 的尾结点,也是第一个创建的结点,由 OS 在启动进程的时候在生成,以确保所有异常都能被处理;不出意外地,调用入口点函数(entry point)也是被 __try/__except 包围。如果用户没有调用 SetUnhandledExceptionFilter() 更改函数指针,这个结点的异常回调函数会调用 UnhandledExceptionFilter()。在WER(Windows Error Reporting) 被关闭的情况下,程序将弹出对话框让用户选择是退出还是调试;如果用户选择调试,实时调试器(JIT, Just-in-time debugger)就会启动并且附加到当前进程。如果系统启用了自动调试(AeDebug),弹窗将不会出现而且直接启动调试器。关于如何在 Win10 启用自动调试,可参见这里。(顺带一提,启动调试器这个过程涉及进程间通信。由于句柄只是数组索引而已,因此这个过程得以通过在命令参数中传递事件句柄来实现共享内核对象。)

Stack unwinding

到目前为止,我们已经对异常处理的逻辑有了大体了解;但是这样一套复杂而且完整的流程仍然存在许多值得探讨的细节问题。比如,之前提到程序的执行流可能会回到任意一个非直接的调用者手中。以下图为例子,C() 中出现的异常可能会在 A() 中处理;而这就意味着 B() 剩下代码将被忽略。对于那些动态申请系统资源的程序来说,没有得到及时释放的资源往往是潜在的危险。因此,SEH 必须要保证每一个函数能够正常地退出。

  • A()
    • B()
      • C()
        • exception happens

为了让无法处理异常的函数能正常退出,异常回调函数将被再一次调用。在此期间,程序执行 stack unwinding 操作来还原函数调用的上下文,把 esp 和 ebp 还原到 SEH 记录对应的栈帧的值。借助下图可以把这一通操作直观地理解为压缩栈。

Stack unwinding

当参数 ExceptionFlagsEH_UNWINDING 位为 1 时,异常回调函数可以得知当前正在进行 stack unwinding 操作。一些收尾工作,比如C++析构函数,会在 stack unwinding 过程中被调用。那些对异常无能为力的回调函数将在这个过程中默默完成自己的善后工作。发生 stack unwinding 之后,在栈上所有位于 A 之前的内容都被擦除(其实只是简单地压低栈顶而已),当然也包括 SEH 记录。

A compiler’s perspective

实际上, SEH 记录的结构 EXCEPTION_REGISTRATION 可能比单纯两个指针要复杂得多;而且 __try/__except 块和 SEH 记录并非一一对应关系。然而对于 ntdll 来说,它只看得到两个指针而已。而且到目前为止,EXCEPTION_REGISTRATION 都没有涉及本文开头所提到的 filter 表达式和 exception-handler 代码块。实际上,这些 “ugly reality” 和 “twist to simplistic view”(作者原话) 全部由编译器和运行时库代码完成。

Exception registration record and callback

_except_handler3() 需要与一个比单纯两个指针更加复杂的 EXCEPTION_REGISTRATION 结构来配合食用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct scopetable_entry;

struct EXCEPTION_REGISTRATION {
    struct _EXCEPTION_REGISTRATION_RECORD *prev;
    PEXCEPTION_ROUTINE handler;
    scopetable_entry* scopetable;
    int trylevel;
    int _ebp;
};

struct scopetable_entry {
    DWORD previousTryLevel;
    PROC lpfnFilter;    // filter expression
    PROC lpfnHandler;   // exception-handler block
};

EXCEPTION_REGISTRATION::scopetable 是一个用数组实现的链表,元素 scopetable_entrylpfnFilterlpfnHandler 分别对应 __except 后圆括号内的 filter 表达式、花括号内 exception-handler 代码块scopetable_entry 之间靠索引 previousTryLevel 串成单向链表;EXCEPTION_REGISTRATION::trylevel 表示适用于当前异常处理的 scopetable_entry 链表的头节点索引。scopetable 链表使得程序只需要一个 SEH 记录就能处理位于同一函数内的多个 __try/__except 块。

_except_handler3()伪代码很长。它的执行流程大体如下

  1. 如果正在进行 unwinding 就转到4;否则继续。
  2. 从 SEH 记录中取出并遍历 scopetable 链表,调用 lpfnFilter() 并根据返回值决定下一步:
    • EXCEPTION_CONTINUE_SEARCH: 继续遍历 scopetable;
    • EXCEPTION_CONTINUE_EXECUTION: 促使 _except_handler3() 返回 ExceptionContinueExecution,程序从引发异常的指令重新开始执行;
    • EXCEPTION_EXECUTE_HANDLER: 表示异常得到正确处理,执行 stack unwinding
      • 调用 __global_unwind2() -> RtlUnwind() -> RtlpExecuteHandlerForUnwind() -> ExecuteHandler() -> exception-callback() (_except_handler3())
      • 调用 __local_unwind2() 清理当前栈帧;
      • 执行 lpfnHandler() 然后 不再返回_except_handler3()
  3. 如果 scopetable 链表中所有 lpfnFilter() 都返回 EXCEPTION_CONTINUE_SEARCH,这表示当前 _except_handler3() 无法处理这个异常,返回 DISPOSITION_CONTINUE_SEARCH。这个函数放弃治疗。
  4. 执行 __local_unwind2() 清理当前栈帧,返回 DISPOSITION_CONTINUE_SEARCH

结合 stack unwinding 的内容,虽然部分异常回调函数会被调用第二次,但是 lpfnFilter()lpfnHandler() 并不会被调用第二次;而且只有一个 lpfnHandler(),亦即 __except 代码块可能被执行。由此可见,正是 _except_handler3() 复杂的执行逻辑才能保证程序实现正确的(或者说和开发者脑回路一致的) __try / __except 语义。

__try/__except block with _except_handler3

如果编译器决定使用 _except_handler3 作为异常回调函数,SEH 记录 EXCEPTION_REGISTRATION 的创建过程如下,

  1. 压入 0XFFFFFFFF(trylevel);
  2. 压入 scopetable 的地址(scopetable, 在 XP 中位于 .rdata);
  3. 压入 _except_handler3 地址(handler);
  4. 压入 fs:[0](prev).
  5. 修改 fs:[0] 为当前栈顶地址。

之后,当控制流进入第一个 __trytrylevel 被设置为 0;进入第二个__try 代码块时,无论是否嵌套,这个变量的值会+1。当控制流离开 __try__except 后,这个变量被重新赋值为 0XFFFFFFFF

__try/__except block with _except_handler4

启用编译选项 GS 之后,异常回调函数变成 _except_handler4 ,而且创建 EXCEPTION_REGISTRATION 的过程也发生变化,这其实是因为相应的结构被修改了(虽然不明,可能是 scopetable)。

  1. 压入 0xFFFFFFFE(trylevel?);
  2. 压入一个位于 .rdata 的地址,可能还是 scopetable 但结构有变化;
  3. 压入 _except_handler4 地址;
  4. 压入 fs:[0]
  5. 修改 fs:[0] 为当前栈顶地址;
  6. 其它一般操作,比如抬高栈顶;
  7. 取出 .data 第一个双字整数(位于 0x00403000),这个值就是”security cookie”。trylevel与之异或(trylevel ^= (DWORD)(*.data));
  8. 压入 ebp 与上述双字整数异或的结果。

显然编译选项 GS 对 SEH 记录的 scopetable 和 trylevel 作出保护措施防范栈上的缓冲区溢出攻击。和前面的类似,当控制流进入第一个 __trytrylevel 被设置为 0;进入下一个__try 代码块时这个变量的值增加 1。当退出 __try__except 后,这个变量被重新赋值为 0XFFFFFFFE

_except_handler4() 调用另一个函数 _except_handler4_common()(尾调用),这个函数比 _except_handler4 还要多出来两个参数:

1
int _except_handler4_common(DWORD*, function*, PEXCEPTION_RECORD, EXCEPTION_REGISTRATION*, PCONTEXT, PEXCEPTION_RECORD)

第一个参数是硬编码的 security cookie 的地址。第二个参数是硬编码的函数指针(0x004031375),该函数的调用约定可能是 fastcall(靠 ecx 传递参数),其作用是检查这个参数是否等于 security cookie ,如果二者不相等,最后会调用 UnhandledExceptionFilter(),结局就是喜闻乐见的弹窗;换言之,程序在这种情况下不会处理异常而且退出。

回到 _except_handler4_common()。这个函数做的第一件事情是从参数取出原本应该是 scopetable 指针的值,和 security cookie 异或还原出原来的 scopetable 的地址。不过反汇编就只看到这里了,剩下的过程没有继续研究。

Show SEH frames

下面的代码是在文章例子的基础上稍加修改得到的。这个例子的作用是输出 SEH 链以及 scopetable 信息。

原来的代码编译后当然不能正常运行,XP 也不行。原来的 scopetable 指针已经和 security cookie 异或,尝试读取位于此处的数据时必然引起非法访问异常(0xC0000005)。例子经过修改加上了异常处理。

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
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
#include <Windows.h>
#include <cstdio>

typedef EXCEPTION_REGISTRATION_RECORD EXCEPTION_REGISTRATION;

struct scopetable_entry {
    DWORD previousTryLevel;
    PROC lpfnFilter;
    PROC lpfnHandler;
};

struct VC_EXCEPTION_REGISTRATION {
    VC_EXCEPTION_REGISTRATION *prev;
    PEXCEPTION_ROUTINE handler;
    scopetable_entry* scopetable;
    int trylevel;
    int _ebp;
};

extern "C" int _except_handler3(PEXCEPTION_RECORD, EXCEPTION_REGISTRATION*, PCONTEXT, PEXCEPTION_RECORD);
extern "C" int _except_handler4(PEXCEPTION_RECORD, EXCEPTION_REGISTRATION*, PCONTEXT, PEXCEPTION_RECORD);

void ShowScopeTableInFrame(VC_EXCEPTION_REGISTRATION*);
void WalkSEHFrame();
void Function();

int main(int argc, char* argv[], char* envp[]) {
    // TODO main
    int i;
    _try{
        i = 0x1234;
    }_except(EXCEPTION_CONTINUE_SEARCH) {
        i = 0x4321;
    }

    __asm int 3
    __try{
        Function();
    }__except(EXCEPTION_CONTINUE_SEARCH) {
        printf("caught in %s\n", __FUNCTION__);
    }
    //_asm int 3
    return 0;
}

void ShowScopeTableInFrame(VC_EXCEPTION_REGISTRATION * pVCExcRec) {
    printf("Frame: 0x%p Handler: 0x%p Prev: 0x%p Scopetable: 0x%p\n",
        pVCExcRec, pVCExcRec->handler, pVCExcRec->prev, pVCExcRec->scopetable);

    __try {
        scopetable_entry* pScopeTableEntry = pVCExcRec->scopetable;
        for (int i = 0; i <= pVCExcRec->trylevel; ++i) {
            printf(" scopetable[%u] previousTryLevel: %08X filter: 0x%p __except: 0x%p\n",
                i, pScopeTableEntry->previousTryLevel, pScopeTableEntry->lpfnFilter, pScopeTableEntry->lpfnHandler);

            pScopeTableEntry++;
        }
    } __except (EXCEPTION_EXECUTE_HANDLER) {
        printf(" Exception occurs. Code:0x%08X\n", GetExceptionCode());
    }

    printf("\n");
}

void WalkSEHFrame() {
    printf("_except_handler3 is at address: 0x%p\n", _except_handler3);
    printf("_except_handler4 is at address: 0x%p\n", _except_handler4);

    VC_EXCEPTION_REGISTRATION* pVCExcRec;
    _asm {
        mov eax, fs:[0x0]
        mov pVCExcRec, eax
    }
    printf("The top SEH node is at address: 0x%p\n\n", pVCExcRec);

    while (0xffffffff != (unsigned)pVCExcRec) {
        ShowScopeTableInFrame(pVCExcRec);
        pVCExcRec = pVCExcRec->prev;
    }
}

void Function() {
    __try {
        __try {
            __try {
                WalkSEHFrame();
            } __except (EXCEPTION_CONTINUE_SEARCH) {

            }
        } __except (EXCEPTION_CONTINUE_SEARCH) {

        }
    } __except (EXCEPTION_CONTINUE_SEARCH) {

    }
}
OS IDE Configuration
Windows XP Professional SP3 VS2017 Release
  • 默认开启 GS

Disabled security check

  • 关闭 GS

Disabled security check

从上边两个图片可以看出来,因为 GS 默认是打开的,所以 VS2017 的编译器使用 _except_handler4() 作为异常回调函数。

Conclusion

对于 ntdll 而言,ntdll 只要找到 SEH 链,调用异常回调函数然后根据其返回值决定下一步行动。然而实际上更多的工作,包括(stack unwinding, 嵌套/多个__try/__except结构)靠编译器生成代码来完成。

A more detailed view

当程序从 OS 接过异常的时候,控制流进入到 KiUserExceptionDispatcher(),该函数立即调用 RtlDispatcherException()RtlDispatcherException() 通过 RtlpGetRegistrationHead() 获取 SEH 链表头结点,在遍历链表结点的过程中做了一堆验证工作(栈帧是否有效、是否对齐)再调用 RtlpExecuteHandlerForException(),然后跳转到 ExecuteHandler()。异常回调函数的指针(比如 _except_handler3()),作为最后一个(第五个)参数传递到 ExecuteHandler(),在 ExecuteHandler() 当中被调用。

本文只讨论 SEH 机制的小部分内容,并没有讨论更多细节,比如 OS 如何监视并得知异常出现,派发异常的过程 RtlDispatcherException()(负责遍历 SEH 链),实际上完成 unwind 操作的 RtlUnwind()等等。更多的内容还要拜读 Matt Pietrek 的原文