11.2 虚函数的识别

如果掌握了以上所讲解的虚函数的实现机制,就具备了识别虚函数的能力。在判断是否为虚函数时,我们要做的是鉴别类中是否出现了以下这些特征:

类中隐式定义了一个数据成员;

该数据成员在首地址处,占4字节;

构造函数会将此数据成员初始化为某个数组的首地址;

这个地址属于数据区,是相对固定的地址;

在这个数组内,每个元素都是函数指针;

仔细观察这些函数,它们被调用时,第一个参数必然是this指针(要注意调用约定);

在这些函数内部,很有可能会对this指针使用相对间接的访问方式。

有了虚表,类中所有的虚函数都被囊括在其中。这个虚表的查找又需要得到指向它的虚表指针,虚表指针又是在构造函数中被初始化为虚表首地址。由此可见,要想找到虚函数,就要得到虚表的首地址。

经过层层分析,虚函数的识别最终转变成识别构造函数或者析构函数。构造函数与虚表指针的初始化有依赖关系。对于构造函数而言,虚表指针的初始化会使识别构造函数的过程简化,而虚表指针的初始化又必须在构造函数内完成,因此在分析构造函数时,应重点考察对象首地址前4字节被赋予的值。

查询this指针所指向的地址中前4字节的内存数据,跟踪并分析其数据是否为地址信息,是否对这4字节的内容进行了赋值操作,赋值后的数据是否指向了某个地址表,表中各单元项是否为函数首地址。有了这一系列的鉴定流程后,就可得知此成员函数是否为一个构造函数。识别出构造函数后,即可顺藤摸瓜找到所有的虚函数。我们来看一段如下所示的一段代码:


;具有成员函数特征,传递对象首地址作为this指针

lea ecx,[ebp-8];获取对象首地址

call XXXXXXXXh;调用函数

;调用函数的实现代码

pop ecx;this指针的还原,非Debug编译选项组在可能无此代码

mov eax, dword ptr[ecx];取出首地址前4字节数据

;向对象首地址处写入4字节数据,查看并确认此4字节数据是否为函数地址表的首地址

mov dword ptr[eax],XXXXXXXXh


如以上代码所示,当分析过程中遇到此类特征代码时,应高度怀疑其为一个构造函数或者析构函数。查看并确认此4字节数据是否为函数地址表的首地址,即可判断是否为构造或析构函数。

在对构造函数和析构函数进行区分时,分析它们的特性可知:构造函数一定出现在析构函数之前,而且在构造函数中虚表指针没有指向虚表的首地址;而析构函数出现在所有成员函数之后,在实现过程中,虚表指针已经指向了某一个虚表的首地址。

识别出了虚表的首地址后,就可以利用IDA的引用参考功能得到所有引用此虚表首地址的函数所在的地址标号。只有构造函数和析构函数中存在对虚表指针的修改操作,等同于定位到了引用此虚表的所有构造函数和析构函数,这使得识别类中的构造函数和析构变得更为简单,也更为准确,引用参考选项如图11-4所示。

图 11-4 “交叉参考到……”与“交叉参考来自……”[1]

这个选项可在虚表首地址引用处通过右击弹出。由于代码过于简单,使用Release版编译后会将简单的类结构优化为普通变量,简单的成员函数也会自动内联,因此使用Debug版分析学习。对“交叉参考来自……”的分析如图11-5所示。

图 11-5 “交叉参考来自……”视图信息

选中图11-4中的“Chart of xrefs from”选项后弹出如图11-5所示的“交叉参考来自……”视图信息。该视图显示了此地址的来源信息,j_?GetNumber@CVirtual@@UAEHXZ为GetNumber粉碎后的函数名称。

选中图11-4中的“Chart of xrefs to”选项后弹出如图11-6所示的交叉参考视图信息。该视图中显示了虚表地址的引用者(读取者)。如图11-6所示,一共有3处引用了此地址,分别有3个地址标号指向了虚表的首地址标号。

图 11-6 交叉参考视图信息

图11-6中的3个地址标号分别表示了两个构造函数与一个析构函数,由于它们的实现中都存在引用虚表首地址修改虚表指针的操作,因此IDA会将它们找到并显示出来。所引用的函数如图11-7所示。

图 11-7 3个引用的函数

借助虚表和IDA的引用参考功能,便能轻松找到类中所有的构造函数、析构函数和虚函数的信息,可见虚表的重要性。

学习了交叉参考与虚表的知识后,我们可以利用交叉参考与虚表的组合快速识别出程序中全局对象所对应的类中的构造函数和析构函数。由于构造函数可以被重载,分析起来相对复杂,因此可以先从任何一个构造函数或者析构函数入手,找到虚表的操作部分,使用IDA的交叉参考找到所有对此虚表指针有修改的函数的地址,除析构函数的地址外,剩余的就是构造函数。下面先观察带有全局对象的C++源码,如代码清单11-5。

代码清单11-5 含有虚函数的全局对象


class CGlobal{

public:

CGlobal(){//无参构造函数

printf("CGlobal\r\n");

}

CGlobal(int nInt){//有参构造函数

printf("CGlobal(int nInt)%d\r\n",nInt);

}

CGlobal(char*pChar){//有参构造函数

printf("CGlobal(char*pChar)%s\r\n",pChar);

}

virtual~CGlobal(){//虚析构函数

printf("~CGlobal()\r\n");

}

void Show(){

printf("对象首地址:0x%08x",this);

}

};

CGlobal g_Global_void;

CGlobal g_Global_int(10);

CGlobal g_Global_lpchar("hello C++");

void main(){

g_Global_void.Show();

g_Global_int.Show();

g_Global_lpchar.Show();

}


代码清单11-5中定义了三个全局对象,分别调用了三种不同的构造函数。main函数中使用全局对象调用了成员函数Show。在分析过程中,全局对象调用成员函数的操作非常容易识别。第一步是定位全局对象,示例如代码清单11-6所示。

代码清单11-6 全局对象识别


;代码截取自IDA,为Release版

00401150;int__cdecl main(int argc, const char**argv, const char**envp)

00401150_main proc near;CODE XREF:start+AF p

00401150 mov eax, dword_40A960;获取对象首地址

00401155 push eax

00401156 push offset unk_40809C;unk_40809C,可更名为strShowInfo

0040115B call printf;调用printf函数

00401160 mov ecx, dword_40A95C;获取对象首地址

00401166 push ecx

00401167 push offset strShowInfo

0040116C call printf

00401171 mov edx, dword_40A958;获取对象首地址

00401177 push edx

00401178 push offset strShowInfo

0040117D call printf

00401182 add esp,18h

00401185 xor eax, eax

00401187 retn

00401187_main endp


经过内联优化后,代码清单11-6中没有了成员函数Show的调用过程,直接内联使用printf函数显示全局对象首地址。虽然没有了成员函数传递this指针的过程,但由于在成员函数中使用了printf,经过内联优化后,必须存在等价类成员函数的功能,故成员函数的实现代码不会被删除。我们只需对三个全局地址标号dword_40A960、dword_40A95C和dword_40A958进行逐一检查,即可得知是否为全局对象。有了全局对象的地址标号以后,接下来要对它们重新命名,如下所示:


dword_40A960 g_Obj_One

dword_40A95C g_Obj_Two

dword_40A958 g_Obj_Three


第10章代码清单10-5的全局对象构造代理函数的分析中有个神秘的调用:


00401488 call$E6(004013a0);这个call里面调用了构造函数,请读者自行查看其中的代码

0040148D call$E8(0040f110);登记析构函数的地址,后面将详细解释


其中$E6是构造函数的代理,而$E8又是什么呢?

我们现在可以继续分析$E8,接着往下看:


;略去函数入口等无关部分,看关键处的atexit调用

00401228 push offset$E10(00401260)

0040122D call atexit(00401860)

00401232 add esp,4


这个函数的关键之处是调用atexit,查阅相关文档可知,该函数可以在退出main函数后执行开发者自定义的函数(即注册终止函数),其函数声明如下:


int__cdecl atexit(void(__cdecl*)(void));


只有一个无参且无返回值的函数指针作为atexit的参数,这个函数指针会添加在终止函数的数组中,在main函数执行完毕后,由doexit函数倒序执行数组中的每个函数。

了解这个函数后,请读者观察atexit的参数$E10(00401260),将地址00401260的内容反汇编之后不难发现,这个$E10就是析构函数的代理。为了不使本书的篇幅过大,这里就不粘贴代码了,请读者自行动手验证并观察。

那么,atexit函数理所当然地成为了我们寻找全局对象析构函数的指路灯。注意,在IDA的环境下,C的调用约定是在函数名前加上下划线“_”。

查找函数_atexit,查看调用它的地址,如图11-8所示。

图 11-8 _atexit的引用查看

根据图11-8的显示,至少有两个地址调用这个函数,分别为0x00401065和0x004010C5。双击0x00401065这个地址,找到_atexit的函数调用处,如图11-9所示。

图 11-9 _atexit的引用函数

在图11-9中找到_atexit的函数调用处,在调用_atexit函数前,压入了一个参数,这个参数为一个地址标号,此地址标号所指向的地址正是全局对象g_Obj_One的析构函数。在loc_401070中发现了一句代码“mov g_obj_One, offset off_4070B8”,这就是在析构函数中设置虚表信息,off_4070B8是虚表首地址,将其重新命名为Obj_vir。对Obj_vir使用交叉参考,如图11-10所示。

图 11-10 虚表Obj_vir的交叉参考

图 11-10 (续)

从图11-10中可知,共有6处引用到此虚表,其中3处对应构造函数,另外3处对应析构函数,地址信息如下:


0x004010F0 对应构造函数的调用地址

0x00401090 对应构造函数的调用地址

0x00401000 对应构造函数的调用地址

0x00401135 对应析构函数的调用地址

0x004010D5 对应析构函数的调用地址

0x00401075 对应析构函数的调用地址


例如,0x00401075这个地址便是图11-9中析构函数中写入虚表指令的地址。其余析构函数的查看分析略。在IDA中查看地址0x004010F0,如图11-11所示。

图 11-11 分析构造函数

图11-11显示了地址0x004010F0中的信息,这个函数调用了sub_401100。深入到sub_401100中查看,发现这个地址标号正是构造函数的地址。其余地址的分析过程相同,这里就不一一分析和验证了。

结合虚表可以方便快捷地根据析构函数定位全局对象所属类的构造函数的调用情况。

[1]“Chart of xrefs from”指的是某数据或函数的来源,IDA的中文版翻译为“交叉参考来自……”是贴切的,因此本书使用“交叉参考来自……”;“Chart of xrefs to”指的是数据或函数的引用者(读取者),译为“交叉参考到……”也是很贴切的,故本书使用此种译法。