C++逆向之虚函数表

0x01.为什么需要这个玩意?

如下代码,编译器无法在编译期知道 a 指向的是 Dog 还是 Cat,所以必须有一套运行期的查找机制,这就是虚函数表的作用。

class Animal {
public:
void eat() { puts("eat something"); } // 普通函数:编译期就确定调用地址
virtual void speak() { puts("..."); } // 虚函数:运行期根据对象实际类型决定
};

class Dog : public Animal {
public:
void speak() override { puts("ww"); }
};

Animal* a = new Dog();
a->eat(); // 调 Animal::eat(看指针类型,编译期定死)
a->speak(); // 调 Dog::speak(看对象实际类型,运行期查表)(多态)

0x02.虚函数表结构

逆向代码如下:

#include <cstdio>

// ---------- 基类 ----------
class Animal {
public:
int age;
Animal() : age(1) {}
virtual void speak() { puts("Animal::speak"); } // 虚表槽 0
virtual void eat() { puts("Animal::eat"); } // 虚表槽 1
virtual ~Animal() {} // 虚表槽 2(虚析构)
};

// ---------- 单继承 + 覆盖 + 新增虚函数 ----------
class Dog : public Animal {
public:
int balls;
Dog() : balls(0) {}
void speak() override { puts("Dog::wang wang"); } // 覆盖槽 0
virtual void fetch() { puts("Dog::fetch"); } // 新增槽 3
};

// ---------- 另一个基类(用于多重继承) ----------
class Machine {
public:
int power;
Machine() : power(100) {}
virtual void charge() { puts("Machine::charge"); } // Machine 虚表槽 0
virtual ~Machine() {}
};

// ---------- 多重继承:两个 vptr + this 调整 thunk ----------
class RobotDog : public Dog, public Machine {
public:
int version;
RobotDog() : version(2) {}
void speak() override { puts("RobotDog::beep"); } // 覆盖 Dog 表槽 0
void charge() override { puts("RobotDog::charging"); } // 覆盖 Machine 表槽 0
};

// ---------- 纯虚函数 / 抽象类 ----------
class Shape {
public:
virtual double area() const = 0; // 虚表项指向 __cxa_pure_virtual
virtual ~Shape() {}
};

class Circle : public Shape {
public:
double r;
Circle(double r) : r(r) {}
double area() const override { return 3.14159 * r * r; }
};

// ---------- 多态调用现场 ----------
void poke(Animal *a) {
a->speak(); // 虚调用:看对象实际类型
a->eat();
}

void useMachine(Machine *m) {
m->charge(); // 多重继承场景:RobotDog* 转 Machine* 时 this 会调偏移
}

int main() {
Dog d;
RobotDog rd;
Circle c(2.0);

printf("--- poke(dog) ---\n");
poke(&d);
printf("--- poke(robotdog) ---\n");
poke(&rd);
printf("--- useMachine(robotdog) ---\n");
useMachine(&rd);
printf("circle area = %f\n", c.area());

// 通过基类指针 new/delete:观察虚析构
Animal *heapDog = new Dog();
heapDog->speak();
delete heapDog;

return 0;
}

初始化虚表的操作在构造函数中实现,在没有构造函数时,也必须初始化虚表指针,所以编译器会提供默认的构造函数,完成虚表指针的初始化。可以通过Animal的构造函数找到对应Animal类的虚表。

image-20260826110307308

那么虚表是属于类的,不属于对象,一个类只有一张虚表,放在 .rdata,编译期就生成好了。奇怪的是,为什么虚表中会有两个析构函数?这是 Itanium ABI(GCC/Clang/MinGW)的设计,两个版本分工不同,D1负责执行析构函数体和析构成员和基类,而D0负责先调 D1做完析构,再调operator delete释放内存。并且可以发现虚表项按虚函数声明顺序排列的,第1个声明的虚函数在槽 0,第2个在槽1,以此内推。

Animal a;              // 栈对象:a 离开作用域 → 编译器调 D1(不用 free)
Animal *p = new Animal;
delete p; // 堆对象:因为 p 是多态指针 → 虚调用虚表里的 D0(析构 + free)

image-20260826110424276

每一个实例对象头部(偏移0)有一个vptr指针(虚表指针),这个指针指向.rdata中的虚函数表,在构造函数里被填入。如下Animal构造函数可证实这一点,rcx是构造函数的隐藏参数this指针。

image-20260826161852645

0x03.虚函数调用汇编

看到poke函数相关汇编,调用虚函数具体步骤就是通过隐藏参数this指针拿到vptr指针,利用vptr指针获取对应槽位的函数地址,最后进行调用。

image-20260826113339928

0x04.继承的虚表形态

用同样的方法看到继承自Animal的Dog类的虚函数表,不难得出结论:派生类虚表 = 复制基类虚表 + 同槽覆盖override + 尾部追加新虚函数。并且覆盖发生在相同的槽位,这是多态能工作的根本(不管指针是Animal*还是Dog*,槽0永远是speak)。

image-20260826114016558

上面所述情况是单继承,那么是多继承呢?可以看到继承自Dog和Machine的RobotDog类的虚函数表。这时候存在两张虚函数表,因为对象里有两个“基类部分”,每个部分开头都要有一个vptr;Dog那部分用的表放真函数,Machine那部分用的表放纠偏跳板(thunk)。每个基类指针用自己的那张表查,就都能正确调到函数。

image-20260826115214593

多继承的情况下对象是两块基类拼起来:

RobotDog 对象:
┌─────────────────┐
│ Dog 那部分 │ ← 从这里开始
├─────────────────┤
│ Machine 那部分 │ ← 从这里开始!
├─────────────────┤
│ 自己的成员 │
└─────────────────┘

当调用类似useMachine方法时,对应汇编代码如下,很明显编译 useMachine 时根本不知道m的动态类型。可能是普通 Machine,可能是 RobotDog。它只能机械地按“this已指向Machine子对象”来找到虚函数表,来调用第0个槽位的charge方法。

void useMachine(Machine* m) {
m->charge();
}

image-20260826155316788

所以在main函数中调用useMachine(&rd)之前,会将rd对象增加0x10,显然就是跳过Dog那部分嘛,获取Machine,这是在编译的时候就决定好的。

image-20260826160612398

那就可以通过这个数值找vptr,然后找到对应虚函数表,因为RobotDog类的构造函数会将第二张虚表写入开头。

image-20260826162152088

那么对应thunk表中虚函数会干嘛?首先是把this指针还原,还是指向rd对象,然后将调用交给RobotDog重载的charge方法。整个过程非常完美。

image-20260826162623645

那么如果没有这个chunk表,没有main函数中加的0x10,每一步都会出问题。当然这一切都是编译的时候就决定好了。