C++逆向之虚函数

虚函数是面向对象程序设计的关键组成部分。上一篇介绍构造函数和析构函数识别方法。对于具有虚函数的类而言,构造函数和析构函数识别流程更加简单。而且,在类中定义了虚函数后,如果没有提供默认的构造函数,编译器必须提供默认的构造函数。

对象的多态性需要通过虚表和虚表指针来完成,虚表指针被定义在对象首地址的前4字节处,因此虚函数必须作为成员函数使用。由于非成员函数没有this指针,因此无法获得虚表指针,进而无法获取虚表,也就无法访问虚函数。

虚函数的机制

在C++中,使用关键字virtual声明函数为虚函数。当类中定义有虚函数时,编译器会将该类中所有虚函数的首地址保存在一张地址表中,这张表被称为虚函数地址表,简称虚表。同时,编译器还会在类中添加一个隐藏数据成员,称为虚表指针。该指针中保存着虚表的首地址,用于记录和查找虚函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include"stdafx.h"
class CVirtual
{
public:
virtual int GetNumber()
{
return m_nNumber;
}
virtual void SetNumber(int nNumber)
{
m_nNumber = nNumber;
}
private:
int m_nNumber;
};
int main(int argc,char* argv[])
{
printf("%d\n", sizeof(CVirtual));
return 0;
}

如果这个类没有定义虚函数,则其长度为4,定义了虚函数后,由于含有隐藏数据成员(虚表指针),因此大小为8。

如图,类CVirtual确实多出了4个字节数据,这4字节用于保存虚表指针。在虚表指针所指向的指针数组中,保存着虚函数GetNumber和SetNumber的首地址。
下面实例化一个CVirtual分析虚表指针的实现过程。

1
2
3
4
5
int main(int argc,char* argv[])
{
CVirtual MyVirtual;
return 0;
}

int main(int argc,char* argv[])

1
2
00401048    8D4D F8         lea ecx,dword ptr ss:[ebp-0x8]           ; 获取对象首地址
0040104B E8 C4FFFFFF call test.00401014 ; 调用构造函数,类CVirtual中没有定义构造函数,此调用未默认构造函数

默认构造函数

1
2
3
4
5
00401089    59              pop ecx                                                 ; 还原this指针
0040108A 894D FC mov dword ptr ss:[ebp-0x4],ecx ; [ebp-0x4]存储this指针
0040108D 8B45 FC mov eax,dword ptr ss:[ebp-0x4]
00401090 C700 1C204200 mov dword ptr ds:[eax],offset test.CVirtual::`vftable'c>; 取虚表的首地址,保存到虚表指针中
00401096 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; 返回对象首地址

编译器为类CVirtual提供了默认的构造函数。该构造函数先取得虚表的首地址0x0042201C,然后赋值到虚表指针中。虚表信息如下:

虚表中有两个地址,分别为成员函数GetNumber和SetNumber的地址。因此,得到虚表指针就相当于得到了类中所有虚函数的首地址。对象的虚表指针初始化是通过编译器在构造函数内插入代码来完成的。在用户没有编写构造函数时,必须初始化虚表指针,因此编译器会提供默认的构造函数,以完成虚表指针的初始化。
由于虚表信息在编译后会被链接到对应的执行文件中,因此所获得的虚表地址是一个相对固定的地址。虚表中虚函数的地址排列顺序依据虚函数在类中的声明顺序而定,先声明的虚函数的地址会被排列在虚表中靠前的位置。第一个被声明的虚函数的地址在虚表的首地址处。
在虚表指针的初始化过程中,对象执行了构造函数后,就得到了虚表指针,当其他代码访问这个对象的虚函数时,会根据对象的首地址,取出对应虚表元素。当函数被调用时,会间接访问虚表,得到对应的虚函数首地址,并调用执行。此种调用方式是一个间接调用过程,需要多次寻址才能完成。
这种通过虚表间接寻址访问的情况只有在使用对象的指针或引用来调用虚函数的时候才会出现。当直接使用对象调用自身的虚函数时,没有必要查表访问。这是因为已经明确调用的是自身成员函数,根本没有构成多态性,查询虚表只会画蛇添足,降低程序执行效率。

1
2
3
4
5
6
7
int main(int argc,char* argv[])
{
CVirtual MyVirtual;
MyVirtual.SetNumber(argc);
printf("%d\r\n", MyVirtual.GetNumber());
return 0;
}

1
2
3
4
5
6
7
8
9
0040D538    8D4D F8         lea ecx,dword ptr ss:[ebp-0x8]
0040D53B E8 D43AFFFF call test.00401014 ; 默认构造函数
0040D540 8B45 08 mov eax,dword ptr ss:[ebp+0x8]
0040D543 50 push eax
0040D544 8D4D F8 lea ecx,dword ptr ss:[ebp-0x8]
0040D547 E8 BE3AFFFF call test.0040100A ; 调用虚函数SetNumber
0040D54C 8D4D F8 lea ecx,dword ptr ss:[ebp-0x8]
0040D54F E8 B13AFFFF call test.00401005 ; 调用虚函数GetNumber
;虚函数与其他非虚函数的成员函数的实现流程一致,函数内部无差别。

以上代码直接通过对象调用自身的成员虚函数,因此编译器使用了直接调用函数的方式,没有访问虚表指针,间接获取虚函数地址。对象的多态性常常在派生和继承关系中体现。
仔细分析虚表指针的原理后发现,编译器隐藏了初始化虚表指针 的实现代码,当类中出现虚函数时,必须在构造函数中对虚表指针执行初始化操作,而没有虚函数的类对象在构造时,不会进行初始化虚表指针的操作,由此可见,在分析构造函数时,又增加了一个新特征—虚表指针初始化。根据以上分析,如果排除开发者伪造编译器生成的代码来误导分析人员的可能,我们就可以给出一个结论:对于单线继承的类结构,在其某个成员函数中,将this地址初始化为虚表首地址时,可以判定这个成员函数为构造函数。
构造函数可以通过识别虚表指针的初始化来简化分析,那么析构函数中是否有对虚表指针的操作呢?

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
class CVirtual
{
public:
virtual int GetNumber()
{
return m_nNumber;
}
virtual void SetNumber(int nNumber)
{
m_nNumber = nNumber;
}
~CVirtual()
{
printf("~CVirtual");
}
private:
int m_nNumber;
};
int main(int argc,char* argv[])
{
CVirtual MyVirtual;
MyVirtual.SetNumber(argc);
printf("%d\r\n", MyVirtual.GetNumber());
return 0;
}

~CVirtual()

1
2
3
4
5
0040119A    894D FC         mov dword ptr ss:[ebp-0x4],ecx                                            ; [ebp-0x4]保存this指针
0040119D 8B45 FC mov eax,dword ptr ss:[ebp-0x4] ; eax得到this指针,这是虚表的位置
004011A0 C700 30504200 mov dword ptr ds:[eax],offset test.CVirtual::`vftable'1rementgsAlterter ; 将当期类的虚表首地址赋值到虚表指针中
004011A6 68 24504200 push test.00425024
004011AB E8 70000000 call test.printfgvdbgind_blockeressges ; printf

构造函数与析构函数的分析流程得知,两者对虚表的操作过程几乎相同,都是将虚表指针设置为当前对象所属类中的虚表首地址。两者看似相同,事实上差别很大。
构造函数中完成的是初始化虚表指针的工作,此时虚表指针并没有指向虚表地址,而指向析构函数时,其对象的虚表指针已经指向了某个虚表首地址。是否觉得在析构函数中填写虚表是没有必要的?这里实际上是在还原虚表指针,让其指向自身的虚表首地址,防止在析构函数中调用虚函数时取到非自身虚表,从而导致函数调用错误。
鉴定析构函数的依据和虚表指针相关,识别析构函数的充分条件是写入虚表指针,但是,它与签名讨论的虚表指针初始化不同。所谓虚表指针初始化,是指对象原来的虚表指针位置不是有效的,初始化后才指向了正确的虚函数表;而写入虚表指针,是指对象的虚表指针可能是有效的,已经指向了正确的虚函数表,将对象的虚表指针重新赋值后,其指针可能指向了另一个虚表,其虚表的内容不一定和原来的一样。
结合IDA中的引用参考可以得知,只要确定一个构造函数或者析构函数,就能顺藤摸瓜找到其他的构造函数以及类之间的关系。

虚函数的识别

在判断是否为虚函数时,我们需要做的是鉴别类中是否出现了一下这些特征:

  • 类中隐式定义了一个数据成员;
  • 该数据成员在首地址处,占4字节;
  • 构造函数会将此数据成员初始化为某个数据的首地址;
  • 这个地址属于数据区,是相对固定的地址;
  • 在这个数组内,每个元素都是函数指针;
  • 仔细观察这些函数,它们被调用时,第一个参数必然是this指针;
  • 在这些函数内部,很有可能会对this指针使用相对间接的访问方式。

有了虚表,类中所有的虚函数都被囊括在其中。这个虚表的查找有需要得到指向它的虚表指针,虚表指针又是在构造函数中被初始化为虚表首地址。由此可见,想要找到虚函数,就要得到虚表的首地址。
经过层层分析,虚函数的识别最终转变成识别构造函数或者析构函数。构造函数与虚表指针的初始化有依赖关系。对于构造函数而言,虚表指针的初始化会使识别构造函数的过程简化,而虚表指针的初始化又必须在构造函数内完成,因此在分析构造函数时,应重点考察对象首地址4字节被赋予的值。

1
2
3
4
5
6
7
8
;具有成员函数特征,传递对象首地址作为this指针
lea ecx,[ebp-8] ;获取对象首地址
call XXXXXXXXh ;调用函数

;调用函数的实现代码
mov reg,this ;某寄存器得到对象首地址
;向对象首地址处写入4字节数据,查看并确认此4字节数据是否为函数地址表的首地址
mov dword ptr [eax], XXXXXXXXh

当分析过程中遇到此类特性代码时,应高度怀疑其为一个构造函数或者析构函数。查看并确认此4字节数据是否为函数地址表的首地址,即可判断是否为构造或者析构函数。
在对构造函数和析构函数进行区分时,分析它们的特征可知:构造函数一定出现在析构函数之前,而且在构造函数执行前虚表指针没有指向虚表的首地址;而析构函数出现在所有成员函数之后,在实现过程中,虚表指针已经指向了某一个虚表的首地址。
识别出了虚表的首地址后,就可以利用IDA的引用参考功能得到所有引用此虚表首地址的函数所在的地址标号。这里不另做演示。

文章目录
  1. 1. 虚函数的机制
  2. 2. 虚函数的识别