虚函数与多态
继承和多态,是面向对象中老生常谈的话题。C++中,我们也可以经常看到virtaul
、override
这样的关键字;这正是虚函数的标志。虚函数就是为了解决多态的问题:如果要使用一个基类的指针,根据对象的不同类型去调用相应的函数,就需要使用虚函数了。通俗的说也就是同一个入口,却能够调用不同的方法。
通常,对于虚函数的调用,往往在运行时才能确定调用哪个版本的函数。这是由于基类的指针或者引用,其动态类型必须在运行的时候才能确定(它具体指向了什么类型)。而“动态绑定”就指的在运行时,根据对象的类型,调用具体方法的过程,这个过程正是通过虚函数表实现的。
抽象基类指的是有纯虚函数的类。纯虚函数,指的是没有函数体的函数,通常通过在函数体的位置写上=0
来表示。对于抽象基类,是不能直接创建一个对象的;但是可以创建它它们的派生类的对象:只要它们覆盖了纯虚函数。纯虚函数表示这个函数的具体实现全部交给派生类去做。
虚函数表与虚函数的调用
那么,“动态绑定”是如何实现的呢?这便是借助于虚函数表来实现。对于每个具有虚函数的类,都会有一个对应的虚函数表vtable,其代码和对应内存结构如下所示:
class A {
int varA;
public:
virtual int vAfoo(int a, int *b){
return a + (*b);
}
virtual int vAbar(int a){
return a + 1;
}
virtual bool vAduh(){
return true;
}
virtual int vAtest(int a){
return 0;
}
void Afoo(){
this->vAduh();
}
};
class B {
int varB;
public:
virtual int vBfoo(int a) = 0;
virtual bool vBbar(int b){
return b == 0;
}
char *Bfoo(char *c){
return c;
}
};
class C : public A, public B {
int varC;
public:
int vAtest(int a){
return -(a);
}
int vAfoo(int a, int *b){
return *b;
}
int vBfoo(int a){
return a - 1;
}
virtual void vCfoo(){}
bool vAduh(){
return false;
}
};
这个表中的每一项,都是一个虚函数的地址,也就是虚函数的指针。而每个对象的第一个值都是虚标指针,它指向了了所对应虚函数表的第一个表项(也就是虚函数表的基址)。每次调用虚函数时,都会首先通过这个虚表指针,找到虚函数表,然后再在虚函数表中,找到真正的虚函数的地址,并进行调用。假设存在有多继承的情况,那么就会有多个vptr,分别放在对应的基类对象的开头位置。
虚继承
对于“菱形继承”情况(也即两个子类继承同一个父类,而新的子类又同时继承这两个子类),则可能产生二义性问题。例如下面的情况,那么D中就会保存两次A中的变量和函数,并且在使用时也会很不方便,必须利用域作用符来使用变量和函数。
A
/ \
B1 B2
\ /
D
虚继承是在继承时,在基类类型前面加上virtual
关键字。虚继承能够解决基类多副本的问题:在任何派生类当中,虚基类都是通过一个共享对象来表示的,它们通过指针去访问这个基类中的内容;它不用去保存多份基类的拷贝,而是只需要多出一个指向基类子对象的指针。从内存布局上来说,在虚表的负offset位置,会保存一个指针指向虚基类对象。
也就是说继承自A的虚函数和对象,全部只保存一份在D自身的子对象中,相比不使用虚继承,它删除了B1和B2当中的(2份)基类成员;它自己则需要保存一份基类成员和偏移指针;而如果要用B1和B2的指针或者引用去访问一个D对象时,那么访问A的成员则需要通过间接引用来访问;也就是说子对象需要有一个偏移量,指示在内存中,基类的位置。其内存布局一般如下:
内存 |
---|
B1的虚表指针 |
B1的偏移指针 |
B1的数据成员 |
B2的虚表指针 |
B2的偏移指针 |
B2的数据成员 |
D的虚表指针 |
D的偏移指针 |
D的数据成员 |
A的虚表指针 |
A的数据成员 |
虚表与劫持攻击
在C程序中,%90以上的间接调用都是vcall。篡改程序中的虚函数调用,是劫持C程序的一种常见手段。这里简单说说常见手段。
一种方法是虚表注入。众所周知,虚表保存在程序的.rodata段中,它是可读,不可写的;而对象当中的虚表指针却是可读写的状态;因此篡改虚表指针是较为直接的方式。
如图,如果利用漏洞(overflow、use-after-free等)在内存中构造一个虚假的虚表,并且将对象中的虚函数指针指向注入的虚假的虚表,那么在虚函数调用时,就会调用虚假的虚函数。甚至只需要一次虚函数调用就能够通过shellcode完成攻击。
当然,如果程序进行了一定程度的保护,例如检查虚表指针是否属于.rodata段,攻击就只能依赖于现有的虚表来构造了。Counterfeit Object-oriented Programming就提出了这样一种方法。
可以看到,这种方法没有注入新的虚表,而是将vptr的值,指向了虚表中的不同位置(而不是虚表的起始地址)。如果能够构造一系列的虚假对象,那么就可以在一次循环中(比如某个对象数组的依次析构),在调用同一个虚函数时,实际上调用不同的函数,从而构造一个虚假的执行链。看到这里,也许你会有疑问:仅仅用有限的虚函数,能够构造图灵计算的攻击吗?答案是肯定的:有兴趣的话可以阅读一下原文,通过拼凑虚函数,是能够组合出各种语义的。
小结
可见,虚函数是面向对象语言中,十分巧妙而又必不可少的设计;但它的特点也使得它成为黑客滥用、攻击的目标。指的庆幸的是,目前已经有一些开销较小的方法,能够保护虚表和虚函数了。
The link of this page is https://blog.nooa.tech/articles/dcffa0c0/ . Welcome to reproduce it!