intro

C++是一门支持面向对象的语言,为面向对象的软件开发提供了丰富的语言支持。要想高效、正确地使用C++中的继承、多台等语言特性,就必须对这些特性的底层有一定的了解。

其实,C++的对象模型的核心概念并不多,最重要的概念是虚函数。虚函数式程序运行时定义的函数。虚函数的地址不能在编译时确定,只能在调用即将进行时确定。所有对虚函数的饮用通常放在一个专用数组————虚函数表(Virtual Table,VBTL)中,数组的每个元素中存放的就是类中虚函数的地址。调用虚函数时,程序先取出虚函数指针(Virtual Table Pointer,VPTR),得到虚函数表的地址,再根据这个地址到虚函数表中取出该函数的地址,最后调用该函数。

x32 demo code

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class CSum {
public:
virtual int Add(int a,int b) {
return (a + b);
}
virtual int Sub(int a, int b) {
return (a - b);
}
};

void main() {
CSum* pCSum = new CSum;
pCSum->Add(1, 2);
pCSum->Sub(1, 2);
}

首先会使用new函数分配class所需的内存(由IDA识别)。调用成功后保存在eax寄存器中,最后传到ecx。

我们可以看到,v3指向了虚函数表,通过指针进行调用Add和Sub函数。

而且需要注意到,程序以ecx作为this指针的载体传递给虚函数成员函数,并利用两次间接寻址得到虚函数的正确地址从而执行。

image-20220517132723253

image-20220517132332719

虚函数表

image-20220517125804861

x64 demo code

成员函数CVirtual,析构函数CVirtual

虚函数 func1、func2

私有变量 m_nMember1、m_nMember2

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
26
27
28
29
30
31
32
33
34
35
36
37
38
/*--------------------------------------------
《加密与解密(第四版)》
(c) 看雪学院 www.kanxue.com 2000-2018
----------------------------------------------*/

// Example4-1.cpp : 定义控制台应用程序的入口点。
//

#include "stdafx.h"


class CVirtual {
public:
CVirtual() {
m_nMember1 = 1;
m_nMember2 = 2;
printf("CVirtual()\r\n");
}
virtual ~CVirtual() {
printf("~CVirtual()\r\n");
}
virtual void fun1() {
printf("fun1()\r\n");
}
virtual void fun2() {
printf("fun2()\r\n");
}
private:
int m_nMember1;
int m_nMember2;
};

int main(int argc, char* argv[]) {
CVirtual object;
object.fun1();
object.fun2();
return 0;
}

for循环用于初始化栈空间为0xCC

image-20220517143459563

函数判断

那么我们如何判断构造函数、析构函数?

main函数在申请了对象实例空间后的第一个函数调用即可猜测为类的构造函数,调用的最后一个函数可以猜测为析构函数

构造函数实现

首先初始化虚表指针,然后初始化数据乘员,构造函数完成,返回this指针。为什么需要返回this指针?c++编译器为了判断一个构造是否被调用而设置的。

如果一个函数在入口处使用lea reg,off_xxxxxxxxxmov [reg],reg特征初始化虚表,且返回值为this指针,就可以怀疑这个函数是一个构造函数。

image-20220517145509769

析构函数实现

首先初始化栈空间,然后赋值虚表,最后返回this指针。

image-20220517152232823

既然前面两个函数的流程都相同,那么如何判断构造函数和析构函数呢?

————在main函数中的调用顺序

虚表结构

因为这个类有虚函数,所以编译器为这个类产生了一个虚表,其存储在全局数据区(.rdata).虚表的每一项都是8个字节,其中存储的是成员函数的地址。

这里需要注意:因为虚表的最后一项不一定是以0结尾,所以虚表项的个数会根据其他信息来确定

虚表汇总的函数按类中成员函数声明顺序依次放入。

函数分布顺序在某些情况下不一定与声明顺序相同(例如虚函数重载),不过这个顺序对逆向还原代码没有影响。

在该demo code中虽然只编写了一个析构函数,编译器却生成了两个析构函数。其中一个是普通析构函数,对象出作用域调用;另一个放在虚表里,在delete对象的时候调用。

image-20220517153516872

image-20220517153449968

虚表中的析构函数比普通的析构函数多一个delete this操作。

delete 对象的时候,需要先调用析构函数,再释放对象的堆空间。

object.~CVirtual()属于多态调用,所以会直接调用虚表里的析构函数,这个时候对象被释放。delete object这句代码优惠调用虚表里的析构函数,这样堆空间会重复释放。

那么为了解决这个问题,VC++编译器给析构函数增加了一个参数,pObject->~CVirtual()调用时参数传递0,这样对象就不会被释放。如果delete pObject的时候参数传递1,对象就会被释放。这样就解决了上面那个问题。

gcc则采用了虚表里放两个析构函数的方法解决该问题。

本文采用CC-BY-SA-3.0协议,转载请注明出处
Author: scr1pt