ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

64位Windows SEH机制详解:从链式到表驱动的异常处理变革

64位Windows SEH机制详解:从链式到表驱动的异常处理变革 1. 64位SEH机制到底改了什么聊64位Windows的SEH机制得先把一个容易混淆的事情说清楚SEH这个缩写在32位和64位Windows上指的是同一套异常分发框架但实现方式已经完全是两套东西了。很多从32位时代过来的调试老手第一次在WinDbg里看64位进程崩溃时都会愣一下——怎么栈上找不到那串经典的_EXCEPTION_REGISTRATION_RECORD链表了这不是系统坏了而是64位下SEH已经不再把处理函数链挂在栈上。这套机制解决的核心问题其实一直没变当程序执行过程中触发异常除零、访问违例、非法指令、显式RaiseException系统需要找到合适的处理函数来处理它如果当前函数不处理就逐层往外层函数找最终还处理不了就把异常递交给默认的UnhandledExceptionFilter由WERWindows错误报告接管或者让调试器接手。32位时代这个“逐层找”的过程靠栈上的链式结构完成而64位时代改成了一张只读的处理函数查询表。这个改动表面看只是实现细节变化实际上影响了整个异常处理的安全模型、调试方式、漏洞利用思路甚至编译器生成代码的方式。这篇文章适合谁看我觉得三类人最应该读一是做Windows应用开发、想搞清楚__try/__except背后到底发生了什么的人二是做逆向分析和漏洞研究的需要理解为什么64位下传统的“覆盖SEH链”手法失效了以及拿到一个崩溃现场时怎么定位异常处理逻辑三是从32位迁到64位、被各种奇怪行为折磨过的老开发。我会尽量把里面“为什么这么做”的逻辑讲透再给一些实操中能直接用的排查命令和思路。1.1 先从异常分发链路说起无论32位还是64位Windows的用户态异常分发链路大体上是这样的CPU触发异常后控制权交给内核内核的异常处理代码对异常进行初步分类构造一个EXCEPTION_RECORD结构记录异常代码、异常地址、参数等信息然后通过KiUserExceptionDispatcher这个分发器把异常“扔回”用户态。紧接着用户态的RtlDispatchException上场它才是SEH机制真正的核心调度者。RtlDispatchException要做的事情是根据当前线程的上下文、异常发生时的指令地址确定应该让哪个处理函数来回答“这个异常你管不管”。32位下它遍历fs:[0]指向的链表64位下它根据异常指令地址去查可执行模块的异常处理表。确定处理函数后调用RtlpExecuteHandlerForException之类的执行器把异常记录和上下文传进去让处理函数决定是继续执行EXCEPTION_CONTINUE_EXECUTION、还是继续找上层处理EXCEPTION_CONTINUE_SEARCH、或者返回一个处理结果。这里有个关键点也是很多人容易误解的地方64位下并不是没有SEH而是SEH在64位下变成了“表驱动”的形式。通常我们说的“64位SEH”、“基于表的异常处理”Table-Based Exception Handling指的就是同一个东西。处理函数不再靠栈上的指针去发现而是靠模块映像中的静态元数据去索引。你可以把它理解成32位时代是一根绳子上串着很多牌子系统沿着绳子找到底64位时代是一本按地址排好序的目录系统拿到指令地址后翻目录直接查到这个地址属于哪个函数的哪个处理块。1.2 为什么微软要在64位下改设计这个问题其实挺有历史感。32位SEH链的致命弱点在于异常处理函数的指针存放在栈上。栈是什么是程序自己管理的一块内存充满了不可信的数据。一旦程序存在栈溢出漏洞攻击者可以通过覆盖栈上的SEH处理函数指针在异常发生时让系统跳转到攻击者指定的地址执行这就是经典的“SEH覆盖利用”。32位后期微软加了SEHOPSEH Overwrite Protection来做校验但这只是在原有脆弱模型上打的补丁问题从根上没有解决。64位架构对内存寻址和数据布局做了大量调整规则本来就比32位严格得多。微软借着64位这个新起点把SEH链改成查表方式处理函数指针不再出现在栈上而是放在只读的PE映像段中比如.pdata段和编译器生成的异常处理表。这样一来通过栈溢出覆盖栈上数据再也无法直接篡改异常处理函数的指向传统的SEH覆盖技术从机制上就失效了。这一点是整个64位SEH设计与32位最大的本质区别后面讲安全影响时还会展开。2. 快速回顾32位SEH理解对比才有坐标系在详细拆64位之前我先把32位SEH的整体结构讲一遍。倒不是说32位还有多少新东西可讲而是没有这个坐标系64位的很多设计选择你就看不出门道。老读者可以快速略过这一段但千万别完全不看因为后面我会反复拿两边做对比。2.1 32位链式SEH的工作流程32位Windows的SEH核心数据结构是_EXCEPTION_REGISTRATION_RECORD它包含两个字段Next指向链表中的下一个记录Handler指向当前节点对应的异常处理函数。线程环境块TEB的0x0偏移处存放着这个链表的头指针我们平时在调试器里看到了fs:[0]指向的就是这个头。_EXCEPTION_REGISTRATION_RECORD structure: 0x00 Next : Ptr32 _EXCEPTION_REGISTRATION_RECORD 0x04 Handler : Ptr32 _EXCEPTION_DISPATCHER当异常发生时RtlDispatchException从fs:[0]开始遍历这个链表对每个节点调用其Handler。Handler是一个类似回调的函数系统给它传入异常记录、注册记录地址、上下文、以及一个专用的DispatcherContext。处理函数返回一个_EXCEPTION_DISPOSITION枚举值常见的有四种ExceptionContinueExecution0修复后继续、ExceptionContinueSearch1不处理继续往上找、ExceptionNestedException2嵌套异常、ExceptionCollidedUnwind3与新异常冲突的展开。这个设计最关键的两个特征第一Handler指针保存在线程栈上第二链表顺序由编译器在函数入口点生成的代码动态维护每个函数进入时把自己的处理函数push到链表头部退出时再恢复。我一直觉得32位SEH很像一个用来登记“地下活动联系人”的名单名单本身藏在栈这个最危险的地方每个函数都往名单最前面加一笔出事时系统从前往后一个个打电话问。2.2 为什么32位SEH成了漏洞利用的重灾区正因为它把安全关键信息放在栈上32位SEH成了漏洞利用研究中绕不开的话题。栈溢出攻击里最典型的两种思路一种是直接覆盖返回地址劫持函数返回流程另一种就是覆盖SEH链上的Handler指针。后者比覆盖返回地址更好用因为异常机制本身自带“分发”能力栈溢出之后只要人为触发一次异常系统就会自动跳到被覆盖的Handler地址执行。我记得最典型的场景是攻击者先往栈里塞一段shellcode粗略估算一下溢出偏移把SEH链节点上的Handler修改为一个指向“pop pop ret”指令序列的地址然后在栈上构造一个跳板让这个指针最终导向shellcode。因为异常分发时栈指针SP正好指向我们可控的数据区域pop pop ret这种指令序列做完两次出栈后再ret就会从我们放置的地址开始执行。这就是为什么32位下SEH相关的漏洞利用文章里pop pop ret这几个字出现频率极高。这套攻击链非常成熟也让微软在32位系统上疲于应付。虽然最终加了SAFESEH针对模块编译期的函数表校验和SEHOP运行期检查SEH链完整性但由于32位SEH链在栈上这个事实无法改变加固终究只是提高门槛。64位设计干脆釜底抽薪不再把Handler放在栈上一劳永逸地断了这条路。3. 64位SEH核心表驱动异常分发的完整链路进入正题。64位Windows在异常分发上采用的是“基于表的异常处理”架构英文通常叫Table-Based Exception Handling或Metadata-Based Exception Handling。核心思想是每一个函数在编译时编译器会生成一份描述该函数异常处理逻辑的元数据这份元数据随模块一起加载放在只读内存中。异常发生时系统根据异常指令所在地址查找属于哪个函数然后读取该函数的元数据解析出真正需要调用的处理函数。3.1 关键的数据结构RUNTIME_FUNCTION、UNWIND_INFO与ScopeTable64位SEH这套体系里第一层要认识的结构是RUNTIME_FUNCTION它定义了函数地址范围和对应元数据之间的关系RUNTIME_FUNCTION structure: 0x00 BeginAddress : Uint32B 0x04 EndAddress : Uint32B 0x08 UnwindData : Uint32B三个字段都是相对某个基址的偏移量。BeginAddress和EndAddress表示函数在模块内的起始、结束偏移UnwindData指向与这个函数关联的展开信息UnwindInfo。x64程序的所有RUNTIME_FUNCTION都放在.pdata节里按地址升序排列形成一个有序数组。系统查表时用二分查找快速定位某个指令地址属于哪个RUNTIME_FUNCTION这也是为什么它要求有序。顺着UnwindData偏移找到的是UNWIND_INFO结构。这个结构描述了函数栈布局、非易失寄存器保存位置、以及几个关键标志位。真正和SEH逻辑相关的是UNWIND_INFO里的异常处理相关字段UNWIND_INFO structure (简化): 0x00 Version : 3 bits 0x00 Flags : 5 bits 0x01 SizeOfProlog : 1 byte 0x02 CountOfCodes : 1 byte ... 0x04 ... : UNWIND_CODE array 0x?? ExceptionHandler : RVA (可选) 0x?? ExceptionData : RVA (可选真正指向ScopeTable)Flags中有三个位很关键UNW_FLAG_EHANDLER表示函数有异常处理逻辑对应__try/__exceptUNW_FLAG_UHANDLER表示有展开处理对应__finally/析构UNW_FLAG_CHAININFO表示这个函数是链式信息需要继续查找父函数。如果设置了EHANDLER或UHANDLER那么UNWIND_INFO末尾会出现一个ExceptionHandler字段指向一个运行时解析函数通常是__C_specific_handler它负责在异常发生时去遍历ScopeTable并决定调用哪个处理函数。ScopeTable也就是__C_specific_handler要用的表才是64位SEH真正决定“哪个处理块来处理这个异常”的地方。它的结构大致是SCOPE_TABLE structure: 0x00 Count : Uint32B 0x04 ScopeRecord[0] : SCOPE_RECORD_ENTRY ... SCOPE_RECORD_ENTRY structure: 0x00 BeginAddress : Uint32B (相对函数起始的偏移__try块起点) 0x04 EndAddress : Uint32B (相对函数起始的偏移__try块终点) 0x08 HandlerAddress : Uint32B (相对函数起始的偏移__except过滤器/处理函数) 0x0C JumpTarget : Uint32B (相对函数起始的偏移处理完成后跳转目标)如果你写过或者逆向过32位的C代码看到ScopeTable可能会觉得眼熟。它确实和32位压缩过的异常处理信息有相似之处但32位通常放在数据段而且和栈上的链配合使用64位则完全依赖这份表表的每一项告诉你从BeginAddress到EndAddress这段指令区域如果发生异常应该调用HandlerAddress指向的处理逻辑处理完跳到JumpTarget继续执行。3.2 从异常发生到调用处理函数的完整过程我自己用文字把这套流程走一遍你对照着理解比看图更清晰。假设CPU执行一条非法指令触发异常。第一步内核捕获异常构建EXCEPTION_RECORD64包含ExceptionCode、ExceptionAddress等连同当时的CONTEXT64通过KiUserExceptionDispatcher返回用户态。第二步KiUserExceptionDispatcher调用RtlDispatchException。RtlDispatchException拿到异常地址和线程栈信息后开始模块级查找。它会遍历已加载模块列表判断异常指令地址落在哪个模块的范围内然后进入该模块的.pdata節用二分查找找包含这个地址的RUNTIME_FUNCTION。第三步顺着RUNTIME_FUNCTION的UnwindData找到UNWIND_INFO检查Flags。如果Flags没有EHANDLER/UHANDLER说明这个函数没有SEH处理逻辑RtlDispatchException就返回ContinueSearch异常继续向上层函数查找。如果确实有异常处理标志那ExceptionHandler字段指向的运行时代理函数被调用。第四步代理函数通常是__C_specific_handler它读取UNWIND_INFO末尾的ExceptionData指针拿到ScopeTable。ScopeTable本身也是编译器生成的记录了这个函数内部各个__try块的范围以及对应的过滤器、处理函数。第五步__C_specific_handler逐条比对ScopeRecord如果异常地址落在BeginAddress到EndAddress范围内并且过滤器返回EXCEPTION_EXECUTE_HANDLER就执行对应的处理函数随后设置JumpTarget作为继续执行的位置。如果过滤器和处理块都没有最终处理则返回ContinueSearch继续往上一层函数找。这整个过程中处理函数的地址完全来自只读的映像元数据没有经过任何栈上数据的中转。安全模型从“动态链式信任”变成了“静态表查询”这就是64位SEH在机制层面最核心的变化。3.3 为什么某些函数会有多个UNWIND_INFO有一个现象在64位下很常见甚至会让刚看.pdata的人困惑同一个函数在.pdata里可能出现多条RUNTIME_FUNCTION记录。比如一个函数中间有一大段__try区域编译器可能为这段区域生成独立的RUNTIME_FUNCTION和UNWIND_INFO而不是和函数的整体信息混在一起。这样子做的一个直接原因是x64要求栈展开信息必须精确定位到函数的每一条指令——当异常发生在__try块内时分发逻辑需要知道当前处于哪个异常区域、当前的栈布局是什么样子的如果整个函数只有一个大一统的UNWIND_INFO那么在__try区域的边缘处栈指针调整、寄存器保存状态可能表述不准确。实操中遇到这种多UNWIND_INFO的函数时不要觉得奇怪这是编译器在做“区域细化”。你在WinDbg里用.fnent看某个地址的展开信息如果发现返回的结构和预期不符可以考虑是不是看错了函数区域的入口地址可以尝试用ln或者.u到函数内部再看。另外一个相关现象是函数如果特别大或包含多个独立异常块编译器也可能为不同代码区段各生成一个RUNTIME_FUNCTION。这些细节平时写代码的人几乎感觉不到但对逆向调试来说是硬知识尤其是你在做栈回溯和异常展开分析时容易卡在这上面。4. 编译器的表现__try/__except在x64下如何被落地聊完系统侧的逻辑我们回到开发侧。大部分读者写代码时其实不直接和RUNTIME_FUNCTION打交道而是用MSVC的__try/__except/__finally或者C的try/catch。这些语言级语法在64位下编译出来会变成什么样子直接决定了我们调试时看到的东西长什么样。4.1 用一段测试代码看看编译产物我建议你自己也可以这样试一遍。写一个简单的x64测试程序#include windows.h #include stdio.h int TestSEH(int n) { int result 0; __try { if (n 0) { RaiseException(0xE0000001, 0, 0, NULL); } result 100 / n; } __except(GetExceptionCode() 0xE0000001 ? EXCEPTION_EXECUTE_HANDLER : EXCEPTION_CONTINUE_SEARCH) { printf(handler for 0xE0000001\n); result -1; } return result; } int main() { printf(result%d\n, TestSEH(0)); return 0; }用Visual Studio编译x64版本不要开优化或者开/O2也行。编译后你可以用dumpbin或WinDbg去查看TestSEH函数对应的RUNTIME_FUNCTION和UNWIND_INFO。用dumpbin可以这样dumpbin /headers TestSEH.exe dumpbin /unwindinfo TestSEH.obj如果你用的编译器版本支持/unwindinfo参数会直接打印UNWIND_INFO的细节包括Flags、代码数、以及异常处理表的CRC/偏移。如果看不到异常处理表留意是否有链接器参数没开。默认情况下MSVC的x64编译器会为带__try/__except的函数生成SEH相关元数据这些数据最终会被放入.pdata和.rdata也就是我会在调试器里找的东西。4.2 编译器生成的ScopeTable和实际地址计算把TestSEH函数编译出来后用WinDbg的下列方式可以确认ScopeTable0:000 ln TestSEH 0:000 .fnent TestSEH.fnent命令会显示函数的RUNTIME_FUNCTION、UNWIND_INFO、以及如果有异常处理器的地址。注意一件非常容易搞混的事UNWIND_INFO里存的ExceptionData是一个RVA相对虚拟地址但调试器显示的通常已经转换为VA。ScopeRecord里保存的BeginAddress、EndAddress、HandlerAddress、JumpTarget都是“相对函数起始地址的偏移”。也就是说真正的处理函数地址是函数基址加上这些偏移值。我自己在做逆向分析时会把函数基址记下来然后手动心算一遍偏移跟反汇编窗口里的代码对比这样能快速确认异常处理的逻辑。举个例子假设函数起始VA是0x140001000ScopeRecord里HandlerAddress是0x1A0则真正处理器地址是0x1400011A0。如果偏移指向的位置不是合理代码开头那很可能函数基址取错了或者这条RUNTIME_FUNCTION本身是链式的需要先解析父函数。4.3 C异常、析构函数与__finally在64位下的关系还有一点在64位下表现得特别明显就是C的try/catch和SEH会共用同一套底层展开机制。当你在64位代码中抛出一个C异常MSVC会调用CxxThrowException最终在系统层看来这依然是一个异常分发过程通过RtlDispatchException去查找处理函数。区别在于C异常的查找逻辑要复杂得多它需要根据异常类型去匹配catch块并且要在展开过程中调用所有中间作用域内对象的析构函数。因此64位下C异常处理不仅需要“找到处理函数”还需要“展开栈并析构局部对象”这就用到了UNWIND_INFO中的UnwindCode。编译器为每个需要析构的函数生成专门的展开代码异常机制在向前递进前逐个函数执行这些清理过程。__finally在语义上就是专门负责这种清理的而在64位系统里__finally块的调用同样通过UNWIND_INFO的UHANDLER标志来触发。所以从机制层面看__finally和C析构函数走的是同一条展开通道。我见过不少负数经验来自这个点有的人只有32位开发经验习惯性地认为__finally只是“编译期改写为一个伪造代码块”不太理解它和栈展开的绑定。在64位下你如果把__finally块放在一个很复杂、包含大量局部对象和手动栈调整的函数里调试时可能会看到栈回溯变得极其复杂。真遇到这种情况先把优化关到/Od再编一版对比往往能快速定位是不是展开元数据出了问题或者是不是编译器对某些边缘情况生成了奇怪的流程。5. 调试实操在WinDbg中观察64位SEH分发理论说得再多不动手调试一遍容易变成纸上谈兵。下面把我常用的调试步骤记录下来你可以拿一个会崩溃的程序做实验。5.1 异常发生时的第一手现场先用WinDbg打开或附加目标进程。当异常发生时调试器会中断下来默认就能停在异常分发的位置。这时候先别急着看栈也不要直接按g先看当前的异常信息。命令是!analyze -v这个命令会自动分析当前的异常和栈输出包括异常代码、异常地址、堆栈跟踪甚至尝试识别出是哪个模块、哪条指令产生的异常。如果崩溃位置有符号它会顺带把相关函数名列出来。然后我再手动看.exr ExceptionRecord.exr显示异常记录的全部字段包括异常参数。如果使用者在那个区域抛出了自定义异常代码这里的参数就能看到。接着用.ecxr这条命令会切换线程上下文到异常发生时的上下文让后续的栈回溯、反汇编都基于异常现场而不是调试器中断现场。做完再执行kn/ kv看到的栈就是异常发生时的真实调用链。5.2 从异常地址定位函数和SEH元数据拿到异常地址后我通常按这个顺序操作。先看它落在哪个模块lm m testseh再看它属于哪个函数ln ExceptionAddress如果是内部函数ln会给出最近的符号。没有符号的模块也没关系用.pdata查表0:000 .pdata.pdata命令会列出当前模块的RUNTIME_FUNCTION表。这里数据量可能很大我需要结合函数地址范围去查。一条RUNTIME_FUNCTION通常在模块内是一个VA如果我要手动匹配最简单的方法是先用lm找到模块基址再查看.pdata输出找到BeginAddress和EndAddress包住目标异常地址的那一项。找到对应的RUNTIME_FUNCTION之后.fnent RUNTIME_FUNCTION的UnwindData或函数入口.fnent输出里会明确显示ExceptionHandler字段和ExceptionHandlerData。看到这里你就找到了代理函数的地址和ScopeTable的地址。需要把ExceptionHandlerData对应的内存dump出来dq ExceptionHandlerData L10如果表带符号WinDbg可能直接显示为SCOPE_TABLE。如果没符号就只能手动按刚才讲的结构解析了。我在实际排一个64位崩溃时有一次就是这样定位到一个非常隐蔽的问题程序在某个__try块里调用了第三方DLL的函数DLL内部又触发了异常最后直接跳到JumpTarget去执行跳转目标并不是我们预期的代码路径。如果不是靠ScopeTable强行定位光看栈回溯根本发现不了这个跨模块的异常处理顺序问题。5.3 用调试器验证ScopeRecord的匹配逻辑当你已经从ScopeTable里读出几条ScopeRecord建议在反汇编窗口里验证一下。找到ExceptionHandlerData指向的地址用以下几种方式解析dp ExceptionHandlerData L10这条会把记录的原始字节打出来。对照结构体定义就可以逐字段解读。然后为了确认某个ScopeRecord的HandlerAddress指向的确实是我们预期的处理函数可以这样反汇编u 函数基址 HandlerAddress - 模块基址偏移误差主要出在“基址”到底是模块基址还是函数起始地址。ScopeTable中的偏移是相对函数起始地址的所以在计算前要先用ln/fnent把函数的起始VA拿到再相加。这个计算顺序一旦搞错反汇编出来的就是完全不存在或无关的地址排查时很容易走弯路。5.4 一个实用的小习惯开启异常分发打印有时候异常被层层处理最终被某个隐蔽的处理函数吞掉了调试器根本看不到“原始异常”。这种情况我会提前启用异常分发相关的事件打印sxe eh这条命令配置调试器在发生C异常时中断。对原生SEH异常可以关注0x40000015相关的状态异常以及各种访问违例。如果你怀疑某个模块异常被死吞还可以用!gflag kd把内核调试器输出异常分发详细信息打开不过这个需要内核调试环境普通用户态调试用不上。用户态下心智模型更简单如果某个异常被__except吞掉那在异常发生后你通常会看到从KiUserExceptionDispatcher到RtlDispatchException再到__C_specific_handler的调用栈如果在栈里已经看不到这些函数说明早就处理完了应该重新设置断点在异常发生的第一时间暂停。6. 实战中必须避开的坑安全、兼容与老经验的误区这块内容完全是靠实践踩出来的。很多网上文章讲到64位SEH就一句“改成了基于表的处理更安全了”这个结论没错但中间的细节很容易让人误解。6.1 传统SEH覆盖利用在64位下为什么失效了32位下攻击者想劫持SEH目标是栈上的Handler指针。64位下Handler地址来自.pdata和异常处理表这两块映射在内存中的只读数据。栈溢出能改写栈上的数据但改写不了内存映射的只读页。因此传统思路里面最重要的“改写Handler指针”这一环在64位下找不到攻击面了。所以你会看到现在的漏洞利用研究里已经很少有64位SEH覆盖这种提法取而代之的是其他思路。但这不代表64位SEH绝对安全。异常处理表本身如果可以被伪造或者加载了不受信任的模块Windows上的安全也没法保障。所以微软在64位系统上做了更多加固模块加载时可以校验异常处理表的合法性CFG控制流保护也会对间接调用目标做校验。这些一起构成了纵深防御体系。做安全研究的人看64位SEH时重点应该放在“这个模块的异常表是否存在伪造可能”“CFG是否开启”“模块是否带SAFESEH标志”这些问题上。6.2 SAFESEH与64位下异常表的关系32位编译时有个/SAFESEH链接器选项它会把模块允许的合法SEH处理函数索引集中到一个表里运行时分发时校验处理函数是否在这个表内。因为这个机制本身依赖模块的只读表所以64位系统也吸收了类似思想不过实现上更底层它直接要求所有异常处理器都通过UNWIND_INFO/ ScopeTable这种固定格式来注册。换句话说64位下编译器在生成模块时就已经强制使用严格格式不允许程序在运行时动态安装自定义SEH处理函数。如果你从32位时代带过来的老代码靠_set_se_translator之类的API做转换在64位下仍可工作但底层已经没有了手动插入链的机会。这个变化导致一些老式调试工具、注入工具在64位进程里观察SEH时看到的是空的很多新手因此误以为“64位程序没有SEH”。6.3 从32位移植到64位的几个典型错误第一不要在内联汇编里写SEH相关操作。x64下MSVC已不支持_inline assembly如果你有一段32位代码直接操作fs:[0]来访问TEB在x64下必须改为__readgsqword或使用WinDbg查看。注意x64下TEB的访问使用的是gs段。第二__try/__except不能跨越函数边界。在x64下这一点比32位更严格编译器对“在同一个函数里使用__try”有更明确的限制。如果你写了一个宏宏内用了__try展开后可能出现在另一个函数体里这时MSVC会直接报错。应该把__try封装到一个独立函数中或者用C RAII机制替代。第三异常处理块中不要随便使用需要展开的C对象。在x64下如果__except块内返回EXCEPTION_EXECUTE_HANDLER跳转时会执行栈展开这时候带有析构函数的局部C对象会被正常销毁。若你从32位迁来可能习惯在__except里执行一些赋值和重置操作这些其实一般没问题但如果你在__try块内使用了带有必须保证释放的资源建议显式使用__finally或RAII别依赖SEH的隐式顺序。第四打开/O2优化后代码布局会变ScopeTable的范围会变调试时看到的BeginAddress/EndAddress未必跟你写的__try块一一对应。排查优化过的64位代码先关优化或者直接把异常处理逻辑抽到小函数里会省很多时间。7. 常见问题排查与实用技巧最后整一个实操中最常用的排查速查。下面这些问题我都见过真实案例每一条背后都有具体场景直接对着用即可。7.1 崩溃后看不到异常处理回调现象程序内部崩溃但__except里设置的断点根本没有命中程序直接退出了。可能原因一异常发生后过滤器返回EXCEPTION_CONTINUE_SEARCH而非EXCEPTION_EXECUTE_HANDLER。检查一下过滤器的条件尤其是GitCode里做了“”判断却由于异常代码位数不匹配导致失败的情况。可能原因二异常发生在SEH保护范围之外比如在某个完全没有__try的外层函数里。这时即使内层有__try如果外层没有处理逻辑系统会直接递交给默认处理器。可能原因三非MSVC编译器或使用了特殊编译选项导致异常处理表格式不符合系统预期。在64位下如果编译器不支持正确的UNWIND_INFO模块加载时可能就直接失败。排查思路先按上文流程看异常现场和栈回溯确认异常地址在哪个函数、那个函数有没有EHANDLER标志。如果没有再查调用栈上更上层的函数。如果上层也没有那就说明当前代码路径全部没有SEH处理应该在更外层调用方加__try/__except。7.2 明明写了__try.pdata里却没有异常标志现象用.fnent看函数信息发现没有ExceptionHandler字段或Flags里没有EHANDLER。原因极大概率是__try被编译优化掉了。比如一个空的__try块、一个不可能触发异常但也没有任何代码的块编译器在优化时直接把这块元数据删除了。还有一种情况是__try块内的调用被内联优化异常处理逻辑被合并到调用目标函数里。处理方式把相关函数标记为__declspec(noinline)或者编译时使用/Ob0禁止内联或干脆把__try和可能_引发异常的代码放到一个独立小函数中。如果你确实需要保留__try但又不想被优化影响还可以用编译指令/volatile:ms强制指定某块用MSVC的可变语义但不一定能解决优化删除元数据的问题最稳妥的还是设置断点看生成的汇编。7.3 我的一些调试习惯与小技巧最后分享几个我平时用得比较顺手的小习惯。第一个在WinDbg里我喜欢配合符号服务器加载ntdll的符号这会让RtlDispatchException、KiUserExceptionDispatcher这些关键函数的内部步骤清晰可见。没有符号的时候自己在这些函数入口都打断点观察调用栈的进出顺序一样能判断分发链路。第二个如果崩溃现场被处理函数吞掉了且程序并没有退出我会用“内存搜索法”在崩溃后的现场内存里搜已知的SEH表地址或者ScopeTable的地址往往能找到异常分发过程留在栈上的中间产物。例如RtlDispatchException在处理过程中会把异常记录和上下文地址留在某个位置通过搜索栈上是否有指向ExceptionRecord的指针我可以还原出异常现场的寄存器快照然后手动构造CONTEXT继续分析。第三个在做漏洞分析时我强列建议对目标模块编译时开启/guard:cf和/SAFESEH这类选项这样即使模块存在潜在异常逻辑问题系统层的保护也能拦截很大一部分异常跳转。如果目标程序为了兼容性把这些保护关了那分析范围就要扩大到所有以异常处理为跳板的可能利用路径。第四个写代码的人如果想让SEH处理更可控可以在__except的过滤器中主动调用GetExceptionInformation获取EXCEPTION_POINTERS结构把异常地址和上下文完整地记录到日志文件方便后期分析。64位下这个结构依然是合法的但在过滤器里做太多复杂操作会影响性能建议只做记录不要做耗时计算。8. 结尾一点经验总结这篇文章从32位SEH的链式结构讲到了64位的表驱动模型再到调试实操和避坑指南。自己这些年跟Windows底层异常处理打交道最大的感受是这玩意儿看着复杂但只要把“异常发生后系统如何找到处理函数”这条主线抓住所有的细节都是围绕这条主线展开的。32位靠栈上链表64位靠只读元数据表所以调试方法也完全变了。如果你是从32位时代走过来的老程序员我建议你有空就在WinDbg里拿自己编译的64位测试程序跑一遍看看.pdata、UNWIND_INFO、ScopeTable到底长什么样跟本文的结论交叉验证一遍这种亲手看到的印象比看多少篇文章都深。后面如果再遇到SEH相关的异常崩溃你至少能知道该去哪个表里翻处理函数而不是像无头苍蝇一样在调用栈里打转。
返回列表