C++逆向分析——多态和虚表
虚表
上一章了解了多态,那么我们来了解一下多态在C++中是如何实现的。
了解本质,那就通过反汇编代码去看就行了,首先我们看下非多态的情况下的反汇编代码:
然后再来看下多态情况下的反汇编代码:
很明显这里多态的情况下会根据edx间接调用,而非多态则会直接调用。
那么我们来看下间接调用的流程是什么:
-
ebp+8地址对应的值给到eax(ebp+8 也就是函数的参数 → 当前参数指针【父类指针】)
-
eax地址对应的值给到edx(eax相当于当前对象的第一个成员)
-
调用edx地址对应的值,也就是子类对象的Print函数
但是这里很奇怪,第一个成员为什么就能是Print函数呢?跟我们之前理解的4个字节的参数完全不一样。
我在vs2022实验下:
#include <stdio.h> class Person { private: int Age; int Sex; public: Person() { Age = 0; Sex = 0; printf("default person ctor invoked.\n"); } Person(int Age, int Sex) { this->Age = Age; this->Sex = Sex; } virtual void Print() { printf("age = %d sex = %d\n", Age, Sex); } }; class Teacher :public Person { private: int Level; public: Teacher() { } Teacher(int Age, int Sex, int Level) :Person(Age, Sex) { this->Level = Level; } virtual void Print() { Person::Print(); printf("level = %d \n", this->Level); } }; void main() { Teacher t(35, 0, 3); //t.Print(); Person* p = &t; p->Print(); return; }
先说输出:
age = 35 sex = 0
level = 3
通过父类指针调用到了子类的函数。反汇编看到的:
那么编译器到底是做了什么工作,才能根据我们传入的对象来进行间接调用的呢?这是因为虚表。
只要有虚函数,不论多少个,对象的数据宽度就会比其原来多出4个字节,这四个字节我们称之为虚表。
那么虚表在哪呢?可以通过VC6来寻找虚标,先创建对象然后下断点运行查看,如下图中,可以很清晰的看见对象t除了继承Person父类的Age、Sex以及本身的Level成员外,还有一个__vfptr,上面有一个地址就是0x00422024,那这个地址就是虚表,这个表里面存储的就是函数的地址:
我们可以调出内存窗口查看一下:
这个存储的地址就是0x00401037,这时候切到反汇编代码就然后Ctrl+G输入跟进这个地址:
那这个地址就是Teacher的成员函数Print的地址。
虚表的结构:虚表中存储的都是函数地址,每个地址占用4个字节,有几个虚函数,则就有几个地址。
子类没有重写时,虚表中则只有父类自己的成员函数地址,反之,当子类重写虚函数时候,虚表中则存在父类自己的成员函数地址与子类重写的成员函数地址。
对于我上面的vs2022里的代码,看到的情况如下:
上述地址在反汇编里看到的就是:
最重要的是,看下有了虚函数表以后的内存布局:
如下,可以看到__vfptr就是放在this指针(也就是&t)的位置了!
名称 | 值 | 类型 | |
---|---|---|---|
▶ | __vfptr | 0x00b57b58 {Conso.exe!void(* Teacher::`vftable'[2])()} {0x00b51064 {Conso.exe!Teacher::Print(void)}} | void * * |
对于虚表的内存布局,见这个文章总结:C++中的虚函数表实现机制——对于虚表的内存布局讲解得非常好
接下來,我们看看虚函数的覆盖是什么时候做的???
#include "stdio.h" class CSum { public: virtual int Add(int a, int b) { return (a + b); } virtual int Sub(int a, int b) { return(a - b); } }; int x = 0xff; class DSum : public CSum { virtual int Add(int a, int b) { return (a + b + x); } }; void main() { CSum* pCSum = new DSum; pCSum->Add(1, 2); pCSum->Sub(1, 2); CSum* pCSum2 = new DSum; pCSum2->Add(1, 2); delete pCSum; pCSum = NULL; delete pCSum2; pCSum2 = NULL; }
运行的时候,调试看看pCsum和pCsum2里面的虚表地址是不是同一个?答案是肯定的!如下所示:
我原本以为子类重载了虚函数add以后,vfptr里面函数add的覆盖是在构造函数的代码里做的,实际上我想错了,vs2022里的做法是在编译时就将vfptr直接指向了一常量区域,如下所示,该常量区域里面的东西在编译生成二进制的时候就已经确定了到底是CSum还是DSum对应的虚表!==》核心就一句:vs2022将vfptr指向了一个常数!
因此,如下代码,将pCSum修改为new CSum以后,看到的情况是pCsum和pCsum2里面的vfptr指向的常量不一样了!很容易理解:就是编译器在为CSum和DSum生成了两个虚表常量,然后在类的构造函数里将该常量赋值给vfptr了。
纯虚函数
之前学习过虚函数,也提到了纯虚函数,虽然纯虚函数语法很简单的,但是其比较难理解,所以在有一定的面向对象的基础时候再来学习会比较容易理解一些。
纯虚函数语法:
-
将成员函数声明为 virtual
-
该函数没有函数体,最后跟=0
class
Base {
public
:
virtual
int
Plus() =
0
;
}
语法不过多的阐述,之前也有写过;接下来我们要了解一个新的概念:抽象类。
抽象类有这几种特征:
-
含有纯虚函数的类,称之为抽象类;
-
抽象类也可以包含普通的函数;
-
抽象类不能实例化(创建对象)。
那么问题来了,抽象类有什么意义呢?我们可以把抽象类看作是对子类的一种约束,或者认为其(抽象类)就是定义一种标准。
比如:淘宝,有很多店铺,虽然每个店铺卖的东西都不一样,但是他们同样都可以下单、评论、购物车,也就是说他们都遵守了这种标准规则;也就是说你可以把淘宝当作一个抽象类,其有很多成员:购物车、评论、商品展示区...但是他都没有定义,而是交给开淘宝店的人(子类)去根据标准规则定义。
而如果不按照这种标准呢来,那么假如要统计所有的数据就会非常麻烦,不便于管理。