ARTICLE DETAIL

资讯详情

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

深入解析32位Windows下C++异常机制:从SEH链到FuncInfo

深入解析32位Windows下C++异常机制:从SEH链到FuncInfo 1. 32位下C异常的入口从FS:[0]这条SEH链说起如果你反汇编过一个32位的老程序一定见过这样的序列函数序言里不是简单的push ebp / mov ebp, esp而是紧跟几条奇怪的指令——push -1、push offset _ehhandler$xxx、mov eax, dword ptr fs:[0]、push eax、mov dword ptr fs:[0], esp。第一次见到的人很容易懵这是什么魔法其实这整段就是C异常机制在32位Windows下的地基。在32位Windows中FS段寄存器指向当前线程的TEB线程环境块而TEB偏移0的地方保存着一个指针指向当前线程的SEH链头。所谓SEH链就是一个栈式的异常处理注册链表。每个节点是_EXCEPTION_REGISTRATION_RECORD结构struct _EXCEPTION_REGISTRATION_RECORD { _EXCEPTION_REGISTRATION_RECORD* Next; // 下一个节点链表尾为 0xFFFFFFFF PEXCEPTION_ROUTINE Handler; // 异常处理器入口 };这个链表是后来者居上的后进先出结构。当异常发生时系统从FS:[0]开始沿着链表逐个调用Handler直到有一个处理器返回我已处理。C编译器正是把这套机制当作了自己的传送带。每个可能抛出异常或包含try/catch的函数都会在栈上插入一个SEH帧并把Handler指向__CxxFrameHandler3。反汇编时你看到的push offset _ehhandler$xxx这个_ehhandler$xxx通常是一个跳板__ehhandler$?may_throwYAX_NZ: mov eax, offset __ehfuncinfo$?may_throwYAX_NZ jmp __CxxFrameHandler3也就是说编译器把每个函数的异常元数据打包成了一个名为__ehfuncinfo$函数名的结构体随后把这个结构体指针交给__CxxFrameHandler3。__CxxFrameHandler3这个SEH处理器拿到函数异常信息结构之后才开始真正的工作查表、匹配catch、执行栈展开。理解这层关系后32位C异常反汇编的分析路径就清楚了不是先看catch代码而是先在函数入口找到SEH帧确认Handler再顺着__ehfuncinfo符号找到异常元数据。我见过不少人卡在第一步一直在catch块里打转却不知道异常是怎么被路由过去的。其实从FS:[0]出发整条链是线性的数据结构也非常规整。2. FuncInfo家族异常元数据里的四张核心表把__ehfuncinfo符号对应的数据在反汇编器里展开你会看到一个8字段的头部它的C语言定义大致长这样struct FuncInfo { unsigned int magicNumber; // 魔数恒为 0x19930520 unsigned int maxState; // 最大状态编号 unsigned int pUnwindMap; // UnwindMap 表偏移 unsigned int nTryBlocks; // try 块数量 unsigned int pTryBlockMap; // TryBlockMap 表偏移 unsigned int nIPMapEntries; // IP 映射表条目数 unsigned int pIPToStateMap; // IP 到状态的映射表偏移 unsigned int dispUnwindHelp; // 辅助展开信息的有符号偏移 };其中magicNumber是识别该结构的重要标志。反汇编时如果你在数据段搜索十六进制20 05 93 19就能快速定位一个函数是否包含C异常元数据。0x19930520这个值就是MSVC的版本签名从VC4.0时代沿用至今。这个头部后面牵出四张表它们的协作关系是理解整个机制的关键表名作用关键字段UnwindMap记录每个状态转换时需要执行的析构动作toState、actionTryBlockMap描述当前函数内每个try块的边界和catch集合tryLow、tryHigh、catchHigh、nCatches、pHandlerArrayCatchHandlerType描述每个catch子句的类型和处理器位置adjectives、typeId、dispCatchObj、dispOfHandlerIPToStateMap把异常发生时的EIP映射成状态编号ip、state先看IPToStateMap。它的条目是成对的ip, stateip按递增排序state表示执行到该地址时函数处于哪一段逻辑区域。正数表示在某个try块内部-1表示不在任何try块。当__CxxFrameHandler3被调用时系统从CONTEXT结构里取出异常指令的EIP用二分查找在这个表中定位对应的state。这也是为什么这张表必须有序存放。再看TryBlockMap条目struct TryBlockMapEntry { int tryLow; // 进入该 try 块的起始状态编号 int tryHigh; // try 块结束状态编号 int catchHigh; // catch 处理器可以覆盖的最大状态编号 int nCatches; // catch 子句数量 int pHandlerArray; // CatchHandlerType 数组偏移 };注意这里的tryLow / tryHigh不是IP地址而是状态编号。它把一段IP区间对应一个状态编号和哪些状态属于哪个try块这两件事分开了。边界判定通过状态编号进行这样做的好处是编译器只需维护一张线性变化的IP映射表嵌套try块则用状态区间的包含关系来表达不需要在try/except的嵌套上做复杂的递归分析。CatchHandlerType表是整个机制里最干货的部分struct CatchHandlerType { unsigned int adjectives; // 修饰符标志按引用/常量/易失捕获等 int typeId; // 指向 type_info 的偏移 int dispCatchObj; // catch 对象在栈帧中的偏移 int dispOfHandler; // catch 处理器代码地址偏移 };其中dispOfHandler就是异常匹配成功后要跳转到的代码入口。dispCatchObj告诉运行时异常对象应该被写到当前栈帧的哪个位置这样catch子句的参数才能被正确初始化。而adjectives里包含的信息编译器在生成代码时固定写死比如catch (const MyException e)会被标记为按引用、按常量。最后是UnwindMap。它的条目也很简单struct UnwindMapEntry { int toState; // 要回退到的目标状态 int action; // 执行动作通常是指向析构函数/清理代码的偏移0 表示无动作 };当catch匹配成功、需要展开栈帧时运行时从当前状态开始沿着状态编号逐步回退到目标状态每回退一步就查找UnwindMap中对应的action如果非0就跳过去执行。这就是局部对象在异常路径上被正确析构的底层依据。这四张表全部选用偏移而非绝对地址。为什么因为异常元数据经常被嵌入不同模块、不同基址的PE文件中。若存绝对指针加载地址一变就得做重定位存偏移通常相对于某项数据自身的ImageBase则可以在不改动原始字节的情况下自由重定位。反汇编器在展示这些字段时往往会直接渲染成offset xxx这样的可读形式但你自己追踪时看到裸露的dd 00003000这类数值时要意识到它多半是一个RVA需要用所在模块基址去换算。3. 反汇编实战一个try/catch函数从throw到catch的完整链路纸上谈兵没有意义下面用一个真实可编译的小例子走一遍32位反汇编。源码如下class MyException { public: int code; }; void may_throw(bool trigger) { if (trigger) { MyException ex; ex.code 42; throw ex; // 按值抛出 } } int main() { try { may_throw(true); } catch (const MyException e) { printf(caught: %d\n, e.code); } return 0; }用VS2019以32位Release/O1/GS-编译后may_throw函数的反汇编主体如下?may_throwYAX_NZ: push ebp mov ebp, esp push -1 ; IPToStateMap 的初始状态-1 表示“无状态” push offset __ehhandler$?may_throwYAX_NZ mov eax, dword ptr fs:[0] push eax mov dword ptr fs:[0], esp ; 把自己的 SEH 帧挂到链头 movzx eax, byte ptr [ebp8] ; 读取 trigger test eax, eax je short loc_ret ; 不触发则直接返回 push 4 ; sizeof(MyException) call ??2YAPAXIZ ; operator new add esp, 4 test eax, eax je short loc_throw ; new 失败也走抛出路径抛 bad_alloc mov dword ptr [eax], 2Ah ; ex.code 42 loc_throw: push offset __TI1?AUMyException ; ThrowInfo 偏移 push eax ; 异常对象地址 call __CxxThrowException ; 内部会调用 RaiseException loc_ret: mov ecx, dword ptr [ebp-0Ch] mov dword ptr fs:[0], ecx ; 还原 SEH 链头 pop ebp retn注意几个关键点。第一这段代码里看不到任何对析构函数~MyException()的显式调用因为MyException是平凡类型没有自定义析构。如果它是一个含std::string成员的类throw逻辑里就会在异常对象构造完成后额外登记UnwindMap的清理动作。第二push offset __TI1?AUMyException中的__TI前缀是ThrowInfo符号。反汇编时看到__TI开头的地址可以直接断定这里发生了C异常抛出。MSVC为每个异常类型生成的元数据符号有一套命名规律符号前缀对应结构??_R0?AU...8type_info类型描述符__TI?AU...ThrowInfo__CTA?AU...CatchableTypeArray__CTT?AU...CatchableType这套符号规律比任何结构体手册都实用。在IDA里搜索__TI就能批量定位所有throw点。接下来看main函数。它同样有自己的SEH帧并且在try块区域存在IP映射?mainYAHXZ: push ebp mov ebp, esp push -1 push offset __ehhandler$?mainYAHXZ mov eax, dword ptr fs:[0] push eax mov dword ptr fs:[0], esp ; 注册 main 的 SEH 帧 push 1 call ?may_throwYAX_NZ add esp, 4 ; try 块正常结束 xor eax, eax mov ecx, dword ptr [ebp-0Ch] mov dword ptr fs:[0], ecx pop ebp retn __catch$?mainYAHXZ: ; catch (const MyException e) 的处理器入口 ; 此时 eax 指向已经被运行时“搬”到栈帧上的异常对象 mov dword ptr [ebp-14h], eax ; dispCatchObj 所在的槽位 mov eax, dword ptr [ebp-14h] mov eax, dword ptr [eax] ; e.code push eax push offset format_string ; caught: %d\n call printf add esp, 8 ; 清理动作后返回处理完成 retnmain函数里没有显式跳转到__catch的jmp指令。这说明catch处理器不是被普通控制流调用的而是由__CxxFrameHandler3在异常分发阶段通过CatchHandlerType的dispOfHandler字段把EIP直接指到这里。完整的链路可以这样串起来may_throw里的throw ex它的实际行为是在堆上构造一份异常对象拷贝operator new 拷贝构造然后调用RaiseException(0xE06D7363, 1, 3, ...)。0xE06D7363是MSVC的C异常专用异常码看到这个十六进制数基本可以断定是MSVC C异常。系统异常分发机制开始遍历FS:[0]链。现在栈上挂着两个SEH帧先是may_throw的帧再是main的帧。系统先调用may_throw的Handler也就是__CxxFrameHandler3。__CxxFrameHandler3从__ehfuncinfo$?may_throw里读取FuncInfo用异常时刻的EIP查询IPToStateMap。由于异常发生在may_throw函数内部这时的状态对应的是——不受任何try块保护的普通区域也就是state为-1。找不到匹配的catch它就返回ExceptionContinueSearch系统继续沿着SEH链找下一个节点。系统接下来调用main的Handler。这里IPToStateMap会命中try块区域状态值为0假设try块映射到了状态0。__CxxFrameHandler3顺着TryBlockMap找到该try块绑定的catch集合对CatchHandlerType数组逐条做类型匹配。类型匹配时它会拿__TI1?AUMyExceptionThrowInfo指向的CatchableTypeArray里记录的类型与catch子句的typeId做比较。本例catch的是const MyException实际抛出的类型恰好是MyException在派生关系上完全一致于是匹配成功。匹配成功后运行时先按照UnwindMap展开栈帧把may_throw函数状态从异常点回退到调用前的干净状态同时析构中途创建的局部对象再把异常对象拷贝到catch子句要求的栈槽位dispCatchObj指出地址最后把EIP改为dispOfHandler指向的__catch$?mainYAHXZ入口。catch块执行完毕后通过retn回到调用方。main之后的正常控制流其实是从try块之后那个xor eax, eax继续的catch块里用retn配合运行时修正栈指针实现了这个看起来像函数调用、实际上是异常控制流的无缝衔接。整条链路走完后反汇编层面的骨架就非常清晰了。你不需要在每一条汇编指令上死抠重点是先找到SEH帧再找到__ehfuncinfo最后顺着四张表理清IP、状态、catch入口三方关系。4. 32位与64位异常机制的本质差异以及我在调试中踩过的坑聊完32位的结构很多从64位入手的朋友会疑惑为什么64位反汇编里见不到FS:[0]这套SEH链因为x64下的Windows已经抛弃了基于栈注册链的SEH实现改用table-driven unwinding表驱动展开。这两种模型的差异不是简单的“换个寄存器”那么简单而是整个异常分发架构的换代。对比维度32位 MSVC64位 MSVC注册方式函数入口把SEH帧压栈并挂到FS:[0]链编译期把展开数据放到.rdata运行时不修改栈帧结构查找方式遍历FS:[0]链逐个调用Handler通过RtlLookupFunctionEntry从RUNTIME_FUNCTION表里二分查找处理器函数主要是__CxxFrameHandler3新编译器用__CxxFrameHandler4FuncInfo 头前4字节是0x19930520前4字节是0x19930522字段布局不同展开方式处理器在既定栈帧内飞线跳转使用RtlVirtualUnwind等系统调用逐帧展开异常对象传递依赖栈帧偏移dispCatchObj定位catch形参主要通过寄存器/影子栈区域传递异常对象路径更直接这里有个很容易误会的点不少人以为64位程序里没有异常处理器其实只是它的处理器不再挂在SEH链上而是被PE的.pdata段里的RUNTIME_FUNCTION条目引用。所以分析64位异常时应该在PE文件的异常目录.pdata里找RUNTIME_FUNCTION再顺着找到__CxxFrameHandler4和它后面的UnwindInfo——这是一套完全不同的路由。在实际调试中我总结过几个教训都是容易被资料忽略的第一不要只看函数的开始几条指令判断它有没有异常帧。32位下有些内联函数、叶子函数不生成SEH帧但它内部可能调用了可能抛异常的代码。反汇编时真正的异常边界标注在指令的IP映射上而不是函数里有没有try关键字。在IDA里打开函数列表时如果函数旁边有__ehhandler$符号说明它注册了异常处理器如果只有普通的序言/尾声则可能只是普通函数。第二类型匹配失败时不要急着怀疑catch写错了。检查两个地方一是ThrowInfo的CatchableTypeArray里列了几个类型二是CatchHandlerType的typeId指向的type_info字符串。有时候异常对象是一个基类指针实际运行时是派生类而catch写的是派生类引用但编译器在throw点只登记了基类的CatchableType信息导致匹配失败。这个在存在多重继承时尤其坑——PMD多态布局位移会让实际要访问的异常子对象和type_info指向的类型对不上。一旦你发现catch匹配规则和你预期的继承关系不一致就应该去看__CTA数组里到底列举了哪些类型。第三UnwindMap里的action不一定是析构函数入口也可能是一段编译器生成的清理代码。早期我天真地认为它永远指向??1MyExceptionQAEXZ这类符号后来分析一个带智能指针的复杂函数时发现action指向的是一个内部标签里面依次执行了多个成员对象的析构。编译器会把连续几个需要析构的对象打包进一段清理代码而不是每个对象对应一条UnwindMap项。所以分析UnwindMap时要把action字段当作“一段清理例程”的入口而不是“一个对象的析构函数”。第四排查“catch没接住”的问题时别急着在catch里下断点。正确做法是先在__CxxFrameHandler3入口下断观察它被调用的次数和传入的FuncInfo。如果你在main的Handler里看到传入的FuncInfo属于内部库函数而不是你自己的函数说明异常在被你的try/catch接手之前已经被某个库函数的处理器处理过并吞掉了。这种问题在旧版STL容器配合32位程序时尤其常见往往是容器的内部临时对象先匹配了某个catch (...)导致外层感知不到。第五32位程序在升级编译器后异常元数据的细节可能有微妙差异。比如VS2015之前的FuncInfo和VS2017之后的虽然整体布局一致但某些字段的语义在带/EHa异步异常和/EHsc同步异常选项下略有不同。分析第三方32位程序前最好先确认它用的是哪个时代的MSVC。最简单的方法是看PE的链接器版本戳再结合__CxxFrameHandler3出现的字符串特征做判断。我在分析一个工业软件旧组件时曾经为了一个“异常被吞但退出码异常”的问题折腾了整整一天。最后定位到根源该组件用/EHa编译在SEH的__except过滤器里接住了全部硬件异常但过滤器里又触发了C异常导致旧版本的__CxxFrameHandler3在解析UnwindHelp时拿错了栈基址把catch跳转地址算到了无效页面上。这属于编译选项和异常模型之间的兼容性边界不是常规分析能轻易看出来的。遇到这种诡异场景我的建议是把IPToStateMap、TryBlockMap、UnwindMap三张表同步导出手动走一遍状态转换基本都能找到问题点。不要靠猜异常处理不是一个“看逻辑就能知道结果”的路径它是一台严格的状态机状态变化表就是它的全部行为。最后分享一个我惯用的定位技巧在WinDbg里对32位程序用!exchain命令可以快速列出当前线程的SEH链逐帧查看Handler是不是__CxxFrameHandler3。配合dt ntdll!_EXCEPTION_REGISTRATION_RECORD看节点结构能在几分钟内确认异常帧的挂载顺序。而在x32dbg中异常窗口直接显示每条SEH链上的处理器地址配合函数符号比盲翻汇编要高效得多。理解了FS:[0]这条链的逻辑再回头看最开始那五行奇怪的push/mov指令你会觉得它比普通函数序言还要直白。
返回列表