在-Windows-平台上,操作系统实现了一套独有的异常处理机制 —— 结构化异常处理(SEH) 和 向量化异常处理(VEH),这可以看作是对传统 C/C++ 语言异常处理机制的扩展,用于在运行时处理错误。
这些机制仅适用于-Windows-可执行文件,因为它们依赖于-Windows-内核来捕获异常并将控制权转移回程序!
这种独特的异常处理方式使得我们在逆向工程或追踪程序控制流时,如果不理解异常处理器的安装与实现方式,就会变得异常复杂。
本文将深入底层,探究这些异常处理器是如何实现的。
x64 架构下的结构化异常处理(SEH)
SEH 在 32 位与 64 位程序中的实现差异巨大。
本文将主要研究 64 位程序中 SEH 的工作原理,随后简要对比 32 位实现及 VEH。
为了更好地理解 SEH 在编译后的程序中是如何呈现的,我们可以编译一个简单的程序并在 IDA 中查看其汇编代码。以下是示例程序:
1 2 3 4 5 6 7 8 9 10 11 12 13
| #include <windows.h> #include <stdio.h>
int main() { __try { printf("__try block\\n"); } __except (EXCEPTION_EXECUTE_HANDLER) { printf("__except block\\n"); } return 0; }
|
在 IDA 中查看其汇编代码,我们可以识别出 try 和 except 代码块。然而,控制流似乎从未跳转到 except 块。那么程序是如何知道异常处理器的位置的呢?

接下来,我们将深入探究程序是如何追踪这些异常处理器的。
在 x64 中如何定位异常处理器?
在 PE 文件中,有多个目录用于存储映像信息。例如,如果映像包含导出函数,就会有一个导出目录来描述这些导出。
对于 x64 映像,存在一个异常目录(Exception Directory),我们可以使用如 CFF Explorer 这类工具查看:

异常目录中包含多个 RUNTIME_FUNCTION 结构体,其定义如下:
1 2 3 4 5 6
| typedef struct _RUNTIME_FUNCTION { ULONG BeginAddress; ULONG EndAddress; ULONG UnwindData; } RUNTIME_FUNCTION, *PRUNTIME_FUNCTION;
|
我们可以简单理解为:
每个 RUNTIME_FUNCTION 条目通过 UnwindData 字段定义了一组指令,用于处理在 BeginAddress 和 EndAddress 之间发生的异常。
为了更深入地查看异常目录的内容,我们可以在 IDA 中使用快捷键 g 跳转到 ExceptionDir。在那里,我们可以立即看到 main 函数的条目!

我们可以看到 RUNTIME_FUNCTION 结构体的各个字段以及它如何与实际的 try-except 块对应:
1 2 3 4 5 6
| struct _RUNTIME_FUNCTION { ULONG BeginAddress = main; ULONG EndAddress = end; ULONG UnwindData = unwind_data; };
|
我们还可以通过查看 unwind_data 指向的 UNWIND_INFO 结构体来了解异常是如何被处理的。


如你所见,unwind 数据确实包含一个指向异常处理器的指针,当异常发生时会调用它。然而,UNWIND_INFO 中的其他字段是做什么的呢?
在 NTDLL 中查看异常处理器的实现
到目前为止,我们已经简要介绍了 64 位程序中 SEH 异常的处理方式。然而,实际上背后发生的事情远不止这些。为了深入了解,我们需要查看异常处理的源代码。
我最初是通过在 IDA 中逆向分析 ntdll.dll 开始的 😅
不过为了大家的 sanity,我们将尽可能引用 ReactOS(一个开源的-Windows-实现)中的代码片段,只有在 ReactOS 不足以说明问题时才会回到 ntdll。
一旦异常被触发(无论是 VEH 还是 SEH),内核会捕获异常并将控制权传递给 ntdll!KiUserExceptionDispatcher 函数,该函数会找到合适的异常处理器来处理异常。
以下是从异常调度器出发的一些重要函数调用链:
1 2 3 4 5 6 7 8
| KiUserExceptionDispatcher -> RtlDispatchException -> RtlpCallVectoredHandlers -> RtlLookupFunctionEntry -> RtlpLookupDynamicFunctionEntry -> RtlVirtualUnwind / RtlpxVirtualUnwind -> RtlpExecuteHandlerForException
|
我们将详细解释 RtlLookupFunctionEntry 和 RtlpxVirtualUnwind。
ContextRecord
从 KiUserExceptionDispatcher 传递到 RtlDispatchException 的一个重要数据结构是 CONTEXT 结构体。
该结构体包含了异常发生时寄存器的状态信息。
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
| typedef struct _CONTEXT { DWORD64 P1Home; DWORD64 P2Home; DWORD64 P3Home; DWORD64 P4Home; DWORD64 P5Home; DWORD64 P6Home; DWORD ContextFlags; DWORD MxCsr; WORD SegCs; WORD SegDs; WORD SegEs; WORD SegFs; WORD SegGs; WORD SegSs; DWORD EFlags; DWORD64 Dr0; DWORD64 Dr1; DWORD64 Dr2; DWORD64 Dr3; DWORD64 Dr6; DWORD64 Dr7; DWORD64 Rax; DWORD64 Rcx; DWORD64 Rdx; DWORD64 Rbx; DWORD64 Rsp; DWORD64 Rbp; DWORD64 Rsi; DWORD64 Rdi; DWORD64 R8; DWORD64 R9; DWORD64 R10; DWORD64 R11; DWORD64 R12; DWORD64 R13; DWORD64 R14; DWORD64 R15; DWORD64 Rip; union { XMM_SAVE_AREA32 FltSave; NEON128 Q[16]; ULONGLONG D[32]; struct { M128A Header[2]; M128A Legacy[8]; M128A Xmm0; M128A Xmm1; M128A Xmm2; M128A Xmm3; M128A Xmm4; M128A Xmm5; M128A Xmm6; M128A Xmm7; M128A Xmm8; M128A Xmm9; M128A Xmm10; M128A Xmm11; M128A Xmm12; M128A Xmm13; M128A Xmm14; M128A Xmm15; } DUMMYSTRUCTNAME; DWORD S[32]; } DUMMYUNIONNAME; M128A VectorRegister[26]; DWORD64 VectorControl; DWORD64 DebugControl; DWORD64 LastBranchToRip; DWORD64 LastBranchFromRip; DWORD64 LastExceptionToRip; DWORD64 LastExceptionFromRip; } CONTEXT, *PCONTEXT;
|
例如,Context->Rip 会保存导致异常的指令指针。
RtlLookupFunctionEntry
该函数遍历异常目录中的 RUNTIME_FUNCTION 结构体,查找满足 BeginAddress < Context->Rip < EndAddress 的条目。
如果找不到有效条目,它会调用 RtlpLookupDynamicFunctionEntry 来查找动态函数条目。
RtlpLookupDynamicFunctionEntry
之前我们提到,RUNTIME_FUNCTION 条目存储在编译时嵌入到可执行文件中的 ExceptionDir 中。
然而,为了支持动态生成或即时编译的代码,Windows 提供了两个 API 用于在运行时添加更多的 RUNTIME_FUNCTION 条目:
注意,这只有在可执行文件的 ExceptionDir 中找不到有效的 RUNTIME_FUNCTION 时才会被调用。
第一种方式是使用 RtlInstallFunctionTableCallback,它接受一个回调函数作为参数。
该回调函数将被调用,并期望返回一个 RUNTIME_FUNCTION 结构体。
1 2 3 4 5 6 7 8 9
| BOOLEAN RtlInstallFunctionTableCallback( DWORD64 TableIdentifier, DWORD64 BaseAddress, DWORD Length, PGET_RUNTIME_FUNCTION_CALLBACK Callback, PVOID Context, PCWSTR OutOfProcessCallbackDll );
|
第二种方式是使用 RtlAddFunctionTable 或 RtlAddGrowableFunctionTable。与前者不同,你需要提前提供 RUNTIME_FUNCTION 条目,这些条目会被添加到一个数组中,在异常发生时被查找。
1 2 3 4 5 6 7 8 9
| NTSTATUS RtlAddGrowableFunctionTable( PVOID *DynamicTable, PRUNTIME_FUNCTION FunctionTable, DWORD EntryCount, DWORD MaximumEntryCount, ULONG_PTR RangeBase, ULONG_PTR RangeEnd );
|
酷!能够在运行时安装 RUNTIME_FUNCTION 条目(尤其是通过调用我们自己的函数)无疑会让逆向工程变得更加复杂 :)
RtlVirtualUnwind
异常可能发生在极其复杂的函数中,此时栈和寄存器状态一片混乱。为了将执行权交还给异常处理器,我们必须恢复栈的状态。
栈展开(Stack Unwinding) 确保即使在异常发生时,程序也能通过系统地回溯函数帧、执行清理处理器并恢复程序状态来维持程序的完整性和资源管理。
前面我们简要提到了 UnwindData 和 UNWIND_INFO。RUNTIME_FUNCTION 中的 UnwindData 包含了指向 UNWIND_INFO 结构体的偏移。
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
| typedef union _UNWIND_CODE { struct { UBYTE CodeOffset; UBYTE UnwindOp:4; UBYTE OpInfo:4; }; USHORT FrameOffset; } UNWIND_CODE, *PUNWIND_CODE;
typedef struct _UNWIND_INFO { UBYTE Version:3; UBYTE Flags:5; UBYTE SizeOfProlog; UBYTE CountOfCodes; UBYTE FrameRegister:4; UBYTE FrameOffset:4; UNWIND_CODE UnwindCode[1];
} UNWIND_INFO, *PUNWIND_INFO;
|
简而言之,UNWIND_INFO 包含一个 UNWIND_CODE 数组,定义了一组指令,用于在将执行权交还给 ExceptionHandler 之前恢复栈和寄存器的状态。
Unwind 操作码的详细文档可以参考 Microsoft 官方文档。
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
| /* Process the remaining unwind ops */ while (i < UnwindInfo->CountOfCodes) { UnwindCode = UnwindInfo->UnwindCode[i]; switch (UnwindCode.UnwindOp) { case UWOP_PUSH_NONVOL: Reg = UnwindCode.OpInfo; PopReg(Context, ContextPointers, Reg); i++; break;
case UWOP_ALLOC_LARGE: if (UnwindCode.OpInfo) { Offset = *(ULONG*)(&UnwindInfo->UnwindCode[i+1]); Context->Rsp += Offset; i += 3; } else { Offset = UnwindInfo->UnwindCode[i+1].FrameOffset; Context->Rsp += Offset * 8; i += 2; } break;
case UWOP_ALLOC_SMALL: Context->Rsp += (UnwindCode.OpInfo + 1) * 8; i++; break;
case UWOP_SET_FPREG: Reg = UnwindInfo->FrameRegister; Context->Rsp = GetReg(Context, Reg) - UnwindInfo->FrameOffset * 16; i++; break;
case UWOP_SAVE_NONVOL: Reg = UnwindCode.OpInfo; Offset = UnwindInfo->UnwindCode[i + 1].FrameOffset; SetRegFromStackValue(Context, ContextPointers, Reg, (DWORD64*)Context->Rsp + Offset); i += 2; break;
case UWOP_SAVE_NONVOL_FAR: Reg = UnwindCode.OpInfo; Offset = *(ULONG*)(&UnwindInfo->UnwindCode[i + 1]); SetRegFromStackValue(Context, ContextPointers, Reg, (DWORD64*)Context->Rsp + Offset); i += 3; break;
case UWOP_EPILOG: i += 1; break;
case UWOP_SPARE_CODE: ASSERT(FALSE); i += 2; break;
case UWOP_SAVE_XMM128: Reg = UnwindCode.OpInfo; Offset = UnwindInfo->UnwindCode[i + 1].FrameOffset; SetXmmRegFromStackValue(Context, ContextPointers, Reg, (M128A*)Context->Rsp + Offset); i += 2; break;
case UWOP_SAVE_XMM128_FAR: Reg = UnwindCode.OpInfo; Offset = *(ULONG*)(&UnwindInfo->UnwindCode[i + 1]); SetXmmRegFromStackValue(Context, ContextPointers, Reg, (M128A*)Context->Rsp + Offset); i += 3; break;
case UWOP_PUSH_MACHFRAME: /* OpInfo is 1, when an error code was pushed, otherwise 0. */ Context->Rsp += UnwindCode.OpInfo * sizeof(DWORD64);
/* Now pop the MACHINE_FRAME (RIP/RSP only. And yes, "magic numbers", deal with it) */ Context->Rip = *(PDWORD64)(Context->Rsp + 0x00); Context->Rsp = *(PDWORD64)(Context->Rsp + 0x18); ASSERT((i + 1) == UnwindInfo->CountOfCodes); goto Exit; } }
|
与 32 位 SEH 的对比
如你所见,64 位 SEH 处理器**几乎总是(默认情况下)**存储在编译时嵌入的只读异常目录中。
而 32 位 SEH 处理器则存储在运行时的栈上,形成一个异常处理器链表。每个使用 SEH 的函数都必须运行如下汇编代码来安装处理器:
1 2 3 4
| push DWORD PTR fs:[0] # 保存当前处理器 push <exception_handler> # 压入新处理器的地址 mov DWORD PTR fs:[0], esp # 将 SEH 链指向新记录
|
当异常发生时,系统从最新到最旧遍历该链,直到找到一个处理器。每个函数在返回前必须解除其处理器的链接。
SEH 与 VEH 的对比
尽管 SEH 和 VEH 的目标都是处理异常,但它们的实现方式差异巨大。
向量化异常处理(VEH)
关于 VEH,最重要的是它在整个进程范围内监控异常,并通过在运行时调用 AddVectoredExceptionHandler 函数来注册。
以下是一个使用 VEH 的示例程序:
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
| #include <windows.h> #include <stdio.h>
LONG WINAPI VectoredHandler(PEXCEPTION_POINTERS pExceptionInfo) { if (pExceptionInfo->ExceptionRecord->ExceptionCode == EXCEPTION_ACCESS_VIOLATION) { printf("Access Violation Detected!\\n"); printf("Violation Address: 0x%p\\n", pExceptionInfo->ExceptionRecord->ExceptionAddress); printf("Memory Address: 0x%p\\n", (void*)pExceptionInfo->ExceptionRecord->ExceptionInformation[1]); return EXCEPTION_CONTINUE_SEARCH; } return EXCEPTION_CONTINUE_EXECUTION; }
int main() { PVOID handler = AddVectoredExceptionHandler(1, VectoredHandler); int* p = NULL; *p = 42; RemoveVectoredExceptionHandler(handler); return 0; }
|
当 VEH 处理器被注册时,它会被添加到异常链的末尾。
当异常发生时,系统从链表头部开始遍历,寻找合适的处理器。如果找不到,进程将被终止。
结语
本文并不全面,绝对没有涵盖-Windows-异常处理的所有细节。如有任何不准确之处,请联系我或者留言。
延伸阅读
以下是一些关于-Windows-异常处理内部机制及其在安全领域应用的优秀文章:
参考链接