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;
}
很显然,m
是cat
类还是dog
类,取决于rand
函数的输出。这是无法被编译器提前预测的,那么编译器是怎么调用合适的walk
函数呢?
由于我们把walk
函数声明为了虚函数,编译器会在程序所处的内存空间中,插入一张含有函数指针的表,称为“虚函数表”,也就是“虚表”(vtable
);而在实例化类的时候,每个对象会多出一个称作“虚指针”(vptr
)的成员,这个虚指针指向正确的虚表,初始化这个虚指针的代码会被添加到类的构造函数中。这样,当编译器需要调用虚函数的时候,就可以通过虚指针找到对应的虚表,从而找到合适的函数,进而调用他。这也意味着,具有同一个父类的子类,其虚表中函数的顺序也应该是一致的。比如,在上面的例子中,Dog
和Cat
类都是Mammal
类的子类,那么Dog
类的虚表中,第一项指向的是Dog::run
,第二项指向的是Dog::walk
,而Cat
类的虚表中,第一项指向的是Cat::run
,第二项则是Cat::walk
。
通过在.rodata
段中寻找指向函数的偏移量,我们可以在二进制文件中轻松地找到Mammal
,Cat
和Dog
类的虚表,如下图所示。
主函数这时候被编译成了这个样子:
可以看到,程序在实例化类的时候,为每个对象申请了4字节的内存空间,这和我们预期是相符的(因为每个类中没有数据成员,而编译器为我们添加了vptr
)。我们也可以在第15行和第17行中,看到对虚函数的调用过程。在第15行中,编译器先对指针解引用,从而获得vptr
;接下来计算vptr+12
,也就是访问虚表中的第四项,而第17行则是访问虚表中的第二项。之后,程序调用虚表中对应项目指向的函数。
我们再看一下,三张虚表的第四项分别是sub_80487AA
、sub_804877E
和___cxa_pure_virtual
。前两个函数如上图所示,分别是Dog
和Cat
类中对walk
函数的实现,那么最后一个函数一定是Mammal
类中对应的实现了。这很正常,因为Mammal
类中没有定义walk
的具体实现,而是声明其为“纯虚函数”,那么就GCC就帮我们插入了一个默认的项目。那么到这里我们知道了,虚表1是属于Mammal
类的,而2和3分别是属于Cats
和Dogs
类的。
但比较奇怪的是,每个vtable中含有五个项目,而在我们的程序中,每个类只有四个虚函数,他们分别是:
run
walk
move
- 析构器
实际上,多出来的项目是一个“额外的”析构器。这是因为,GCC会在不同的场景中,使用不同的析构函数。前者只是简单的把实例对应的所有成员都清理掉,而后者则会同时要求回收为这个实例分配的内存,这也就是在第17行调用的函数。在某些涉及到虚继承的情况下,还会有第三种析构器。
那么现在我们搞清楚了虚表的布局:
| Offset | Pointer to |
|--------+-------------|
| 0 | Destructor1 |
| 4 | Destructor2 |
| 8 | run |
| 12 | walk |
| 16 | move |
值得注意的是,虚表的前两个项目是空指针。这是新版本GCC的一个特征,当类具有纯虚函数时,编译器会将其析构器替换为空指针。
现在可以考虑给他们重命名,以方便阅读:
注意,由于Cat
和Dog
类都没有实现move
方法,因此他们直接采用了Mammal
类中的方法,虚表中的项目值也一样。
结构体
为了研究方便,我们定义几个结构体。刚才我们已经看到了Mammal
、Cat
和Dog
类的唯一成员是他们的虚指针,因此做定义如下:
接下来就比较麻烦了:我们需要为每个虚表创建一个结构体。这是为了让反编译器能够更清楚的向我们展示,如果m
具有一个特定类型的话,哪个函数应该被调用。这样,我们在阅读代码的时候就可以排除很多干扰了。那么为了达成这个目的,我们需要把结构体中的每个项目命名为对应的函数名:
接下来,把类中虚指针的类型调整为指向对应虚表的指针。比如,Cat
类的vptr
成员,应该使用CatVtable*
类型。此外,我还把虚表中每个项目的类型都改为了函数指针,比如Dog__run
的类型是void (*)(Dog*)
,这样就更容易识别了。
最后一步是回到原来的代码中,为变量赋予适当的类型:
上面是把m
设定为Cat*
或Dog*
,可以看到,现在的代码比刚才简洁很多:如果m
是Dog
类型的话,那么第15行调用的就是Dog__walk
,否则是Cat__walk
。这个例子很简单,但能够说明我们大致的思路了。
我们也可以把m
设置为Mammal*
类型,但效果就不太好了:
假设m
是Mammal*
类型,那么第15行就会调用一个纯虚函数,这是不可能的,此外第17行的调用也会产生问题。因此我们可以推测,m
一定不是Mammal*
类型。
这种和源代码不符的说法,可能听上去比较奇怪。实际上,这是因为在编译时,我们给m
赋予的是一个编译时的类型(静态类型),但我们更关注它的动态类型(或者说是运行时的类型),因为这才是决定哪个虚函数被调用的关键。事实上,一个元素的动态类型,基本永远不可能是一个抽象类。因此如果给出的虚表中,含有一个___cxa_pure_virtual
函数,那这个类型可能并不是他的运行时类型,可以无视。实际刚才的例子中我们完全可以不给Mammal
类的虚表创建一个结构体,因为这个结构体永远都不会用到。
通过以上的分析,我们知道了,动态类型可能是Cat
或者Dog
,我们也知道了如何通过查看虚表的项目,来判断哪个函数会被调用,这是C++虚函数逆向的第一步。下一步,我们会介绍如何处理更多、更复杂的二进制程序和继承关系。