[阅读型]深入理解C++多继承(multiple inheritance)与虚继承(virtual inheritance)的底层原理与实现-以g++为例

前言

做安全的肯定是要深究一下,特别是了解其c++对象的memory layout和一些编译器生成的固定代码。另一方面c++开发者,既然是选择折腾c++,实际上也有了解的必要。本文不是讨论怎么用继承去实现一个良好的设计,而是讨论c++编译器是怎么支持这个特性的。这里不要把编译器想象的多么神通广大,实际上它是通过生成一些固定的代码与数据来实现的。官方参考文献c++的abi1

极具争议的多继承与虚继承为啥这么恶心?

如出现错误,欢迎补充与修正。
更新时间2021.07.30

1. virtual function需要解决的问题

class A { 
public:
	virtual void f() {}
};
class Bpublic A { 
public:
	virtual void f() {}
};

void func(A* p) {
	p->f();
}

考虑上面的函数func(),因为f()是virtual修饰的,应C++多态要求,如果*p实际类型是A,执行A::f(); 如果*p实际类型是B,执行B::f()。但是编译器在编译的时候不知道指针p指向的对象是A还是B。
也就是说,编译器在编译期要解决两个问题

  • 要执行的函数地址在哪里?C++要编译成汇编,具体为call addr,addr是一个内存地址不能是一个符号。
  • 执行的这个函数的this指针是否正确?C++的类成员函数(non-static member function)执行是有一个隐藏的参数为this指针。可以理解为该成员函数的第一个参数是一个指针,指向这个对象的首地址。你可能想,this指针指向这个对象,它难道还会变化?很不幸,在多继承中this指针经常变化。

以下均围绕这两个问题的产生与解决来阐述。

2. 单继承与vtable的出现

class A { 
public:
	virtual void f() {}
	long int a;
};
class B : public A { 
public:
	virtual void f() {}
	long int b;
};

class C : public B { 
public:
	virtual void f() {}
	long int c;
};

考虑上述例子,C继承B,B继承A。每个类有一个long int的整型的成员变量。选择long int是为了8字节对齐,在32位中应该选择int。必须说明的是,一个良好的习惯是给每一个含有virtual member function的类声明一个virtual析构函数。上述例子实际上不加也没事,并且为了方便做例子我还是省略了。

对象内存布局

于是乎,各自创建对象,那么有

assert(sizeof(A) == 16);
assert(sizeof(B) == 24);
assert(sizeof(C) == 32);

在这里插入图片描述其中vtable ptr是一个指针指向.data.rel.rosection。这个section是只读区域,编译器专门在这个区域写上与继承相关的数据。vtable ptr A,B,C都是不相同的,编译器会为每一个类生成一个专门的vtable,vtable中存放着这个类中由virtual修饰的成员函数的地址。

回答两个关键的问题

  • 要执行的函数地址在哪里?
    ans: 在vtable中。程序会在运行时查找vtable,然后跳转到该函数。
//回到这个例子中,g++会怎么编译它?
void func(A* p) {
   p->f();
}
//编译后的伪代码
void func(A* p) {
   vtable*  vt = p->vtable;//vtbale就是p的首8字节,相当于 vt = ((void*)p)[0]; 
   Func f = vt[0]; //f()是第一个virtual member function,所以vt的下标为0
   f(p);//执行,p就是this,作为第一个参数
}

可以看到,func()编译后的代码是不变的,但是它执行的效果达到了c++的多态要求,根据原因是把执行一个virtual function转换成了先查表再执行,实现了动态效果。

  • 执行的这个函数的this指针是否正确?当然正确。有以下代码可以验证
C* c = new C();
B* b = c;//向上转型,安全
A* a = c; //向上转型,安全
assert(c == b);//true
assert(c == a);//true
//两个asser为true,这不显然的吗?
//可惜,在多继承中并不是这样

3. 多继承与thunk的出现

class A { 
public:
	virtual void f() {}
	long int a;
};
class B : { 
public:
	virtual void f() {}
	long int b;
};

class C : public Apublic B { 
public:
	virtual void f() {}
	long int c;
};

考虑上述例子,C继承A与B,于是事情没那么简单了。

对象内存布局

可以验证的是

assert(sizeof(A) == 16);
assert(sizeof(B) == 16);
assert(sizeof(C) == 40);//注意是5*8个字节

在这里插入图片描述当多继承出现的时候,从左到右按继承顺序,故在内存分析,依次是A、B、C,如上图所示。sizeof(C)==40是因为多出了sub-vtable ptr of B-in-C这8个字节。

direct base class order
When the direct base classes of a class are viewed as an ordered set, the order assumed is the order declared, left-to-right.

为什么需要多一个sub-vtable。因为c++在继承强调的是,向上转型是安全的,但是其细节在多继承时会有如下的现象。(不清楚有没有人在面试出过这种题,过扣细节来考察别人实际上很无聊。但是我们这是在学习,下面的例子还是挺有意思的)

C* pc = new C();
B* pb = pc;                                  //向上转型,安全
A* pa = pc;                                  //向上转型,安全
cout << "pc == pb: " << (pc == pb) << endl;  // true, of course?
cout << "pc == pa: " << (pc == pa) << endl;  // true, of course?

cout << "(void*) pc == pb: " << (((void*)pc) == ((void*)pb))  // false, why?
     << endl;
cout << "(void*) pc == pa: " << (((void*)pc) == ((void*)pa))  // true, why?
     << endl;

//----------额外补充关于reinterpret_cast,行为又不一样,读者可以先忽略
cout << "----------------------reinterpret_cat\n";
pb = reinterpret_cast<B*>(pc);
pa = reinterpret_cast<A*>(pc);

cout << "pc == pb: " << (pc == pb) << endl;  // true
cout << "pc == pa: " << (pc == pa) << endl;  // false

cout << "(void*) pc == pb: " << (((void*)pc) == ((void*)pb))  // true
     << endl;
cout << "(void*) pc == pa: " << (((void*)pc) == ((void*)pa))  // true
     << endl;
//下面是一次实际运行的例子
C* c = new C();// c = 0x555555768e70; 堆上的地址
B* b = c;// b= 0x555555768e80; b=c+0x10;
A* a = c; // a = 0x555555768e70; a=c;

上面的例子很典型,B* b = c;的时候,编译器会偷偷的加0x10。为了掩盖这一点,在判断c == b的时候,编译器又偷偷减回来,导致上面奇怪的现象。

assert(c == b);//true 
assert(((void*)c) == ((void*)b));//false, abort!
// c == 0x555555768e70; b == 0x555555768e80 == c + 0x10

实际上编译器这么做也能自圆其说。因为==支持重载,类定义的时候程序员没有重载==,所以编译器就帮忙塞一个,那么就不是编译器的发挥空间了?
有上面的现象可以得出两个结论

  • 多继承中,向上安全转型可能会导致this指针的值发生变化
  • ==运算符在默认的情况下比较两个对象指针的时候,是比较这两个对象的地址,而不是比较这两个指针的值。

好奇宝宝们就自然有两个问题

  • 为什么this的值有时候必须发生变化
  • 为什么在这个例子中是增加0x10

先回答第二个问题: 为什么在这个例子中是增加0x10?
在向上安全转型的过程中,有如下变化,这也是为什么b = c + 0x10 = a + 0x10;
同时也可以说明,为啥a=c不需要变化;也正因为如此,sub-vtbl ptr A-in-Cvtable ptr C可以合并。
在这里插入图片描述

第一问题: 为什么this的值必须发生变化? 或者说为什么设计成这样,我是觉得应该要满足下面的条件。
内存区间 [this, this+sizeof(*this)) 都是属于*this这个对象的。这是很自然的,也应该尽量满足它。自然有

sizeof(*a) == 16
sizeof(*b) == 16
sizeof(*c) == 40

回答两个关键的问题

  • 要执行的函数地址在哪里?
//回到这个例子中,g++会怎么编译它?
void func(A* p) {
   p->f();
}
void func(B* p) {
   p->f();
}

和单继承没有任何变化,这是因为B* b的首8个字节仍然是vtable。一样可以查表找到需要call的函数地址。

  • 执行的这个函数的this指针是否正确?不对,事情发生了变化。
//编译期间是不知道p到底是B本身,还是子类C
void func(B* p) {
   p->f();
}

//编译后的伪代码
void func(B* p) {
  vtable*  vt = p->vtable;//vtbale就是p的首8字节,相当于 vt = ((void*)p)[0]; 
  Func f = vt[0]; //f()是第一个virtual member function,所以vt的下标为0
  
   //编译期间是不知道p到底是B本身,还是子类C,这时候就出了问题
  f(p);//如果是B本身
  f(p-0x10);//如果是子类C,this需要修正
}

为了解决这个问题于是乎这个差异又交给了vtable。
vtable ptr Bsub-vtable ptr B-in-C是不一样的,sub-vtable ptr B-in-C是是专门为class C中的B生成,和vtable类似,每一项存放者要call的函数地址。如果这个class B中的某个virtual函数被class C覆盖,则这一项会特别处理,称为thunk to C
查看汇编可知在上述例子中void func(B* p), 执行p->f()会进入non-virtual thunk to C::f(),修正了this指针后,再进入C::f(),成功调用子类C的f()

0x555555554c21 <non-virtual thunk to C::f()>:	sub    rdi,0x10; rdi 是用于传递第一个参数,这一行修正了this
0x555555554c25 <non-virtual thunk to C::f()+4>:	jmp    0x555555554c16 <C::f()>
//编译后的伪代码
void func(B* p) {
   vtable*  vt = p->vtable;
   Func f = vt[0]; 
   //如果*p是B本身,则f是B::f(); 
   //如果*p是子类C,则f是non-virtual thunk to C::f();
   
   f(p);
}

额外补充 关于vtable中的off-to-top

额外补充 关于this指针修正
c++中实际上允许向下转型,虽然是不安全的,但是提供了dynamic_cast()

C* c = dynamic_cast<C*>(b);

dynamic_cast中是如何严格检查的?我这没有去扒源码,做一个猜测,知识来源一篇17年NDSS的paper2sub-vtable ptr B-in-C也就是下图的vtblptrC2的负1项存放着该对象的类型(RTTI),负2项存放着off-to-top
Run-Time Type Information (RTTI). 可以通过typeid()提取出来,但是除了给标准库使用,不应该用于实际开发。
The Offset-to-Top field of the sub-vtables holds the value that has to be added to the thisptr to reach the beginning of the object.
猜测dynamic_cast就不是暴力的直接修正指针,而是先查表,检查RTTI,检查off-to-top再修正this。

下图例子截取这篇paper2。下图中是一个完成的例子。
在这里插入图片描述

4. 虚继承与VBO(vbase-offset)和VCO(vcall-offset)的出现

虚继承带来的新问题,也是面试常客,菱形继承(diamond inheritance)。

class A { 
public:
	virtual void f() {}
	long int a;
};
class B : public A{ 
public:
	virtual void f() {}
	long int b;
};

class C : public A { 
public:
	virtual void f() {}
	long int c;
};
class D : public B, public C { 
public:
	virtual void f() {}
	long int c;
};

考察上述例子。D继承B与C,意味着A在D中有两份拷贝。两份A的成员意味者歧义

D d;
d.a = 1;//赋值. compile error, ambiguous
d.B::a = 1;//correct
d.C::a = 1;//correct

看看其他语言怎么解决的

  • Java. java中所有类都继承object,那不很容易菱形继承吗。好家伙,java不允许多继承,问题解决,完美。
  • python. python是允许多继承的。python的成员方法和成员变量是类似dict的结构?有待研究python的内存模型。
  • C++中为了解决这个问题,提出了虚继承。

对象内存布局

#define T long int
class A {
	public:
		virtual void af() {cout << "af" << endl;}
		T a;
};

class B : public virtual A{
	public:
		virtual void bf() {cout << "bf" << endl;}
		T b;
};

class C : public virtual A{
	public:
		virtual void cf() {cout << "cf" << endl;}
		T c;
};

class D : public B, public C{
	public:
		virtual void df() {cout << "df" << endl;}
		T d;
};

考虑上述例子。(例子与下文的图来自CCS2020的一篇paper3)

//于是有
sizeof(A) == 16;
sizeof(B) == 32;
sizeof(C) == 32;

sizeof(D) == 56;// 如果成员变量abcd是如下图的int类型,那么这里是48,因为c和d正好凑一起可以节约8个字节

在这里插入图片描述
单看class B和class C,它们生成的对象就已经不同与正常继承了。以class B为例。
class B: public A //普通继承A的时候

sizeof(B) == 24 // [0x00:Vptr], [0x08:a], [0x10:b]

class B: public virtual A //虚继承A的时候

sizeof(B) == 32 // [0x00:Vptr], [0x08:b], [0x10:Vptr_A-in-B], [0x18:a]

图中的Vptr_B / (pry of B) 就是前文的vtable ptr
图中的Vptr_A-in-B / (sec of B) 就是前文的sub-vtable ptr of A-in-B
名词不同 是因为一个出现在2一个出现在3

虚继承在这里有两点与正常继承不同

  • 虚继承的class B的对象多出了Vptr_A-in-B
  • 虚继承的class B的base class A的this是在对象的后面

这么设计是为了解决菱形继承的问题。当B是virtual inheritance A,那么B就要做好准备,B中的A是可能与别的class分享的。如上述例子,D的A-in-B与A-in-C是一致的。因为B的一部分可能去别人分享,在多继承部分描述的一个认识就发生了变化。

内存区间 [this, this+sizeof(*this)) 都是属于*this这个对象的
不再满足
如上述例子的class B in D

虚继承的取值困难

//考虑一个简单的取值例子
void f(B* p) {
	int i = p->a;
}

//编译后的伪代码,出现矛盾。a的位置是编译时期不能确定的
void f(B* p) {
	int i = ((long int*)p)[3];//如果p指向的是B,p->a在第4的位置
	
	int i = ((long int*)p)[6];//如果p指向的是D,p->a在第7的位置
}

取值困难的例子虽然生动,但实际上这个问题的根本是

void f(B* p) {
	A* b = p;//安全向上转型,但是this如何修正?
}

vbase-offset的出现

回顾vtable,在多继承中提到,vtable的-1项是RTTI,-2项是OTT。当这个类与虚继承相关的时候,会多出-3项,为VBO。

vtable[-1]: RTTI (Run-Time Type Information)
vtable[-2]: OTT (off-to-top)
vtable[-3]: VBO (vbase-offset)

//于是
void f(B* p) {
	int i = p->a;
}

//编译后的伪代码,矛盾解决。
void f(B* p) {
	vtable* v = p->vtable;
	long int vbo = v[-3];
	A* a = (p+vbo); //找到了基类A的位置
	int i = ((void*)a)[1];// index 0是vtable, 1是long int a;
}

一个实际的例子,下图是Vptr_B的内容。
在这里插入图片描述下图是一篇paper3中完整的例子。CV(contruction vtable)与构造析构相关,先不考虑。图中每一列是这个类的vtable内容。如class D列,包含Vptr_D, Vptr_C-in-D, Vptr_A-in-D。
在这里插入图片描述

回答两个关键的问题

  • 要执行的函数地址在哪里?
    和多继承一样,查表。vtable表的指针的地址固定存放在为this的首8字节。

  • 执行的这个函数的this指针是否正确?
    和多继承一样,由不同的表中的各个vritual fucntion的thunk承担修正this的职责。在虚继承中thunk修正出现了问题,thunk编译时不知道this的类型。所以,与vbase-offset相对应,每一个virtual function都需要vcall-offset。

//新的例子
#define T long int
class A {
	public:
		virtual void af() {cout << "af" << endl;}
		T a;
};

class B : public virtual A{
	public:
		virtual void af() {cout << "B:af" << endl;}
		virtual void bf() {cout << "bf" << endl;}
		T b;
};

B* b = new B();
A* a = b; //向上转型,借助vbase-offset修正this
a->af(); //进入virtual thunk B:af(); 借助vcall-offset把this修正回来;实际效果是b->af();
   0x555555554d95 <virtual thunk to B::af()>:	mov    r10,QWORD PTR [rdi]
   ; r10 = vtable ptr
   
   0x555555554d98 <virtual thunk to B::af()+3>:	add    rdi,QWORD PTR [r10-0x18]
   ; vcall-offset = r10[-3]; this = this + vcall-offset;
   
   0x555555554d9c <virtual thunk to B::af()+7>:	jmp    0x555555554d5e <B::af()>

额外补充 vcall-offset的理解

如果你看懂了这一块内容,你可能会有一个疑问,为什么一定要vcall-offset,只有thunk修正this指针不可以吗?
先说我的回答:可以不需要vcall-offset,但相对应的就是要生成更多不同的thunk。这是一种设计的结果。
多继承中是non-virtual thunk,虚继承中是virtual thunk
如果不加入vcall-offset,那么this的修正值必须硬编码在代码里,出现大量的类似thunk to B in B,thunk to B in C。但是g++没有这么做,而是只有一个virtual thunk to B
下面给出一个实际的例子,证明vritual thunk to B 确实复用了。

#include <string>
#include <iostream>
using namespace std;
#define T long int
class A {
	public:
		virtual void af() {cout << "A:af" << endl;}
		T a;
};

class B : public virtual A{
	public:
		virtual void af() {cout << "B:af" << endl;}
		T b;
};

class C : public virtual A{
	public:
		T c;
};

class D : public B, public C{
	public:
		T d;
};

int main() {
	A* a1 = new D();
	a1->af();
	// call rax<0x555555554f0d>(virtual thunk to B:af());
	// vcall-offset = -40;
	
	A* a2 = new B();
	a2->af();
	// call rax<0x555555554f0d>(virtual thunk to B:af());
	// vcall-offset = -16;
	return 0;
}

额外补充 虚继承向下强制转换

A* a = new D();
D* d = (D*)a; //compile error. 实际上还是能转换的
D* d = dynamic_cast<D*>(a);//correct,做了检查

5. 虚继承导致的vtable ptr初始化的困难与CV(Construction VTable)和VTT(Virtual Table Table)

上文都是在说,对于一个已经创建好的对象,怎么去找这个对象应该执行的函数地址与成员变量的位置。那么有一个问题,对象是需要被初始化的,初始化是需要执行构造函数的。
新的问题:

  • 执行构造函数,构造函数中需要对vtable初始化。编译器不知道要初始化的这个class B是在class D中的还是说就是原本的B。

这个问题必须要解决。所以编译器专门为class B编译了两份构造函数。第一份是正常的构造,第一个隐藏参数传递this。第二份专门为class D的B的构造函数,第一个隐藏参数传递this,第二个隐藏参数传VTT的地址。VTT是新建的一个表,里面包含一系列vtable ptr,并且有新出现的专门为构造析构而加入的CV。
直接贴一个VTT的结构图,来自3

问题的解决思路是一致的,那就是编译器各种加料。实际处理要考虑很多细节,于是编译器为此偷偷的增加了很多代码与数据。

在这里插入图片描述


  1. Itanium C++ ABI (http://itanium-cxx-abi.github.io/cxx-abi/abi.html) ↩︎

  2. Andre Pawlowski, Moritz Contag, Victor van der Veen, Chris Ouwehand, Thorsten Holz, Herbert Bos, Elias Athanasopoulos, and Cristiano Giuffrida. 2017. MARX : Uncovering Class Hierarchies in C++ Programs. In Proceedings of the 24th Annual Network and Distributed System Security Symposium. ↩︎ ↩︎ ↩︎

  3. Erinfolami, Rukayat Ayomide, and Aravind Prakash. “Devil is Virtual: Reversing Virtual Inheritance in C++ Binaries.” Proceedings of the 2020 ACM SIGSAC Conference on Computer and Communications Security. 2020. ↩︎ ↩︎ ↩︎ ↩︎

  • 3
    点赞
  • 7
    收藏
    觉得还不错? 一键收藏
  • 1
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值