C++虚函数逆向分析(一)

本文以一个简单C++例子进行介绍。

示例代码编译时没有使用RTTI,也没有使用异常机制
下文中的样例在x86平台上测试
所有二进制文件已经被strip了
大多数虚函数的实现细节都没有标准化,并且不同编译器之间可能会有所不一样。因此,我们将专注于GCC的行为。

大多数情况下,没有办法让一个虚函数的调用,变换为一个对非虚函数的调用(反虚拟化)。因为,需要的信息在静态编译中是不全的,只有在运行时才会存在。因此,逆向虚函数时,目标是判断哪些函数会在特定的情况下被调用。后面会学习其他技巧,来进一步缩小范围。

基础

先看一个小程序:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <cstdlib>
#include <iostream>
struct Mammal {
Mammal() { std::cout << "Mammal::Mammal\n"; }
virtual ~Mammal() { std::cout << "Mammal::~Mammal\n"; }
virtual void run() = 0;
virtual void walk() = 0;
virtual void move() { walk(); }
};

struct Cat : Mammal {
Cat() { std::cout << "Cat::Cat\n"; }
virtual ~Cat() { std::cout << "Cat::~Cat\n"; }
virtual void run() { std::cout << "Cat::run\n"; }
virtual void walk() { std::cout << "Cat::walk\n"; }
};

struct Dog : Mammal {
Dog() { std::cout << "Dog::Dog\n"; }
virtual ~Dog() { std::cout << "Dog::~Dog\n"; }
virtual void run() { std::cout << "Dog::run\n"; }
virtual void walk() { std::cout << "Dog::walk\n"; }
};

int main() {
Mammal *m;
if (rand() % 2) {
m = new Cat();
} else {
m = new Dog();
}
m->walk();
delete m;
}

mCat还是Dog类,取决于rand函数的输出。编译器无法提前预测,那么编译器如何调用合适的walk呢?
因为walk函数声明为了虚函数,编译器会在程序所处的内存空间中,插入一个包含函数指针的表,称为虚函数表(虚表vtable);在实例化的时候,每个对象会多出一个虚指针(vptr)的成员,这个虚指针指向虚函数表,初始化这个虚指针的代码会被添加到类的构造函数中。这样当编译器要调用虚函数的时候,就可以通过虚指针找到对应的虚表,从而找到合适的函数。
具有同一个父类的子类,其虚表中函数的顺序也应该是一致的。比如,上面的例子中,Dog和Cat类都是Mammal类的子类,如果Dog类的虚表中,第一项是Dog::run的指针,第二项是Dog::walk的指针,那么Cat类的虚表中,第一项就是Cat::run的指针,第二项是Cat::walk的指针。
通过在.rodata段中寻找指向函数的偏移量,可以找到类的虚表:

主函数编译之后如下:

可以看到,程序在实例化的时候,为每个对象申请了4字节的内存空间,这和预期相符(类中没有数据成员,而编译器为我们添加了vptr)。在第21和23行中看到对虚函数的调用过程。在第21行中编译器对指针解引用,从而获得vptr;接着计算vptr+12,即访问虚表的第四项,而23行访问虚表的第二项。之后,程序调用虚表中对应项目指向的函数。

三个虚表的第四项分别是sub_8048864sub_804881c__cxa_pure_virtual。前两个函数如上图所示,分别是Dog和Cat类中对walk函数的实现,最后一个函数就是Mammal类中对应的实现。因为Mammal类中没有定义walk的具体实现,而是声明其为纯虚函数。那么GCC就插入了一个默认的项目。现在就知道了虚表1是Mammal类的,2是Cat类的,3是Dog类的。
奇怪的是vtable中包含五个项目,而程序中每个类只有四个虚函数:

  • run
  • walk
  • move
  • 析构函数

实际上,多出来的项目是一个“额外的”析构函数。因为GCC会在不同场景下,使用不同的析构函数。第一个析构函数只是把实例对应的所有成员清理掉,而第二个析构函数则会回收为这个实例分配的内存,也就是23行调用的函数。在某些涉及到虚继承的情况下,还会有第三种析构函数。
现在就清楚虚表的布局了:

1
2
3
4
5
6
7
| Offset | Pointer to  |
|--------+-------------|
| 0 | Destructor1 |
| 4 | Destructor2 |
| 8 | run |
| 12 | walk |
| 16 | move |

虚表的前两个项目是空指针。这是新版本GCC的一个特征,当类具有纯虚函数时,编译器会将其析构函数替换为空指针。

给函数重命名:

由于Cat和Dog类都没有实现move方法,因此它们直接采用了Mammal类中的方法,虚表中的指针值也是一样的。

结构体

为了研究方便,我们定义了几个结构体。刚才我们已经分析了Mammal、Cat和Dog的成员只有vptr,因此如下定义三个结构体:

下一步需要为每个虚表创建一个结构体。这是为了让反编译器更清楚的展示,如果m具有一个特定类型的话,哪个函数会被调用。这样阅读代码时会排除很多干扰。为了达成这个目的,需要把结构体中的每个项目命名为对应的函数名:

接着,修改类中虚指针的类型为指向对应虚表的指针。比如,Cat类的vptr成员,修改为CatVtable *类型。(修改类型:光标放在变量名上按y

还可以把虚表中每个项目的类型改为函数指针,比如,Dog_walk的类型是void (*)(Dog*),这样就更容易识别了。

上图是把m的类型修改为Dog *,也可以修改为Cat *,可以看到21行调用了相应的函数。
还可以将m修改为Mammal *类型,但是21行就会变成调用纯虚函数,这是不可能的,23行也会出问题,因此判断m一定不能是Mammal *类型。
这种和源代码不符的说法,可能听上去比较奇怪。实际上,这是因为在编译时,我们给m赋予的是一个编译时的类型(静态类型),但我们更关注它的动态类型(或者说是运行时的类型),因为这才是决定哪个虚函数被调用的关键。事实上,一个元素的动态类型,基本永远不可能是一个抽象类。因此如果给出的虚表中,含有一个___cxa_pure_virtual函数,那这个类型可能并不是他的运行时类型,可以无视。实际刚才的例子中我们完全可以不给Mammal类的虚表创建一个结构体,因为这个结构体永远都不会用到。
通过以上的分析,我们知道了,动态类型可能是Cat或者Dog,我们也知道了如何通过查看虚表的项目,来判断哪个函数会被调用,这是C++虚函数逆向的第一步。后面会介绍如何处理更多、更复杂的二进制程序和继承关系。
reference
http://www.freebuf.com/articles/web/%E7%B3%BB%E7%BB%9F%E5%AE%89%E5%85%A8/123821.html
https://alschwalm.com/blog/static/2016/12/17/reversing-c-virtual-functions/