这是本系列第二篇(第一篇 👉: MSVC++ 逆向(1)——异常处理 ),本篇将介绍 MSVC 中实现的 C++ 底层机制,包括逆向过程中的 类结构内存布局、虚函数、RTTI(Run-Time Type Information,运行时类型识别)。阅读本文需要有 C++ 基础知识以及汇编和逆向相关基础。
类的内存布局基础
为了方便阐述接下来的内容,先看一段简单的 C++ 代码示例:
|
|
多数情况下 MSVC++ 的 类 中个元素在内存布局中的顺序如下:
- 指向虚函数表的指针(
_vtable_
或者_vftable_
),仅当类中有虚函数、并且基类中没有相应的虚函数表的时候才有此指针元素; - 基类
- 类成员
虚函数表中囊括了类中的各个虚函数,以虚函数声明的顺序排列。其中,重载函数 的 地址 覆盖基类中相应函数的地址。如此一来,上面 3 个类在内存中的布局大概如下所示:
|
|
上面的图表是在 VC8 中用一个文档中没说明的编译选项生成的,对于编译器产生的类内存布局图表,用 -d1reportSingleClassLayout
编译选项可以查看单个类的布局图表;用 -d1reportAllClassLayout
可以查看所有类的内存布局(包括内部的 CRT 类),布局图表会在 stdout
中输出。
从上面编译器生成的图表可以看出,类 C
里有两个 虚函数表,这是因为它继承了两个基类,而两个基类均有自己的虚函数成员。在类 C 的第一个虚函数表中,虚函数 C::A_virt2()
的地址覆盖了基类 A 在 C 中派生的 A_virt2()
的地址;类似地,在类 C 的第二个虚函数表中,虚函数 C::B_virt2()
的地址覆盖了基类 B 在 C 中派生的 B_virt2()
的地址。
调用约定与类方法
MSVC++ 中的类方法调用时,默认遵守 _thiscall_
调用约定。通过类的对象调用非静态成员函数或非全局函数时,类的对象自身的地址(即 *this
指针的值)会以隐含参数的形式传递给被调用的类的成员函数,通常,这个 *this
指针的值,存储在寄存器 ecx
中。在函数体的实现中,编译器通常把这个指针值塞在其他寄存器中(比如 esi
或 edi
),或者直接存入栈中的某个变量,然后对其他所有类成员的访问,都基于这个地址进行相对寻址来实现。然而,当实现 COM
类的时候,对类成员函数的调用则遵循 _stdcall_
的调用约定。下面详述几种不同的类成员方法调用时的底层细节:
1) 静态成员函数
调用静态成员函数不需要类的实例对象,可以直接通过类名来调用,在底层看来就跟调用普通非成员函数差不多,并不涉及 *this
指针的隐式传递。不过,也正因如此,逆向过程中不容易区分类的静态成员函数和普通的非成员函数。比如:
|
|
2) 普通成员函数
普通成员函数的调用,就需要通过类的实例对象来调用了,这种情况下 *this
指针会以隐含参数的形式作为被调函数的第一个参数传递进去,并遵循 _thiscall_
调用约定,在底层会存储在 ecx
寄存器中。另外,如果存在类继承的情况,基类对象的地址可能与派生类的对象的地址不同,这时候如果在派生类的对象中调用基类的成员函数, *this
指针的值需要调整到基类对象的起始地址,然后才能调用基类中的普通成员函数。示例如下:
|
|
如上所示,在调用 B
类的成员函数之前, *this
指针的值调整为 B
类子对象的起始地址。
3) 虚函数
为了调用虚函数,编译器首先需要从虚函数表中取出相应虚函数的起始地址,然后就按照类似普通成员函数调用的方式去调用它(把 *this
指针以隐含参数的方式传递),示例如下:
|
|
4) 构造函数和析构函数
构造函数和析构函数的调用过程,与普通成员函数类似。不同的是,即使按惯例来说构造函数并没有返回值,它仍然会把构造好的类的实例对象的起始地址隐式地返回(return
到寄存器 eax
中)。
RTTI 的实现
RTTI(Run-Time Type Identification,运行时类型识别)是编译器为了支持 C++ 中 dynamic_cast<>
和 typeid()
两个操作符操作符以及 C++ 异常而生成的特殊编译信息。RTTI 的特性只有当类涉及多态的时候才会用到,比如类中声明了虚函数。
在类的内存布局中,MSVC 编译器会把一个指向 COL
(Complete Object Locator,完整对象定位符)结构体的指针放在虚函数表之前。之所以叫完整对象定位符,是因为它允许编译器根据一个特定的虚函数表指针(一个类中可能有多个虚函数表指针)定位到整个对象。COL的结构定义如下:
|
|
RTTIClassHierarchyDescriptor
描述整个类的继承关系,它对类的所有 COL
都是通用的。
|
|
Base Class Array
定义了在执行 _dynamic_cast_
时派生类可以动态映射成的所有基类的信息,其中每一个基类描述符(Base Class Descriptor)的结构如下:
|
|
PMD
结构描述一个基类在其派生类中的位置信息。简单继承的时候,基类相对于其派生类的偏移量是固定的,偏移量的值即 _mdisp_
的值;如果涉及到虚继承,就需要先从虚函数表中取出一个额外的偏移量一起计算出基类的偏移,在函数调用的时候则需要重新调整 *this
指针的值。整个过程的伪码示例如下:
|
|
举例来说,文章开头 3 个类的继承关系中 RTTI 的信息图下图所示:
信息提取
1) RTTI
如果存在 RTTI,那么 RTTI 能为逆向工作提供很多有价值的信息。根据 RTTI,我们可能还原类名、类的继承关系,甚至有时候能还原部分类的内存布局信息。在 附录 1 中,我写了一个 RTTI 信息扫描器,可以做进一步参考。
2) 静态初始化 和 全局初始化
全局和静态的对象会在 main()
函数前面初始化。在 MSVC++ 中,编译器会为全局和静态函数生成相应的初始化器,并把他们的地址放在一个表(table
)中,这个表会在 _cinit()
初始化 CRT 的时候生成。在 PE 结构中,这个表通常在 .data
段的起始位置。典型的初始化器结构示例如下:
|
|
这样,从上面这个表里我们可以看出:
- 全局/静态对象的地址;
- 它们的构造函数
- 它们的析构函数
更多细节可以参考 _#pragma_directive_init_seg_
[5]。
3) 栈展开处理函数(Unwind Funclets)
一个函数中生成任何动态的对象时,VC++ 编译器总会生成一个相关的异常处理结构,以便在遇到异常时进行栈展开、销毁该动态对象。VC++ 中异常处理的底层细节可以参考本系列前一篇。典型的 Unwind Funclets
结构如下:
|
|
通过在函数体中寻找相反的状态变化,或者在第一次访问栈中的同一个变量,我们也可以找到其构造函数:
|
|
对与那些用 new()
方法创建的对象,栈展开处理函数 保证即使在析构函数失效的情况下,也能删除掉分配给这些对象的内存:
|
|
在函数体中:
|
|
另一种形式的 栈展开处理函数 存在于构造函数 和 析构函数 中,它将保证在程序遇到异常时销毁对象成员。这种情况下的 栈展开处理函数 使用的是保存在栈变量中的 _this_
指针:
|
|
上面这个例子中,栈展开处理函数 销毁了 B1
在偏移 0x4c
处的成员。总的来说,通过 栈展开处理函数,我们可以获取一下信息:
- 栈中保存的通过
_operator_new_
创建的 C++ 对象,或指向对象的指针; - 类的构造函数;
- 类的析构函数;
new()
创建出来的对象的size
。
4) 递归构造/析构函数
这个规则很简单:递归构造函数递归地调用其他构造函数(比如基类的构造函数、其他成员的构造函数);递归析构函数 递归地调用他们所有的析构函数。典型的构造函数具有以下功能:
- 调用基类的构造函数;
- 调用其他嵌套对象所属类的构造函数;
- 如果类中声明了虚函数,则初始化虚函数表指针(
vfptr
); - 执行程序员定义的构造函数函数体。
典型的析构函数则具有相对应的以下功能:
- 如果类中声明了虚函数,则初始化虚函数表指针(
vfptr
); - 执行程序员定义的析构函数函数体;
- 调用其他嵌套对象所属类的析构函数
- 调用基类的析构函数。
不过, MSVC 编译器创建的 析构函数 还有一个特性:_state_
以最大值初始化,并随着对成员对象的析构行为而递减。这样一来反而方便分析析构函数的执行。另外需要注意的是,在 MSVC 中,简单的 构造/析构函数通常是以内联形式存在的,所以经常会在同一个函数中看到虚函数表指针被不同指针重复调用。
5) 数组构造与析构
MSVC 用辅助函数来完成一个对象数组的构造与析构。用以下代码为例:
|
|
用 C++ 伪码详细还原一下,大概如下所示:
|
|
如果 A
包含虚函数,删除对象数组的时候会调用一个 vector deleting destructor
:
|
|
如果 A
的析构函数是个虚函数,那么析构的时候会以调用虚函数的方式调用析构函数:
|
|
因此,通常来说通过构造/析构的数组迭代调用,我们可以发掘以下信息:
- 对象数组的地址;
- 数组里各对象的构造函数;
- 数组里各对象的析构函数;
- 类的
size
。
6) 删除析构函数( deleting destructor
)
当类中含有虚析构函数( virtual destructor
)时,编译器会生成一个辅助函数——删除析构函数,这样便能确保销毁一个类实例的时候合适的 _operator delete_
被调用。删除析构函数 的伪码如下:
|
|
该函数的地址会被放在虚函数表( vftable
) 中,并覆盖原有的析构函数地址。这样一来,如果另外一个类覆盖了这个虚析构函数,那么它的 _delete_
将被调用。然而实际代码中 _delete_
几乎不会被覆盖,所以你通常只看到调用默认的delete()。有时候,编译器也生成一个删除析构函数向量,伪码示例如下:
|
|
我忽略了大部分涉及虚基类的类的实现细节,因为它们是在太复杂,而且在现实生活中很少用到。请参考 Jan Gray 的文章[1],它非常相近(请忽略那看着脑仁疼的匈牙利命名法)。文章[2]描述了一个MSVC实现虚继承的实现。更多细节还可以看 MS 专利[3]。
附录 1: ms_rtti4.idc
这是我为解析 RTTI 和虚函数表写的一个 IDA 脚本,读者可以从 Microsoft VC++ Reversing Helpers 下载到该脚本以及本系列两篇文章。该脚本的功能特性有以下几个:
- 解析 RTTI 结构、用相应的类名重命名虚函数表;
- 在相对简单的分析工作中重命名构造函数与析构函数;
- 把所有的虚函数表以及引用函数和类的继承关系输出到文件中。
Usage:
IDA 的初始化分析结束之后,载入ms_rtti4.idc
,它会询问你是否要扫描 PE 文件中的虚函数表(vftables)。需要注意的是,这个过程可能需要比较长的时间。如果你选择跳过扫描,后续仍然可以手动解析虚函数表。如果你选择让脚本帮你执行扫描,脚本会识别 PE 文件中所有使用 RTTI 的虚函数表,并且会重命名虚函数表、识别和重命名构造/析构函数。也有可能脚本会解析失败,尤其是涉及到虚继承的情况。扫描结束后,脚本会自动打开存放扫描结果的文件。
另外,脚本载入以后,可以使用以下 快捷键 来对 MSVC 生成的结构进行手动解析:
Alt+F8
:解析一个虚函数表。游标应该会停在虚函数表的起始位置。如果里面用到了 RTTI,脚本会使用里面的类名来重命名虚函数表。如果没有涉及到 RTTI,你可以手动输入类名来自定义。如果脚本扫描到了虚析构函数**,一样也会把它重命名。Alt+F7
:解析FuncInfo
结构。FuncInfo
是一个描述在栈上创建对象或使用异常处理句柄的函数信息的结构。它的地址在异常处理句柄中通常被解析为_CxxFrameHandler
:12mov eax, offset FuncInfo1jmp _CxxFrameHandler多数情况下它会被 IDA 直接识别并解析,但是我提供的脚本可以解析出更多的信息,你可以用
ms_ehseh.idc
解析文件中的所有FuncInfo
。
游标放到FuncInfo
起始位置的,此快捷键有效。Alt+F9
:解析throw
信息。Throw info
是_CxxThrowException
在实现_throw
操作符时用到的辅助结构,它通常作为_CxxThrowException
的第二个参数被调用:123456lea ecx, [ebp+e]call E::E()push offset ThrowInfo_Elea eax, [ebp+e]push eaxcall _CxxThrowException游标放在
throw info
起始位置的时候次快捷键才有效。该脚本会解析throw info
并为调用throw
操作符的类添加注释。它还可以识别和重命名异常的析构函数和拷贝构造函数。
附录 2:实战恢复一个类的结构
我们练手的对象是 MSN Messenger 7.5
( msnmsgr.exe
版本是 7.5.324.0
, 大小 7094272 Bytes ),它主要由 C++ 实现,并且里面用到了很多 RTTI 的结构,正符合我们的需求。先看一下位于 .0040EFD8
和 .0040EFE0
的两处虚函数表。其中完整的 RTTI 结构及其继承关系如下所示:
这样一来,就有了两个虚函数表属于同一个类 —— CContentMenuItem
,再看它们的基类描述符我们可以发现:
CContentMenuItem
里面包含 3 个基类 ——CDownloader
/CNativeEventSink
/CNativeEventSource
;CDownloader
包含 1 个基类 ——CNativeEventSink
;- 因此
CContentMenuItem
继承自CDownloader
和CNativeEventSource
,而CDownloader
继承自CNativeEventSink
; CDownloader
位于整个对象的起始位置,CNativeEventSource
则位于偏移为0x24
的位置。
据此,我们可以得出这么一个结论:第一个虚函数表列出了 CNativeEventSource
里的方法,第二个虚函数表列出了 CDownloader
或者 CNativeEventSink
里的方法(如果这两者都不是,CContentMenuItem
会重用 CNativeEventSource
的虚函数表)。我们再来看都有谁引用了这两个虚函数表,它们都被位于 .052B5E0
和 .052B547
的两个函数引用(这样进一步印证了它们属于同一个类)。如果我们仔细查看 .052B547
处函数的开头,可以发现 _state_
被初始化为 6
,这表明该函数是一个 析构函数;由于一个类只能有 1 个析构函数,我们可以推断 .052B5E0
处的函数是一个构造函数:
|
|
编译器在预言(prolog
) 之后要做的第一件事就是把 _this_
指针的值从 ecx
拷贝到 esi
,继而后续所有的寻址都是相对于 esi
作为基址。在初始化虚函数表指针(vfptrs
) 之前调用了两个其他函数,这一定是基类的构造函数——本例中即 CDownloader
和 CNativeEventSource
的构造函数。进一步深入函数跟踪分析可以帮助我们确认这一点:第一个虚函数表指针( vfptf
)用 CDownloader::'vftable'
来初始化, 第二个虚函数表指针用 CNativeEventSource::'vftable'
来初始化。我们也可以进一步检查 CDownloader
的构造函数 —— 它调用了其基类 CNativeEventSink
的构造函数。
类似的,_this_
指针的值通过 edi
传入,这时它被重置为 _this_ + 24h
,根据我们上面的类结构图来看,这是 CNativeEventSource
子对象的位置。这是另一个证明被调用的第二个函数是 CNativeEventSource
的构造函数的证据。
结束了基类的构造函数调用过程之后,基类中的虚函数指针被 CContentMenuItem
中自己的实现所覆盖,即 CContentMenuItem
实现了基类中的部分虚函数(或者增加了自己的虚函数)。有必要的话,我们可以对比这些表、检查那些指针被修改过或被添加了——新添加的指针就是 CContentMenuItem
中新实现的虚函数。
接下来我们就看到几个对地址 .04D8000
的调用,调用之前 ecx
的值被设置为 this+4Ch
到 this+5Ch
—— 这很明显是在初始化成员对象。一个问题是,我们如何分辨初始化函数是编译器自动生成的构造函数,还是程序员编写的自定义构造函数呢?这里有两个关键点可以参考:
- 函数使用
_thiscall_
的 调用约定,而且是第一次访问这些字段; - 字段的初始化顺序是按照地址增长的方向进行的。
为了确定这些点,我们可以查看析构函数中的栈展开处理函数(Unwind Funclets),在那里我们可以看到编译器为这些成员变量生成的构造函数。
这个新的类并没有虚函数,因此也没有 RTTI,所以我们也不知道它的名字,不妨先命名为 RefCountedPtr。根据前面的分析,位于
.4D8000的函数是**构造函数**,那么在
CContentMenuItem我们可以看到析构函数中的**栈展开处理函数**——在
.63CCB4` 处。
回过头去看 CContentMenuItem
的 构造函数,可以看到 3 个字段初始化为 0,另外一个初始化为一个 虚函数表指针( vftable pointer
)。这个看起来想一个成员变量的内联构造函数(不是基类,因为基类会出现在继承关系树中)。从一个使用了的虚函数表的 RTTI 中我们可以看出这是一个 CEventSinkList
模板的实例。
根据上面的分析,我们可以大概勾勒出类的结构声明:
|
|
我们不确定偏移为 0x48
处的变量是否为 CNativeEventSource
的一部分,但由于它并没有被 CNativeEventSource
的构造函数访问到,那么它很可能属于 CContentMenuItem
。包含被重命名函数的构造函数与类的结构如下:
|
|