(译)MSVC++ 逆向(一) —— 异常处理 | JiaYu's Blog
原文: http://www.openrce.org/articles/full_view/21
摘要
MSVC++ 是编写 Win32 应用程序最常用的编译器,所以在 Win32 平台的逆向工作中,懂得其底层工作原理,对逆向工程师来说至关重要。掌握 VC++ 程序的底层原理之后,便能在逆向过程中精准、快速识别编译器生成的胶水代码(Glue Code),这样可以让逆向工程师快速聚焦于二进制文件背后的真实程序和真实逻辑。另外,这对还原程序中高层次的结构(译注:面向对象的数据结构和程序组织结构、异常相关数据结构等)也有莫大帮助。
本文只是系列文章的上半部分(下半部分见: Part II: Classes, Methods and RTT),主要讲栈展开、异常处理以及 MSVC 编译生成相关的数据结构。阅读本文需要有汇编、寄存器和调用约定相关的知识储备,当然,MSVC++ 的编程基础知识也是必要的。
名词解释:
- 栈帧(Stack Frame):栈 中为函数所用的一个 片段,里面通常包含函数相关的参数(Arguments)、返回地址(Return-to-Caller Address)、保存的寄存器状态、本地变量(Local Variables)和一些其他的数据。在 x86 架构(以及其他多数架构)中,栈里调用和被调用的函数,栈帧通常是连续的;
- 帧指针(Frame Pointer):一个指向栈中特定位置的指针,指针值通常保存在寄存器或某个变量中。访问函数中的数据,一般通过帧指针和特定偏移量来实现。在 x86 架构里,帧指针通常保存在寄存器
ebp中,并且,在栈布局结构中位于返回地址的下方;- 对象(Object):C++ 类的实例;
- 可销毁对象(Unwindable Object):又叫局部对象,一个具有
auto作用域的本地对象,当帧指针访问其作用域之外的位置时该对象会被销毁(译注:函数内部本地变量的默认作用域就是auto,函数被调用的时候其内部变量及其他数据被生成到栈上,调用完毕就会销毁这段栈的片段,其内部变量也就随之被销毁);- 栈展开(Stack Unwinding):触发异常的时候,将暂停当前函数的执行,根据
C++的try/throw/catch异常处理流程或SEH异常处理机制,会在栈段中线性搜索对应的异常处理函数,如果当前栈帧中没有相应的异常处理模块,就会退出当前函数,释放当前函数的内存并销毁局部对象,继续到上层调用函数中寻找对应的异常处理模块,直到找到可以处理该异常的模块……这个过程就是栈展开。
在 C/C++ 程序中,可用的异常处理机制有两种:
- SEH 异常:即结构化异常处理(Structured Exception Handling),也称作 Win32 异常 或 系统异常,这部分内容在 Matt Pietrek 的 Paper[1] 里有详尽的讲解。该机制是 C 程序仅有的异常处理机制,在编译器层面支持的关键字有
__try/__except/finally等等;- C++ 异常:实现于 SEH 链的顶层,并且支持任意类型的
throw和catch。该异常处理机制一个非常重要的特性是在异常处理过程中的自动栈展开,MSVC++ 为了支持这一特性,在底层做了非常复杂的处理。
译注:
根据 VC++ 中的异常处理 | MSDN 所述,自MFC3.0起,MFC 程序中可以使用 MFC 特有的异常处理机制——MFC 异常。
内存中,栈是由高地址向低地址方向增长的,所以在 IDA 中看到的栈,是向上增长的。
栈的内存布局
基础的栈布局如下所示:
1 2 3 4 5 6 7 8 9 10 11 12 | --------------- 局部变量 --------------- 其它寄存器的值 --------------- 原栈基址 %ebp --------------- 返回地址 --------------- 函数参数 --------------- …… |
具体点,如下图所示:
NOTE:
如果设置了FPO(Frame Pointer Omission, 框架指针省略),原栈基址 %ebp可能就不存在了。
SEH
当涉及到编译器层面的 SEH(__try/__except/__finally) 时,栈的内存布局就会变的复杂一些:
一个函数中如果没有 __try 语句块(只有 __finally),Saved ESP 就不会存在。另外,作用域描述表( scopetable )是一个记录每一个 try 块及其关系描述的数组:
1 2 3 4 5 | struct _SCOPETABLE_ENTRY { DWORD EnclosingLevel; void* FilterFunc; void* HandlerFunc; } |
更多 SEH 具体的实现细节可以查阅参考资料[1]。为了恢复 try 语句块,需要监控 try 层面的变量变化。 SEH 为每一个 try 语句块分配了一个编号,语句块之间的相互联系用上面的 scopetable 结构体来描述。举个栗子,假设编号为 i 的 scopetable ,其中属性 EnclosingLevel 值为 j,那么编号为 j 的 try 语句块会把 i 闭合在自己的作用域内。然后该函数的 try level 可以认为是 -1 。具体例子可以参考附录1。
栈越界(溢出)保护
Whidbey 编译器(即 MSVC-2005)为栈中的 SEH 帧添加了缓冲区溢出保护机制,如此一来,栈内存布局就变成了下图这样:
EH Cookie 会一直存在,而 GS cookie 段只有在编译时开启了 /GS 选项才会出现。SEH4 的作用域描述表( scopetable )跟 SEH3 的差不多,不同的是多了以下两组头部字段:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | struct _EH4_SCOPETABLE { DWORD GSCookieOffset; DWORD GSCookieXOROffset; DWORD EHCookieOffset; DWORD EHCookieXOROffset; _EH4_SCOPETABLE_RECORD ScopeRecord[1]; }; struct _EH4_SCOPETABLE_RECORD { DWORD EnclosingLevel; long (*FilterFunc)(); union { void (*HandlerAddress)(); void (*FinallyFunc)(); }; }; |
GSCookieOffset = -2 表示没有启用 GS cookie ,EH cookie 会一直启用,并且访问时用到的偏移都是相对于 %ebp 来计算的。对 security_cookie 的校验方式为:
1 | (ebp+CookieXOROffset) ^ [ebp+CookieOffset] == _security_cookie |
栈中指向 scopetable 的指针也要与 _security_cookie 进行异或计算。另外,SEH4 中最外层的作用域层级(scope level)是 -2 ,而不是像 SEH3 中那样的 -1。
C++ 异常模型实现
如果函数中实现了 C++ 的异常处理,或者可销毁的局部对象,栈的内存布局就会变得更加复杂起来:
不同于 SEH,C++ 每一个函数中的异常处理内存布局都不相同,通常是如下形式:
1 2 3 | // (VC7+) mov eax, OFFSET __ehfuncinfo jmp ___CxxFrameHandler |
其中,__ehfuncinfo 是一个 FuncInfo 结构对象,该结构中囊括了函数中所有的 try/catch 块的描述以及可销毁对象信息:
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 | struct FuncInfo { // 编译器版本 // 0x19930520: 低于 VC6; 0x19930521: VC7.x(2002-2003);0x19930522: VC8 (2005) DWORD magicNumber; // 栈展开描述表中的入口数量 // number of entries in unwind table int maxState; // 栈展开处理方法绑定表 // table of unwind destructors UnwindMapEntry* pUnwindMap; // 函数中的 try 语句块数量 DWORD nTryBlocks; // try-catch 映射表 // mapping of catch blocks to try blocks TryBlockMapEntry* pTryBlockMap; // x86 架构上不可用 // not used on x86 DWORD nIPMapEntries; // not used on x86 void* pIPtoStateMap; // VC7 及以上版本可用,期望异常列表 // VC7+ only, expected exceptions list (function "throw" specifier) ESTypeList* pESTypeList; // VC8 及以上版本可用,但以 /EHs 选项编译时会置 零 // VC8+ only, bit 0 set if function was compiled with /EHs int EHFlags; }; |
栈展开映射(Unwind map)类似 SEH 作用域描述表(SEH scopetable),只是少了过滤函数:
1 2 3 4 | struct UnwindMapEntry { int toState; // target state void (*action)(); // 栈展开时调用的处理函数 }; |
try 语句块描述结构体,描述一个 try{} 块对应的 catch{} 块的映射信息:
1 2 3 4 5 6 7 | struct TryBlockMapEntry { int tryLow; int tryHigh; // this try {} covers states ranging from tryLow to tryHigh int catchHigh; // highest state inside catch handlers of this try int nCatches; // number of catch handlers HandlerType* pHandlerArray; //catch handlers table }; |
catch 语句块描述表,描述对应某个 catch{} 块的单个 try{} 块的相关信息:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | struct HandlerType { // 0x01: const, 0x02: volatile, 0x08: reference DWORD adjectives; // RTTI descriptor of the exception type. 0=any (ellipsis) TypeDescriptor* pType; // ebp-based offset of the exception object in the function stack. // 0 = no object (catch by type) int dispCatchObj; // address of the catch handler code. // returns address where to continues execution (i.e. code after the try block) void* addressOfHandler; }; |
期望异常列表(MSVC 中默认关闭,需要用 /d1ESrt 编译选项开启):
1 2 3 4 5 6 7 | struct ESTypeList { // number of entries in the list int nCount; // list of exceptions; it seems only pType field in HandlerType is used HandlerType* pTypeArray; }; |
RTTI(Run-Time Type Information,运行时类型识别)类型描述表,描述 C++ 中的类型信息,这里会用 catch 块中的类型去匹配 throw 出来的异常的类型:
1 2 3 4 5 6 7 8 9 10 | struct TypeDescriptor { // vtable of type_info class const void * pVFTable; // used to keep the demangled name returned by type_info::name() void* spare; // mangled type name, e.g. ".H" = "int", ".?AUA@@" = "struct A", ".?AVA@@" = "class A" char name[0]; }; |
前面说过,不同于 SEH,C++ 每一个函数中的异常处理内存布局都不相同。编译器不仅会在 进/出 try 语句块的时候改变状态值,在创建/销毁一个对象的时候状态值也会做出相应改变。这样一来,异常被触发的时候就可以知道哪一个对象应该被栈展开而销毁。并且,我们还可以通过检查相关状态变化和 try 语句块处理句柄的返回地址来最终恢复 try 语句块的边界(详见 附录2)。
抛出 C++ 异常
C++ 中的 throw 表达式在底层会转换为对 _CxxThrowException() 的调用,这个调用会以特征码 0xE06D7363('msc'|0xE0000000) 抛出一个 Win32 异常(即 SEH 异常)。SEH 异常的自定义参数里有异常对象及其对应的 ThrowInfo 结构体对象,其中 ThrowInfo 结构描述了被抛出来的异常的类型,异常处理句柄可以拿此类型与 catch 块中的期望异常类型做匹配检索。下面是 ThrowInfo 的结构定义:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 | struct ThrowInfo { // 0x01: const, 0x02: volatile DWORD attributes; // exception destructor void (*pmfnUnwind)(); // forward compatibility handler int (*pForwardCompat)(); // list of types that can catch this exception. // i.e. the actual type and all its ancestors. CatchableTypeArray* pCatchableTypeArray; }; struct CatchableTypeArray { // number of entries in the following array int nCatchableTypes; CatchableType* arrayOfCatchableTypes[0]; }; |
其中,CatchableType 定义了可以 catch 这种异常的类型:
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 | struct CatchableType { // 0x01: simple type (can be copied by memmove), 0x02: can be caught by reference only, 0x04: has virtual bases DWORD properties; // see above TypeDescriptor* pType; // how to cast the thrown object to this type PMD thisDisplacement; // object size int sizeOrOffset; // copy constructor address void (*copyFunction)(); }; // Pointer-to-member descriptor. struct PMD { // member offset int mdisp; // offset of the vbtable (-1 if not a virtual base) int pdisp; // offset to the displacement value inside the vbtable int vdisp; }; |
我们会在下一篇深入阐述这一方面的内容。
序言 与 结语(Prologs & Epilogs)
为了避免向函数体部分注入设置栈帧的代码,编译器通常会选择用一些序言(Prologs)和结语(epilog)函数做一些处理。不过形式多种多样,不同类型的序言或结语作用于不同的函数类型:
| Name | Type | EH Cookie | GS Cookie | Catch Handlers |
|---|---|---|---|---|
| _SEH_prolog/_SEH_epilog | SEH3 | - | - | |
| _SEH_prolog4/_SEH_epilog4 S | EH4 | + | - | |
| _SEH_prolog4_GS/_SEH_epilog4_GS | SEH4 |