目录
前言
做安全的肯定是要深究一下,特别是了解其c++对象的memory layout和一些编译器生成的固定代码。另一方面c++开发者,既然是选择折腾c++,实际上也有了解的必要。本文不是讨论怎么用继承去实现一个良好的设计,而是讨论c++编译器是怎么支持这个特性的。这里不要把编译器想象的多么神通广大,实际上它是通过生成一些固定的代码与数据来实现的。官方参考文献c++的abi1。
极具争议的多继承与虚继承为啥这么恶心?
如出现错误,欢迎补充与修正。
更新时间2021.07.30
1. virtual function需要解决的问题
class A {
public:
virtual void f() {}
};
class B : public 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 A, public 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-C
和vtable 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 B
与sub-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的paper2。sub-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。
问题的解决思路是一致的,那就是编译器各种加料。实际处理要考虑很多细节,于是编译器为此偷偷的增加了很多代码与数据。
Itanium C++ ABI (http://itanium-cxx-abi.github.io/cxx-abi/abi.html) ↩︎
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. ↩︎ ↩︎ ↩︎
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. ↩︎ ↩︎ ↩︎ ↩︎