12.1 识别类和类之间的关系(7)
以上代码对普通析构函数与虚析构函数进行了对比,说明了为什么类在有了派生与继承关系后,需要声明虚析构函数的原因。对于没有派生和继承关系的类结构,是否将析构函数声明为虚析构函数不会影响调用的过程,但是在编写析构函数时应养成习惯,无论当前是否有派生或继承关系,都应将析构函数声明为虚析构函数,以防止将来更新和维护代码时发生析构函数的错误调用。
了解了派生和继承的执行流程与实现原理后,又该如何利用这些知识去识别代码中类与类之间的关系呢?最好的办法还是先定位构造函数,有了构造函数就可根据构造的先后顺序得到与之有关的其他类。在构造函数中只构造自己的类很明显是个基类。对于构造函数中存在调用父类构造函数的情况时,可利用虚表,在IDA中使用引用参考的功能便可得到所有的构造函数和析构函数,进而得到了它们之间的派生和继承关系。
将代码清单12-5修改为如下所示的代码,我们以Release选项组对这段代码进行编译,然后利用IDA对其进行分析。
- // 综合讲解(建议读者先用VC++(www.cppentry.com)分析一下Debug选项组编译的过程,然后再看本内容)
- class CPerson{ // 基类—人类
- public:
- CPerson(){
- ShowSpeak(); // 注意,构造函数调用了虚函数
- }
- virtual ~CPerson(){
- ShowSpeak(); // 注意,析构函数调用了虚函数
- }
- virtual void ShowSpeak(){ // 在这个函数里调用了其他的虚函数GetClassName()
- printf("%s::ShowSpeak()\r\n", GetClassName());
- return;
- }
- virtual char* GetClassName()
- {
- return "CPerson";
- }
- };
-
- class CChinese : public CPerson{ // 中国人,继承自"人"类
- public:
- CChinese(){
- ShowSpeak();
- }
- virtual ~CChinese(){
- ShowSpeak();
- }
- virtual char* GetClassName(){
- return "CChinese";
- }
- };
-
- void main(int argc, char* argv[]){
- CPerson *pPerson = new CChinese;
- pPerson->ShowSpeak();
- delete pPerson;
- }
-
- ; 反汇编讲解
- ; 在IDA中打开执行文件,载入sig,定位到main函数,得到如下代码
-
- .text:00401080 ; int __cdecl main(int argc, const char **argv, const char **envp)
- .text:00401080 _main proc near ; CODE XREF: start+AFp
- .text:00401080
- .text:00401080 var_10= dword ptr -10h
- .text:00401080 var_C= dword ptr -0Ch
- .text:00401080 var_4= dword ptr -4
- .text:00401080 argc= dword ptr 4
- .text:00401080 argv= dword ptr 8
- .text:00401080 envp= dword ptr 0Ch
- .text:00401080
- .text:00401080 push 0FFFFFFFFh
- .text:00401082 push offset unknown_libname_35 ; Microsoft VisualC 2-9/net runtime
- .text:00401087 mov eax, large fs:0
- .text:0040108D push eax
- .text:0040108E mov large fs:0, esp ; 注册C++(www.cppentry.com)异常处理
- .text:00401095 push ecx
- .text:00401096 push esi ; 保存寄存器环境
- .text:00401097 push 4 ; unsigned int
- .text:00401099 call 2@YAPAXI@Z ; operator new(uint)申请4字节堆空间
- .text:0040109E mov esi, eax ; esi保存new调用的返回值
- .text:004010A0 add esp, 4 ; 平衡new调用的参数
- .text:004010A3 mov [esp+14h+var_10], esi ; new返回值保存到局部变量var_10中
- ; 编译器插入了检查new返回值的代码,如果返回值为0,则跳过构造函数的调用
- .text:004010A7 test esi, esi
- ; 在IDA中单击var_4,引用处会高亮显示,可以观察出这个变量是计数标记
- .text:004010A9 mov [esp+14h+var_4], 0
- ; 单击下面这个跳转指令的标号loc_4010F2,目标处会高亮显示,结合目标处上面的一条指令(地址
- ; 004010F0处),可以看出这是一个分支结构,跳转的目标是new返回值为0时的处理(将esi置为0)。读
- ; 者可以按照命名规范重新定义这些标号(IDA中重命名的快捷键是N,选中标号以后按N键即可)
- .text:004010B1 jz short loc_4010F2
- ; 如果new返回值不为0,则ecx保存堆地址,结合004010BB地址处的call指令,可推测是thiscall
- ; 的调用方式,需要到004010BB处看看有没有访问ecx才能进一步确定
- .text:004010B3 mov ecx, esi
- ; 这个地方很关键,需要查看off_40C0DC中的内容
- .text:004010B5 mov dword ptr [esi], offset off_40C0DC
- off_40C0DC中的内容为:
- .rdata:0040C0DC off_40C0DC dd offset sub_401170 ; DATA XREF: _main+35↑o
- .rdata:0040C0DC ; sub_40ACFB:loc_401120↑o sub_401170+3↑o sub_4011E0+49↑o
- .rdata:0040C0E0 dd offset sub_401140
IDA以注释的形式给出了反汇编代码中所有引用了标号off_40C0DC的指令地址,以便于我们分析时参考。如“; DATA XREF: _main+35”,这表示在main函数的首地址偏移35h字节处的指令引用了标号off_40C0DC,最后的上箭头“↑”表示引用处的地址在当前标号的上面,也就是说引用处的地址值比这个标号的地址值小。
接着观察sub_401170和sub_401140中的内容,双击后可以看到这两个名称都是函数名称,可证实off_40C0DC是函数指针数组的首地址,而且其中每个函数都有对ecx的引用,在引用前没有给ecx赋值,说明这两个函数都是将ecx作为参数传递的。结合004010B5处的指令“mov dword ptr [esi], offset off_40C0DC”,其中esi中保存的是new调用所申请的堆空间首地址,这条指令在首地址处放置了函数指针数组的地址。
结合以上种种信息,我们可以认定,esi中的地址是对象的地址,而函数指针数组就是虚表。退一步讲,即使源码不是这样,我们按此还原后的C++(www.cppentry.com)代码在功能和内存布局上也是等价的。