笔者花费了一些时间,学习了64位WindowsSEH机制的底层原理。现写为博客,希望对大家有帮助。
注意:本文是从逆向工程的角度分析64位Windows的SEH机制,阅读本文的前提是能够从程序员(开发)的角度理解SEH机制。
如果读者从未了解过SEH机制,请先阅读以下文章:
Windows SEH机制(一)
Windows SEH之全局展开
Windows SEH机制(二)
要使SEH发挥作用,必须得到编译器、硬件和操作系统的配合支持。SEH 在特定平台上的具体实现方式可能因架构而异。
下图总结了Windows异常处理机制的基本框架(摘自《加密与解密》):
这里先给出一个基本的示例程序,便于我们讨论:
64位中的SEH机制不再把异常处理下相关信息放到栈上,而是由编译器生成相关信息,并在放到PE文件中一个特定的节区中:.pdata。在64位SEH机制中需要学习的第一个结构就是RUNTIME_FUNCTION。若干RUNTIME_FUNCTION结构体组成一个数组,放在.pdata节区中 。该结构体定义如下:
该结构体包含三个字段:
通过PE工具,我们可以查看到该结构体数组。例如,对于上面的程序,使用cff程序查看异常目录:
这个结构体的数量和__try块的数量无关,而与包含__try块的函数有关(不管函数中有多少个__try块)。并且可以看到,RUNTIME_FUNCTION在.pdata中的排序方式是以BeginAddress为准的升序排序。
为什么这样排列?这是为了支持 O(log n) 时间复杂度的二分查找算法。当异常发生时,内核异常分发器(RtlLookupFunctionEntry)需要通过异常地址 RIP快速定位所属函数。二分查找是唯一能在对数时间内完成此任务的高效算法,而二分查找的前提条件就是数据集有序
注意,上面的三个地址都是相对于ImageBase的偏移量。所以尽管是在64位系统上,这三个地址也只有32位。
我们使用IDA来查看main函数对应的RUNTIME_FUNCTION条目。通过快捷键g就可以快速定位到main函数的RUNTIME_FUNCTION:
可以将前两个字段的值放到放到反汇编窗口中查看,就是main函数的起始和结束地址。
接下来说说UnwindData指向的UNWIND_INFO结构体。
这个结构体十分关键,其中的内容是SEH的核心。UNWIND_INFO结构体定义如下:
UNWIND_INFO结构体定义较为复杂,我们只关注其中的关键部分。
根据Flags的不同,UNWIND_INFO结构体的匿名联合体字段有不同的解释,ExceptionData字段是否有效也于Flags字段有关。
下面的UNWIND_INFO结构体的定义更好的展示了Flags字段的影响:
**为了快速讲清楚重点,这里我们只讨论Flags设置为UNW_FLAG_EHANDLER的情况。**一个典型的Flags被设置为UNW_FLAG_EHANDLER的函数如下:
当Flags设置为UNW_FLAG_EHANDLER时,此时union中的ExceptionHandler(编程语言相关)字段有效,由编译器负责填写。如果发生异常且指令指针为>= BeginAddress和< EndAddress,就调用该ExceptionHandler 。
ExceptionHandler会解析ExceptionData字段来确定如何处理发生的异常。
SCOPE译为范围,能力
ExceptionData是指向C_SCOPE_TABLE结构体的指针的偏移量(也就是指向C_SCOPE_TABLE的指针) ,该结构体定义如下(务必仔细阅读):
RUNTIME_FUNCTION描述了包含SEH的函数的整个范围,而SCOPE_TABLE描述了函数内每个单独的__try/__except块。
以上面的代码为例,生成的汇编代码大致如下:
套到RUMTIME_FUNCTION和UNWIND_INFO结构体如下:
到现在为止,我们可以总结一下x64的SEH机制如何定位到发生异常的地方:假设发生异常的地址为EA,先通过二分查找在RUNTIME_FUNCTION(比较规则:BeginAddress < EA < EndAddress)定位到发生异常的函数,然后遍历ExceptionData指向的C_SCOPE_TABLE_ENTRY数组,定位到发生异常的try块(比较规则:Begin < EA < End)。
这是查找的基本流程,但是我们可以再深入一些。
每当发生异常时,就会调用内部 Windows 函数 RtlDispatchException。该函数在用户模式异常的 NTDLL 模块中实现,在内核模式异常的 NTOSKRNL 模块中实现,方式略有不同。该函数通过执行一些初始检查来开始执行:如果存在用户模式VEH,则将调用该异常处理程序;否则将进行标准 SEH 处理。
标准SEH处理:复制异常时的线程上下文,并利用RtlLookupFunctionEntry函数来执行一项重要任务:获取PE文件的ImageBase和RUNTIME_FUNCTION结构。
现在,我们来追踪一个 64 位 Windows 程序发生除零异常 (#DE) 后,从 CPU 陷阱到最终执行 __except 块的完整、详细的系统级调用链和数据结构操作 。这将深入到内核和运行时库的内部。
1. 触发异常
2. CPU 硬件操作
3. 内核陷阱处理 (KiDivideErrorFault)
这是最核心的调度器。其内部逻辑复杂,但关键步骤如下:
1. 构建 CONTEXT 和 EXCEPTION_POINTERS
2. 首次尝试:用户模式异常分发 内核检查异常地址,发现 RIP 在用户空间。于是调用 RtlDispatchException 函数(这是用户态异常分发的内核入口)。
3. RtlLookupFunctionEntry 的查找过程 这个函数是第一阶段查找的具体实现。
4. __C_specific_handler 的工作(用户态回调!) 这是 MSVC 运行时库 (vcruntimexxx.dll) 提供的函数。注意:此时 CPU 仍在内核模式,但该函数是用户态代码,内核会临时切换到用户态执行它。
1. 内核收到 ExceptionExecuteHandler 后的操作 内核的 KiDispatchException 收到 RtlDispatchException 返回 TRUE,知道找到了处理程序。
2. RtlUnwindEx 的展开过程 这是实际的栈帧展开器,它解释执行 UNWIND_CODE 数组。
1. 最终返回 (KiContinuePreviousMode) 内核将修改后的 CONTEXT 写回陷阱帧,然后执行 iretq(或 sysret)指令,返回到用户态 。但返回的 RIP 不是原来的 idiv 之后,而是 __except 块的地址。
2. 用户态恢复执行
关键数据结构和调用链总结:
调用链总结如下:
上面的代码涉及到了较多的结构体,大家可以参考钱松林老师的《C++反汇编与逆向分析技术揭秘》一书。
在UNWIND_INFO结构体中有一个UnwindCode字段,指向UNWIND_CODE结构体。
UNWIND_CODE 结构体是 64 位 SEH 机制中的“展开脚本”或“逆操作指令集” 。它不直接参与异常处理的决策 (这是 SCOPE_RECORD 的工作),而是负责异常处理流程中的执行 环节——即如何安全、正确地将栈帧恢复到函数调用前的状态。这是实现栈展开(Stack Unwind)的基石。
你可以将函数调用过程想象成搭积木:
关键点 :异常可能发生在函数序言之后、尾声之前的任何位置 。系统必须能够从任意点 将栈恢复到函数入口之前的状态。UNWIND_CODE 提供了完成此操作所需的精确、逐步的逆操作指令 。
定义位于 winnt.h:
字段含义 :
让我们结合一个具体例子,跟踪流程。假设有以下函数:
编译器会为它生成以下逻辑的 UNWIND_CODE 数组(伪代码表示):
注意 :CodeOffset 是累加的。第一条对应序言第0字节(push rbx),第二条对应第1字节(push rsi),第三条对应第2字节(sub rsp,0x20)。
场景1:异常发生在序言之后,函数体内部 (例如在 sub rsp, 0x20 之后)
场景2:异常发生在序言执行过程中 (例如在 push rsi 之后,sub rsp,0x20 之前)
场景3:用于 __finally 的展开
当为 __finally 展开时,过程相同。UNWIND_CODE 确保栈被正确恢复,然后系统会跳转到 __finally 块执行清理代码,最后继续展开。
重要提示 :展开时的“pop”操作是逻辑上的 。实际过程是:
栈内存本身的内容不会被修改 ,只是 CONTEXT 被更新,为后续恢复执行做准备。
阶段
关键函数
关键数据结构
操作
硬件陷阱
KiDivideErrorFault
KTRAP_FRAME
保存用户上下文
内核分发
KiDispatchException
EXCEPTION_RECORD, CONTEXT
构建异常信息
用户分发
RtlDispatchException
-
协调分发流程
函数查找
RtlLookupFunctionEntry
RUNTIME_FUNCTION 数组
二分查找函数
展开信息
-
UNWIND_INFO, UNWIND_CODE
描述栈帧布局
作用域查找
__C_specific_handler
SCOPE_RECORD 数组
线性查找 try 块
栈展开
RtlUnwindEx
UNWIND_CODE
解释执行展开指令
返回
KiContinuePreviousMode
KTRAP_FRAME
恢复用户态上下文
操作码
含义
对应的序言指令示例
展开时的逆操作(由 RtlUnwindEx 执行)
UWOP_PUSH_NONVOL
压入非易失寄存器
push rbx
从栈上弹出值,并恢复 CONTEXT 中对应寄存器的值 。RSP += 8。
UWOP_ALLOC_SMALL
分配小栈空间
sub rsp, 0x20
RSP += 分配大小。
UWOP_ALLOC_LARGE
分配大栈空间
sub rsp, 0x1000
RSP += 分配大小。
UWOP_SAVE_NONVOL
保存非易失寄存器到栈上
mov [rsp+0x10], rbx
从栈上指定偏移处读取值,恢复 CONTEXT 中对应寄存器的值 。
UWOP_SAVE_XMM128
保存 XMM 寄存器
movaps [rsp+0x20], xmm6
恢复 CONTEXT 中的 XMM 寄存器。
UWOP_SET_FPREG
建立帧指针
mov rbp, rsp
将 RBP 恢复为 CONTEXT 中保存的调用者值。
UWOP_PUSH_MACHFRAME
压入机器帧(用于硬件中断)
由硬件中断自动完成
特殊中断恢复。
传播安全知识、拓宽行业人脉——看雪讲师团队等你加入!