为了说明以下材料,让我们考虑这个简单的例子:
class A
{
int a1;
public:
virtual int A_virt1();
virtual int A_virt2();
static void A_static1();
void A_simple1();
};
class B
{
int b1;
int b2;
public:
virtual int B_virt1();
virtual int B_virt2();
};
class C: public A, public B
{
int c1;
public:
virtual int A_virt2();
virtual int B_virt2();
};
在大多数情况下,MSVC 按以下顺序排列类:
虚函数表由虚方法的地址按其首次出现的顺序组成。重载函数的地址替换基类中的函数地址。
因此,我们三个类的布局将如下所示:
class A size(8):
+---
0 | {vfptr}
4 | a1
+---
A's vftable:
0 | &A::A_virt1
4 | &A::A_virt2
class B size(12):
+---
0 | {vfptr}
4 | b1
8 | b2
+---
B's vftable:
0 | &B::B_virt1
4 | &B::B_virt2
class C size(24):
+---
| +--- (base class A)
0 | | {vfptr}
4 | | a1
| +---
| +--- (base class B)
8 | | {vfptr}
12 | | b1
16 | | b2
| +---
20 | c1
+---
C's vftable for A:
0 | &A::A_virt1
4 | &C::A_virt2
C's vftable for B:
0 | &B::B_virt1
4 | &C::B_virt2
上图是由 VC8 编译器使用未记录的开关生成的。要查看编译器生成的类布局,请使用:-d1reportSingleClassLayout查看单个类的布局 -d1reportAllClassLayout 查看所有类的布局(包括内部 CRT 类) 布局转储到标准输出。
正如你所看到的,C 有两个 vftable,因为它继承了两个已经有虚函数的类。C::A_virt2 的地址替换了 A 的 C 的 vftable 中的 A::A_virt2 的地址,并且 C::B_virt2 替换了另一个表中的 B::B_virt2。
默认情况下,MSVC 中的所有类方法都使用 _thiscall_ 约定。类实例地址(_this_ 指针)作为 ecx 寄存器中的隐藏参数传递。在方法体中,编译器通常会立即将其隐藏在其他一些寄存器(例如 esi 或 edi)和/或堆栈变量中。类成员的所有进一步寻址都是通过该寄存器和/或变量完成的。但是,在实现 COM 类时,会使用 _stdcall_ 约定。以下是各种类方法类型的概述。
1)静态方法
静态方法不需要类实例,因此它们的工作方式与普通函数相同。没有 _this_ 指针传递给它们。因此,不可能可靠地区分静态方法和简单函数。例子:
A::A_static1();
call A::A_static1
2)简单方法
简单方法需要一个类实例,所以_this_指针作为隐藏的第一个参数传递给它们,通常使用_thiscall_约定,即在_ecx_寄存器中。当基对象不位于派生类的开头时,在调用函数之前需要调整_this_指针指向基子对象的实际开头。例子:
;pC->A_simple1(1);
;esi = pC
push 1
mov ecx, esi
call A::A_simple1
;pC->B_simple1(2,3);
;esi = pC
lea edi, [esi+8] ;adjust this
push 3
push 2
mov ecx, edi
call B::B_simple1
如您所见,在调用 B 的方法之前,_this_ 指针被调整为指向 B 子对象。
3)虚拟方法
要调用虚拟方法,编译器首先需要从_vftable_ 中获取函数地址,然后以与简单方法相同的方式调用该地址处的函数(即传递_this_ 指针作为隐式参数)。例子:
;pC->A_virt2()
;esi = pC
mov eax, [esi] ;fetch virtual table pointer
mov ecx, esi
call [eax+4] ;call second virtual method
;pC->B_virt1()
;edi = pC
lea edi, [esi+8] ;adjust this pointer
mov eax, [edi] ;fetch virtual table pointer
mov ecx, edi
call [eax] ;call first virtual method
4)构造函数和析构
函数构造函数和析构函数的工作类似于一个简单的方法:它们获得一个隐式_this_ 指针作为第一个参数(例如,在_thiscall_ 约定的情况下为ecx)。构造函数在 eax 中返回 _this_ 指针,即使它正式没有返回值。
RTTI(运行时类型标识)是编译器生成的特殊信息,用于支持诸如 dynamic_cast<> 和 typeid() 等 C++ 运算符,也用于 C++ 异常。由于其性质,RTTI 只需要(和生成)多态类,即具有虚函数的类。
MSVC 编译器在 vftable 之前放置一个指向名为“完整对象定位器”的结构的指针。如此调用该结构是因为它允许编译器从特定的 vftable 指针中找到完整对象的位置(因为一个类可以有多个)。COL 如下所示:
struct RTTICompleteObjectLocator
{
DWORD signature; //always zero ?
DWORD offset; //offset of this vtable in the complete class
DWORD cdOffset; //constructor displacement offset
struct TypeDescriptor* pTypeDescriptor; //TypeDescriptor of the complete class
struct RTTIClassHierarchyDescriptor* pClassDescriptor; //describes inheritance hierarchy
};
Class Hierarchy Descriptor 描述了类的继承层次。它由一个类的所有 COL 共享。
struct RTTIClassHierarchyDescriptor
{
DWORD signature; //always zero?
DWORD attributes; //bit 0 set = multiple inheritance, bit 1 set = virtual inheritance
DWORD numBaseClasses; //number of classes in pBaseClassArray
struct RTTIBaseClassArray* pBaseClassArray;
};
基类数组描述了所有基类以及允许编译器在执行 _dynamic_cast_ 运算符期间将派生类转换为其中任何一个的信息。每个条目(基类描述符)具有以下结构:
struct RTTIBaseClassDescriptor
{
struct TypeDescriptor* pTypeDescriptor; //type descriptor of the class
DWORD numContainedBases; //number of nested classes following in the Base Class Array
struct PMD where; //pointer-to-member displacement info
DWORD attributes; //flags, usually 0
};
struct PMD
{
int mdisp; //member displacement
int pdisp; //vbtable displacement
int vdisp; //displacement inside vbtable
};
PMD 结构描述了如何将基类放置在完整类中。在简单继承的情况下,它位于对象开头的固定偏移量处,该值是_mdisp_ 字段。如果它是虚拟基础,则需要从 vbtable 中获取额外的偏移量。将 _this_ 指针从派生类调整为基类的伪代码如下所示:
//char* pThis; struct PMD pmd;
pThis+=pmd.mdisp;
if (pmd.pdisp!=-1)
{
char *vbtable = pThis+pmd.pdisp;
pThis += *(int*)(vbtable+pmd.vdisp);
}
例如,我们三个类的 RTTI 层次结构如下所示:
1) RTTI
如果存在,RTTI 是一个有价值的反转信息来源。从 RTTI 可以恢复类名、继承层次结构,在某些情况下还可以恢复部分类布局。我的 RTTI 扫描仪脚本显示了大部分信息。(见附录I)
2)静态和全局初始化
器 全局和静态对象需要在主程序启动之前进行初始化。MSVC 通过生成初始化函数并将它们的地址放在一个表中来实现这一点,该表在 CRT 启动期间由 _cinit 函数处理。该表通常位于 .data 部分的开头。典型的初始化程序如下所示:
_init_gA1: mov ecx, offset _gA1 call A::A() push offset _term_gA1 call _atexit pop ecx retn _term_gA1: mov ecx, offset _gA1 call A::~A() retn
因此,从这个表格中我们可以发现:
另见 MSVC _#pragma_ 指令 _init_seg_ [5]。
3) Unwind Funclet
如果在函数中创建了任何自动对象,VC++ 编译器会自动生成异常处理结构,以确保在发生异常时删除这些对象。有关C++ 异常实现的详细描述,请参见第一部分。典型的 unwind funclet 会破坏堆栈上的对象:
unwind_1tobase: ; state 1 -> -1 lea ecx, [ebp+a1] jmp A::~A()
通过在函数体内找到相反的状态变化或者只是第一次访问相同的堆栈变量,我们还可以找到构造函数:
lea ecx, [ebp+a1] call A::A() mov [ebp+__$EHRec$.state], 1
对于使用 new() 运算符构造的对象,unwind funclet 确保在构造函数失败时删除分配的内存:
unwind_0tobase: ; state 0 -> -1 mov eax, [ebp+pA1] push eax call operator delete(void *) pop ecx retn
在函数体中:
;A* pA1 = new A(); push call operator new(uint) add esp, 4 mov [ebp+pA1], eax test eax, eax mov [ebp+__$EHRec$.state], 0; state 0: memory allocated but object is not yet constructed jz short @@new_failed mov ecx, eax call A::A() mov esi, eax jmp short @@constructed_ok @@new_failed: xor esi, esi @@constructed_ok: mov [esp+14h+__$EHRec$.state], -1 ;state -1: 对象构造成功或内存分配失败 ;在这两种情况下,进一步的内存管理都由程序员完成
在构造函数和析构函数中使用了另一种类型的展开 funclet。它确保在异常情况下销毁类成员。在这种情况下,funclet 使用 _this_ 指针,该指针保存在堆栈变量中:
unwind_2to1: mov ecx, [ebp+_this] ; state 2 -> 1 add ecx, 4Ch jmp B1::~B1
这里 funclet 在偏移量 4Ch 处破坏 B1 类型的类成员。因此,从 unwind funclets 我们可以发现:
4)构造函数/析构函数递归
这条规则很简单:构造函数调用其他构造函数(基类和成员变量),析构函数调用其他析构函数。典型的构造函数执行以下操作:
典型的析构函数几乎以相反的顺序工作:
MSVC 生成的析构函数的另一个显着特点是它们的 _state_ 变量通常用最高值初始化,然后随着每个被破坏的子对象递减,这使得它们的识别更容易。请注意,简单的构造函数/析构函数通常由 MSVC 内联。这就是为什么您经常可以看到 vftable 指针在同一个函数中使用不同的指针反复重新加载。
5)数组构造销毁
MSVC 编译器使用辅助函数来构造和销毁对象数组。考虑以下代码:
A* pA = new A[n]; delete [] pA;
它被翻译成以下伪代码:
array = new char(sizeof(A)*n+sizeof(int)) if (array) { *(int*)array=n; //store array size in the beginning 'eh vector constructor iterator'(array+sizeof(int),sizeof(A),count,&A::A,&A::~A); } pA = array; 'eh vector destructor iterator'(pA,sizeof(A),count,&A::~A);
如果 A 有一个 vftable,则在删除数组时会调用“向量删除析构函数”:
;pA->'vector deleting destructor'(3); mov ecx, pA push 3 ; flags: 0x2=deleting an array, 0x1=free the memory call A::'vector deleting destructor'
如果 A 的析构函数是虚拟的,它会被虚拟调用:
mov ecx, pA push 3 mov eax, [ecx] ;fetch vtable pointer call [eax] ;call deleting destructor
因此,从向量构造函数/析构函数迭代器调用中,我们可以确定:
6)删除析
构函数 当类有虚析构函数时,编译器生成一个辅助函数——删除析构函数。其目的是确保在销毁类时调用正确的_operator delete_。删除析构函数的伪代码如下所示:
virtual void * A::'scalar deleting destructor'(uint flags) { this->~A(); if (flags&1) A::operator delete(this); };
这个函数的地址被放置在 vftable 中,而不是析构函数的地址。这样,如果另一个类覆盖了虚拟析构函数,则会调用该类的_operator delete_。虽然在实际代码中_operator delete_ 很少被覆盖,所以通常你会看到对默认delete() 的调用。有时编译器还可以生成向量删除析构函数。它的代码如下所示:
virtual void * A::'vector deleting destructor'(uint flags) { if (flags&2) //destructing a vector { array = ((int*)this)-1; //array size is stored just before the this pointer count = array[0]; 'eh vector destructor iterator'(this,sizeof(A),count,A::~A); if (flags&1) A::operator delete(array); } else { this->~A(); if (flags&1) A::operator delete(this); } };
我跳过了关于使用虚拟基类实现类的大部分细节,因为它们使事情变得相当复杂并且在现实世界中相当罕见。请参阅 Jan Gray[1] 的文章。它非常详细,如果匈牙利符号有点重的话。文章 [2] 描述了 MSVC 中虚拟继承实现的示例。另请参阅一些 MS 专利 [3] 了解更多详细信息。
这是我为解析 RTTI 和 vftables 编写的脚本。您可以从Microsoft VC++ Reversing Helpers下载与本文和上一篇文章相关的脚本。脚本特点:
mov eax, offset FuncInfo1 jmp _CxxFrameHandler
lea ecx, [ebp+e] call E::E() push offset ThrowInfo_E lea eax, [ebp+e] push eax call _CxxThrowException
使用热键并将光标放在 throw 信息结构的开头。该脚本将解析结构并添加带有抛出类名称的可重复注释。它还将识别和重命名异常的析构函数和复制构造函数。
我们的主题将是 MSN Messenger 7.5(msnmsgr.exe 版本 7.5.324.0,大小 7094272)。它大量使用了 C++,并为我们的目的提供了大量的 RTTI。让我们考虑两个 vftable,分别为 0.0040EFD8 和 0.0040EFE0。它们的完整 RTTI 结构层次结构如下所示:
所以,这两个 vftable 都属于一个类 - CContentMenuItem。通过检查它的基类描述符,我们可以看到:
所以我们可以得出结论,第一个 vftable 列出了 CNativeEventSource 的方法,第二个列出了 CDownloader 或 CNativeEventSink 的方法(如果它们都没有虚拟方法,CContentMenuItem 将重用 CNativeEventSource 的 vftable)。现在让我们检查一下这些表指的是什么。它们都由两个函数引用,分别为 .052B5E0 和 .052B547。(这强化了它们都属于一个类的事实。)此外,如果我们查看函数的开头 .052B547,我们会看到用 6 初始化的 _state_ 变量,这意味着该函数是析构函数。由于一个类只能有一个析构函数,我们可以得出结论,.052B5E0 是它的构造函数。让我们仔细看看:
CContentMenuItem::CContentMenuItem proc near this = esi push this push edi mov this, ecx call sub_4CA77A lea edi, [this+24h] mov ecx, edi call sub_4CBFDB or dword ptr [this+48h], 0FFFFFFFFh lea ecx, [this+4Ch] mov dword ptr [this], offset const CContentMenuItem::'vftable'{for 'CContentMenuItem'} mov dword ptr [edi], offset const CContentMenuItem::'vftable'{for 'CNativeEventSource'} call sub_4D8000 lea ecx, [this+50h] call sub_4D8000 lea ecx, [this+54h] call sub_4D8000 lea ecx, [this+58h] call sub_4D8000 lea ecx, [this+5Ch] call sub_4D8000 xor eax, eax mov [this+64h], eax mov [this+68h], eax mov [this+6Ch], eax pop edi mov dword ptr [this+60h], offset const CEventSinkList::'vftable' mov eax, this pop this retn sub_52B5E0 endp
编译器在 prolog 之后做的第一件事就是将 _this_ 指针从 ecx 复制到 esi,因此所有进一步的寻址都是基于 esi 完成的。在初始化 vfptrs 之前,它会调用另外两个函数;那些必须是基类的构造函数——在我们的例子中是 CDownloader 和 CNativeEventSource。我们可以通过进入每个函数来确认 - 第一个使用 CDownloader::'vftable' 初始化其 vfptr 字段,第二个使用 CNativeEventSource::'vftable' 初始化其 vfptr 字段。我们还可以进一步研究 CDownloader 的构造函数——它调用其基类 CNativeEventSink 的构造函数。
此外,传递给第二个函数的_this_ 指针取自edi,它指向this+24h。根据我们的类结构图,它是 CNativeEventSource 子对象的位置。这是另一个确认被调用的第二个函数是 CNativeEventSource 的构造函数。
在调用基础构造函数之后,基础对象的 vfptr 被 CContentMenuItem 的实现覆盖——这意味着 CContentMenuItem 覆盖了基础类的一些虚拟方法(或添加了自己的)。(如果需要,我们可以比较表并检查哪些指针已更改或添加 - 这些将是 CContentMenuItem 的新实现。)
接下来我们看到对 .04D8000 的几个函数调用,其中 _ecx_ 设置为 this+4Ch 到 this+5Ch - 显然一些成员变量已初始化。我们如何知道该函数是编译器生成的构造函数调用还是程序员编写的初始化函数?有几个提示表明它是一个构造函数。
为了确保我们还可以检查析构函数中的展开 funclet - 我们可以看到编译器生成的对这些成员变量的析构函数调用。
这个新类没有虚拟方法,因此没有 RTTI,所以我们不知道它的真实名称。我们将其命名为 RefCountedPtr。正如我们已经确定的那样,4D8000 是它的构造函数。我们可以从 CContentMenuItem 析构函数的 unwind funclets 中找到析构函数——它位于 63CCB4。
回到 CContentMenuItem 构造函数,我们看到三个用 0 初始化的字段和一个用 vftable 指针初始化的字段。这看起来像是成员变量的内联构造函数(不是基类,因为基类将存在于继承树中)。从使用的 vftable 的 RTTI 我们可以看出它是一个 CEventSinkList 模板的实例。
现在我们可以为我们的类编写一个可能的声明。
class CContentMenuItem: public CDownloader, public CNativeEventSource { /* 00 CDownloader */ /* 24 CNativeEventSource */ /* 48 */ DWORD m_unknown48; /* 4C */ RefCountedPtr m_ptr4C; /* 50 */ RefCountedPtr m_ptr50; /* 54 */ RefCountedPtr m_ptr54; /* 58 */ RefCountedPtr m_ptr58; /* 5C */ RefCountedPtr m_ptr5C; /* 60 */ CEventSinkList m_EventSinkList; /* size = 70? */ };
我们无法确定偏移 48 处的字段是否不是 CNativeEventSource 的一部分;但由于它没有在 CNativeEventSource 构造函数中访问,它很可能是 CContentMenuItem 的一部分。应用了重命名方法和类结构的构造函数列表:
public: __thiscall CContentMenuItem::CContentMenuItem(void) proc near push this push edi mov this, ecx call CDownloader::CDownloader(void) lea edi, [this+CContentMenuItem._CNativeEventSource] mov ecx, edi call CNativeEventSource::CNativeEventSource(void) or [this+CContentMenuItem.m_unknown48], -1 lea ecx, [this+CContentMenuItem.m_ptr4C] mov [this+CContentMenuItem._CDownloader._vfptr], offset const CContentMenuItem::'vftable'{for 'CContentMenuItem'} mov [edi+CNativeEventSource._vfptr], offset const CContentMenuItem::'vftable'{for 'CNativeEventSource'} call RefCountedPtr::RefCountedPtr(void) lea ecx, [this+CContentMenuItem.m_ptr50] call RefCountedPtr::RefCountedPtr(void) lea ecx, [this+CContentMenuItem.m_ptr54] call RefCountedPtr::RefCountedPtr(void) lea ecx, [this+CContentMenuItem.m_ptr58] call RefCountedPtr::RefCountedPtr(void) lea ecx, [this+CContentMenuItem.m_ptr5C] call RefCountedPtr::RefCountedPtr(void) xor eax, eax mov [this+CContentMenuItem.m_EventSinkList.field_4], eax mov [this+CContentMenuItem.m_EventSinkList.field_8], eax mov [this+CContentMenuItem.m_EventSinkList.field_C], eax pop edi mov [this+CContentMenuItem.m_EventSinkList._vfptr], offset const CEventSinkList::'vftable' mov eax, this pop this retn public: __thiscall CContentMenuItem::CContentMenuItem(void) endp