摘要
MSVC++ 是编写 Win32 应用程序最常用的编译器,所以在 Win32 平台的逆向工作中,懂得其底层工作原理,对逆向工程师来说至关重要。掌握 VC++ 程序的底层原理之后,便能在逆向过程中精准、快速识别编译器生成的胶水代码(Glue Code),这样可以让逆向工程师快速聚焦于二进制文件背后的真实程序和真实逻辑。另外,这对还原程序中高层次的结构(译注
:面向对象的数据结构和程序组织结构、异常相关数据结构等)也有莫大帮助。
本文只是系列文章的上半部分(下半部分见: (译)MSVC++ 逆向(二)—— 类、方法和 RTTI),主要讲栈展开、异常处理以及 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 中看到的栈,是向上增长的。
栈的内存布局
基础的栈布局如下所示:
|
|
具体点,如下图所示:
NOTE:
如果设置了FPO
(Frame Pointer Omission, 框架指针省略),原栈基址 %ebp
可能就不存在了。
SEH
当涉及到编译器层面的 SEH(__try/__except/__finally
) 时,栈的内存布局就会变的复杂一些:
一个函数中如果没有 __try
语句块(只有 __finally
),Saved ESP
就不会存在。另外,作用域描述表( scopetable
)是一个记录每一个 try
块及其关系描述的数组:
|
|
更多 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
的差不多,不同的是多了以下两组头部字段:
|
|
GSCookieOffset = -2
表示没有启用 GS cookie
,EH cookie
会一直启用,并且访问时用到的偏移都是相对于 %ebp
来计算的。对 security_cookie
的校验方式为:
|
|
栈中指向 scopetable
的指针也要与 _security_cookie
进行异或计算。另外,SEH4
中最外层的作用域层级(scope level
)是 -2
,而不是像 SEH3
中那样的 -1
。
C++ 异常模型实现
如果函数中实现了 C++ 的异常处理,或者可销毁的局部对象,栈的内存布局就会变得更加复杂起来:
不同于 SEH,C++ 每一个函数中的异常处理内存布局都不相同,通常是如下形式:
|
|
其中,__ehfuncinfo
是一个 FuncInfo
结构对象,该结构中囊括了函数中所有的 try/catch
块的描述以及可销毁对象信息:
|
|
栈展开映射(Unwind map
)类似 SEH 作用域描述表(SEH scopetable
),只是少了过滤函数:
|
|
try
语句块描述结构体,描述一个 try{}
块对应的 catch{}
块的映射信息:
|
|
catch
语句块描述表,描述对应某个 catch{}
块的单个 try{}
块的相关信息:
|
|
期望异常列表(MSVC 中默认关闭,需要用 /d1ESrt
编译选项开启):
|
|
RTTI
(Run-Time Type Information,运行时类型识别)类型描述表,描述 C++ 中的类型信息,这里会用 catch
块中的类型去匹配 throw
出来的异常的类型:
|
|
前面说过,不同于 SEH,C++ 每一个函数中的异常处理内存布局都不相同。编译器不仅会在 进/出 try
语句块的时候改变状态值,在创建/销毁一个对象的时候状态值也会做出相应改变。这样一来,异常被触发的时候就可以知道哪一个对象应该被栈展开而销毁。并且,我们还可以通过检查相关状态变化和 try
语句块处理句柄的返回地址来最终恢复 try
语句块的边界(详见 附录2)。
抛出 C++ 异常
C++ 中的 throw
表达式在底层会转换为对 _CxxThrowException()
的调用,这个调用会以特征码 0xE06D7363
('msc'|0xE0000000
) 抛出一个 Win32 异常(即 SEH 异常)。SEH 异常的自定义参数里有异常对象及其对应的 ThrowInfo
结构体对象,其中 ThrowInfo
结构描述了被抛出来的异常的类型,异常处理句柄可以拿此类型与 catch
块中的期望异常类型做匹配检索。下面是 ThrowInfo
的结构定义:
|
|
其中,CatchableType
定义了可以 catch
这种异常的类型:
|
|
我们会在下一篇深入阐述这一方面的内容。
序言 与 结语(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 | + | + | |
_EH_prolog | C++ EH | - | - | +/- |
_EH_prolog3/_EH_epilog3 | C++ EH | + | - | - |
_EH_prolog3_catch/_EH_epilog3 | C++ EH | + | - | + |
_EH_prolog3_GS/_EH_epilog3_GS | C++ EH | + | + | - |
_EH_prolog3_catch_GS/_EH_epilog3_catch_GS | C++ EH | + | + | + |
SEH2
当然,这个在早期的 MSVC1.xx
中才会用到(从 crtdll.dll
中导出),在一些运行在旧版 NT 上的程序中会碰到。其内存布局如下所示:
|
|
附录 1:SEH 程序示例
参考以下反汇编出来的结果:
|
|
注意,上面的 0
号 try
块并没有过滤器,所以它的处理句柄是 __finally{}
语句块。try
块 1
的 EnclosingLevel
是 0
,所以它被 0
号 try
块所闭合。由此以来,我们可以大概构造出上面程序的大概结构:
|
|
附录 2:带 SEH 异常的 C++ 程序示例
|
|
我们来看上述程序示例,FuncInfo
结构体中的 maxState
域值为 4
,这表明栈展开映射表中有 4
个入口点,编号 0-3 。检查映射表,可以看到在栈展开过程中会执行的相应动作有以下 4
个:
- state 3 -> state 0 (NOP)
- state 2 -> state 1 (析构
a2
)- state 1 -> state 0 (NOP)
- state 0 -> state -1 (析构
a1
)
再看 try
语句映射表,我们可以推断, state 1
和 state2
对应 try
语句块的执行逻辑,而 sttate3
对应 catch
语句块的执行逻辑。这样一来,state 0 -> 1
的变化代表了 try
语句块的开始,state 1 - >0
代表 try
语句块的结束。另外,我们还可以推断 state -1 --> 0
代表创建 a1
;state 1 -> 2
代表创建 a2
。具体的状态装换和相应的程序执行逻辑如下图所示:
看到这里可能会心生疑惑:1 -> 3
那个箭头是从哪儿来的?其实这是在异常处理句柄内部发生的,我们从函数代码和 FuncInfo
结构体中都看不出来罢了。如果一个异常在 try
块内部被触发,异常处理句柄在调用相应的 catch
块之前,首先要做的就是把栈展开到 tryLow
那一层(上面例子中的 state 1
),然后把状态值 state
设置为 tryHigh+1
(即 2+1=3
)。
try
语句块对应 2 个 catch
句柄。第一个有一个 catch
的类型( char*
),并且在栈 -1Ch
处获取到异常对象。第二个没有对应的异常类型,什么也不做,相当于忽略异常。两个句柄都会返回函数继续执行的地址,这个地址其实就紧随 try
块之后。这样我们试着还原一下该函数:
|
|
附录 3: IDC 辅助脚本
我编写了一个 IDC 脚本用来辅助对 MSVC 程序的逆向分析。它会在整个程序中搜索 SEH/EH 的代码段,并且为所有相关的结构体和结构体元素添加注释。可以被注释的项有栈变量、异常处理句柄、异常类型及其他相关元素。它还能尝试修复 IDA 中误判的函数边界。该脚本的下载链接: MS SEH/EH 逆向辅助脚本 。
参考资料
原文参考资料:
- http://www.microsoft.com/msj/0197/exception/exception.aspx
- http://blogs.msdn.com/branbray/archive/2003/11/11/51012.aspx
- http://blogs.msdn.com/cbrumme/archive/2003/10/01/51524.aspx
- http://www.codeproject.com/cpp/exceptionhandler.asp
- http://www.cs.arizona.edu/computer.help/policy/DIGITAL_unix/AA-PY8AC-TET1_html/callCH5.html
翻译参考资料
- http://www.cnblogs.com/samo/articles/3092895.html
- http://www.cnblogs.com/Winston/archive/2009/04/19/1439184.html
- https://msdn.microsoft.com/en-us/library/8dbf701c(v=vs.80).aspx
- http://www.nynaeve.net/?p=91
- http://www.cnblogs.com/awpatp/archive/2009/11/04/1595988.html
- https://en.wikibooks.org/wiki/C%2B%2B_Programming/RTTI