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(); a->speak();
|
0x02.虚函数表结构
逆向代码如下:
#include <cstdio>
class Animal { public: int age; Animal() : age(1) {} virtual void speak() { puts("Animal::speak"); } virtual void eat() { puts("Animal::eat"); } virtual ~Animal() {} };
class Dog : public Animal { public: int balls; Dog() : balls(0) {} void speak() override { puts("Dog::wang wang"); } virtual void fetch() { puts("Dog::fetch"); } };
class Machine { public: int power; Machine() : power(100) {} virtual void charge() { puts("Machine::charge"); } virtual ~Machine() {} };
class RobotDog : public Dog, public Machine { public: int version; RobotDog() : version(2) {} void speak() override { puts("RobotDog::beep"); } void charge() override { puts("RobotDog::charging"); } };
class Shape { public: virtual double area() const = 0; 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(); }
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());
Animal *heapDog = new Dog(); heapDog->speak(); delete heapDog;
return 0; }
|
初始化虚表的操作在构造函数中实现,在没有构造函数时,也必须初始化虚表指针,所以编译器会提供默认的构造函数,完成虚表指针的初始化。可以通过Animal的构造函数找到对应Animal类的虚表。

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

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

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

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

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

多继承的情况下对象是两块基类拼起来:
RobotDog 对象: ┌─────────────────┐ │ Dog 那部分 │ ← 从这里开始 ├─────────────────┤ │ Machine 那部分 │ ← 从这里开始! ├─────────────────┤ │ 自己的成员 │ └─────────────────┘
|
当调用类似useMachine方法时,对应汇编代码如下,很明显编译 useMachine 时根本不知道m的动态类型。可能是普通 Machine,可能是 RobotDog。它只能机械地按“this已指向Machine子对象”来找到虚函数表,来调用第0个槽位的charge方法。
void useMachine(Machine* m) { m->charge(); }
|

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

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

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

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