C++虚函数逆向(1)

译者注:译者Silver@XDSEC,原文地址https://alschwalm.com/blog/static/2016/12/17/reversing-c-virtual-functions/,作者为Adam Schwalm。译文首发于Freebux。本来想投36x赚点稿费,但是他们的编辑器实在太难用了,就投了支持Markdown的Freebux,结果后来才知道36x也能用markdown投……嘛,反正已经投了,就这样吧。

前言

关于C++程序的逆向,网络上已经有很多文章了,这些文章也或多或少的提到了虚函数。然而,这篇文章中,我想着重介绍一下,在代码量比较大的程序中,我们应该如何处理虚函数。这些程序里,通常存在着数以千计的类,类型之间的关系也很复杂,因此在我看来,分享处理这些类的经验是很有价值的。但在我介绍这些复杂的案例之前,我会先介绍一些简单的栗子。如果你已经对虚函数的逆向有了一些了解,那么可以直接去看本文的第二部分。(译者注:截止本文翻译结束前,作者尚未发布第二部分)

此外,注意以下几点:

  • 示例代码编译时没有使用RTTI,也没有使用异常机制
  • 下文中的样例在x86平台上测试
  • 所有的二进制文件已经被strip了(剥离了符号)
  • 大多数虚函数的实现细节是没有特定标准的,因此不同编译器对此的处理方法很可能不一致。因此,我们将着重讨论GCC编译器的行为

另外,我们的文件编译时的命令行参数为g++ -m32 -fno-rtti -fnoexceptions -O1 file.cpp,输出文件用strip处理过。

目标

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

基本功

假设你已经比较熟悉C++了,但对它的具体实现还不太熟悉,那么就先来看一看编译器是如何实现虚函数的。现在有这么一段代码:

// file reversing-1.cpp


#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"; }

};

然后还有这么一段调用他们的代码:

// file reversing-2.cpp

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)的成员,这个虚指针指向正确的虚表,初始化这个虚指针的代码会被添加到类的构造函数中。这样,当编译器需要调用虚函数的时候,就可以通过虚指针找到对应的虚表,从而找到合适的函数,进而调用他。这也意味着,具有同一个父类的子类,其虚表中函数的顺序也应该是一致的。比如,在上面的例子中,DogCat类都是Mammal类的子类,那么Dog类的虚表中,第一项指向的是Dog::run,第二项指向的是Dog::walk,而Cat类的虚表中,第一项指向的是Cat::run,第二项则是Cat::walk

通过在.rodata段中寻找指向函数的偏移量,我们可以在二进制文件中轻松地找到MammalCatDog类的虚表,如下图所示。

img

主函数这时候被编译成了这个样子:

img

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

img img

我们再看一下,三张虚表的第四项分别是sub_80487AAsub_804877E___cxa_pure_virtual。前两个函数如上图所示,分别是DogCat类中对walk函数的实现,那么最后一个函数一定是Mammal类中对应的实现了。这很正常,因为Mammal类中没有定义walk的具体实现,而是声明其为“纯虚函数”,那么就GCC就帮我们插入了一个默认的项目。那么到这里我们知道了,虚表1是属于Mammal类的,而2和3分别是属于CatsDogs类的。

但比较奇怪的是,每个vtable中含有五个项目,而在我们的程序中,每个类只有四个虚函数,他们分别是:

  • run
  • walk
  • move
  • 析构器

实际上,多出来的项目是一个“额外的”析构器。这是因为,GCC会在不同的场景中,使用不同的析构函数。前者只是简单的把实例对应的所有成员都清理掉,而后者则会同时要求回收为这个实例分配的内存,这也就是在第17行调用的函数。在某些涉及到虚继承的情况下,还会有第三种析构器。

那么现在我们搞清楚了虚表的布局:

| Offset | Pointer to  |
|--------+-------------|
|      0 | Destructor1 |
|      4 | Destructor2 |
|      8 | run         |
|     12 | walk        |
|     16 | move        |

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

现在可以考虑给他们重命名,以方便阅读:

img

注意,由于CatDog类都没有实现move方法,因此他们直接采用了Mammal类中的方法,虚表中的项目值也一样。

结构体

为了研究方便,我们定义几个结构体。刚才我们已经看到了MammalCatDog类的唯一成员是他们的虚指针,因此做定义如下:

img

接下来就比较麻烦了:我们需要为每个虚表创建一个结构体。这是为了让反编译器能够更清楚的向我们展示,如果m具有一个特定类型的话,哪个函数应该被调用。这样,我们在阅读代码的时候就可以排除很多干扰了。那么为了达成这个目的,我们需要把结构体中的每个项目命名为对应的函数名:

img

接下来,把类中虚指针的类型调整为指向对应虚表的指针。比如,Cat类的vptr成员,应该使用CatVtable*类型。此外,我还把虚表中每个项目的类型都改为了函数指针,比如Dog__run的类型是void (*)(Dog*),这样就更容易识别了。

最后一步是回到原来的代码中,为变量赋予适当的类型:

img

上面是把m设定为Cat*Dog*,可以看到,现在的代码比刚才简洁很多:如果mDog类型的话,那么第15行调用的就是Dog__walk,否则是Cat__walk。这个例子很简单,但能够说明我们大致的思路了。

我们也可以把m设置为Mammal*类型,但效果就不太好了:

img

假设mMammal*类型,那么第15行就会调用一个纯虚函数,这是不可能的,此外第17行的调用也会产生问题。因此我们可以推测,m一定不是Mammal*类型。

这种和源代码不符的说法,可能听上去比较奇怪。实际上,这是因为在编译时,我们给m赋予的是一个编译时的类型(静态类型),但我们更关注它的动态类型(或者说是运行时的类型),因为这才是决定哪个虚函数被调用的关键。事实上,一个元素的动态类型,基本永远不可能是一个抽象类。因此如果给出的虚表中,含有一个___cxa_pure_virtual函数,那这个类型可能并不是他的运行时类型,可以无视。实际刚才的例子中我们完全可以不给Mammal类的虚表创建一个结构体,因为这个结构体永远都不会用到。

通过以上的分析,我们知道了,动态类型可能是Cat或者Dog,我们也知道了如何通过查看虚表的项目,来判断哪个函数会被调用,这是C++虚函数逆向的第一步。下一步,我们会介绍如何处理更多、更复杂的二进制程序和继承关系。