翻译:维一零
预估稿费:260RMB(不服你也来投稿啊!)
投稿方式:发送邮件至linwei#360.cn,或登陆网页版在线投稿
前言
在网上可以找到一些关于逆向C++的帖子,并且经常或多或少的涉及虚函数。然而,我想花一些时间详细的写点关于虚函数的处理,基于代码‘enterprisy’。这些代码通常可以包括成千上万的类和大量的类型层次结构,所以我认为一些逆向它们的技术值得好好描述。但是在那之前我想先通过一些简单的例子来进行。如果您已经熟悉虚函数逆向,我想可以直接进入第2部分。
值得注意的是以下几点:
代码编译没有RTTI和异常(稍后将讨论RTTI)
我使用的是32位x86平台
二进制文件已通过strip处理(无相关调试信息)
大多数虚函数的实现细节都没有标准化,并且不同编译器之间可能会有所不一样。出于这个原因,我们将专注于GCC的行为。
总之,我们将看到的二进制文件已经这样编译 g++ -m32 -fno-rtti -fnoexceptions -O1 file.cpp然后去除调试信息 strip.
我们的目标
在大多数情况下,我们无法“反虚拟化”一个虚函数调用,因为那些相关信息在运行时之前不会出现。相反,这个练习的目标是确定哪些函数可能在一个特定的地方被调用。在后面部分,我们将专注于缩小这些可能的范围。
基础
我假设您熟悉编写c++程序,但可能不清楚c++的内部实现细节。那么,让我们首先看看编译器如何实现虚函数。假设我们有以下类:
#include <cstdlib>
#include <iostream>
struct Mammal {
Mammal() { std::cout << "Mammal::Mammaln"; }
virtual ~Mammal() { std::cout << "Mammal::~Mammaln"; };
virtual void run() = 0;
virtual void walk() = 0;
virtual void move() { walk(); }
};
struct Cat : Mammal {
Cat() { std::cout << "Cat::Catn"; }
virtual ~Cat() { std::cout << "Cat::~Catn"; }
virtual void run() { std::cout << "Cat::runn"; }
virtual void walk() { std::cout << "Cat::walkn"; }
};
struct Dog : Mammal {
Dog() { std::cout << "Dog::Dogn"; }
virtual ~Dog() { std::cout << "Dog::~Dogn"; }
virtual void run() { std::cout << "Dog::runn"; }
virtual void walk() { std::cout << "Dog::walkn"; }
};
并且我们有一些代码使用到它们:
int main() {
Mammal *m;
if (rand() % 2) {
m = new Cat();
} else {
m = new Dog();
}
m->walk();
delete m;
}
当然, m是否是一只猫或狗取决于 rand的输出。编译器无法提前知道这个,所以它是如何调用正确的函数呢?答案是,对于每种有虚函数的类型,编译器将一个被称为vtable的函数指针表插入生成的二进制文件。这种类型的每个实例对象将多出一个被称为vptr的额外成员指向正确的vtable。用来以正确值初始化这个指针的代码将被添加到构造函数里。
然后,当编译器需要调用一个虚函数,它可以通过对象的vtable来访问正确的入口地址并进一步调用。这意味着在每种相关类型里表中的入口地址有着相同的次序(如每个类的 run可以在索引1的入口地址,每一个 walk在索引2的入口地址,依此类推)。
于是我们希望在二进制文件里找到哺乳动物,猫和狗的三个vtable表。我们可以通过 .rodata段里相邻函数的偏移量快速的找到它们:
IDA并不总是能很好地检测在rodata段的函数地址,你可能需要找一会才能看到第一个表。
程序的主函数是什么样的呢?IDA反编译看一下:
我们可以看到每个分支分配了4字节内存。这是有理由的,因为在类型结构里唯一的数据是由编译器添加的vptr。我们还可以看到虚函数调用15行和17行。首先,编译器取值(获取vpt)并且通过加12来访问vtable表里的第四个入口地址。第17行获取了vtable表里的第二个入口地址,然后程序从vtable表里调用了这个函数指针。
回头看看这些vtable表,第四个入口 sub_80487AA, sub_804877E,和 ___cxa_pure_virtual。如果我们看看两个“sub_”函数的代码我们知道它们定义了狗和猫的 walk(如上图所示)。经分析后, ___cxa_pure_virtual函数是属于哺乳动物的虚表。这是有理由的,因为哺乳动物没有定义 walk,当一个函数是纯虚函数时这些“纯虚”入口地址被GCC插入(毫不奇怪地)。因此,可以确定vtable表1是属于哺乳动物对象的,而表2是属于猫、表3是属于狗。
但似乎有点奇怪的是每个vtable表里有5个入口地址,而其中只有4个虚函数在使用:
run
walk
move
析构函数
这里多出了一个“额外”的析构函数。这是因为GCC会插入多个在不同的情况下使用的析构函数。这些析构函数中,第一个只会破坏对象的成员,第二个将删除对象的已分配内存(这是在上例第17行中被调用的版本)。在某些情况下可能有第三种版本,如在某些虚函数继承时。
通过回顾上述内容的“sub_”函数,我们发现虚表的布局如下:
但是,注意到哺乳动物虚表中的前两项为零。这是新版本GCC的一个古怪现象。编译器会在有纯虚函数的类中将析构函数项替换为空指针(如抽象类)。
记住这些,然后让我们做一些重命名。之后就剩下:
注意,因为既不是猫也不是狗实现的 move,这个是它们从哺乳动物那里继承的定义,所以在它们的虚表中这个move函数的入口地址是相同的。
结构
在这一点上开始定义一些结构是非常有用的。我们已经看到目前哺乳动物,猫和狗的数据结构里只有一个唯一的成员vptrs。所以我们可以快速地定义这些结构:
下一步会更复杂一点。我们要为每个虚表创建一个结构。这里的目标是获取解码器的输出以告诉我们如果 m有一个特定的类型将会调用什么函数。我们可以通过这些可能性循环检查所有的选项。
为了实现这一点,这个结构的成员会有相应的函数名称将被指出,就像这样:
您将需要为每个通过 Vtable类型通讯的结构设置对应vptr的类型。例如, Cat类vptr的类型应该是 CatVtable*。另外,我已经设置每个vtable入口的类型是一个函数指针。这将有助于IDA正确的显示内容。因此 Dog__run元素的类型应该是 void (*) (Dog*)(因为这是 Dog__run签名)。
如果回到main函数的反编译代码,我们现在可以重命名局部变量 m,并设置其类型 Cat*或 Dog*。之后就可以看到:
现在我们可以很容易地看到调用方可能的函数调用。如果 m是一个 Cat那么第15行就会调用 Cat__walk,如果是一个 Dog它就将调用 Dog__walk。显然这是一个简单的例子,但这是通用的思路。
我们也还可以设置 m的类型为 Mammal*,但如果这样做我们将会看到一些问题
注意到如果 m的真正类型是 Mammal然后在第15行的调用将是一个纯虚函数。这种事情不应该发生。并且在第17行这里还有一个调用到空指针显然页会导致异常问题。所以我们可以得出这样的结论: m不能一个 Mammal类型。
这可能看起来很奇怪,因为实际上 m是声明为 Mammal*。然而,那个类型是编译时类型(即静态类型)。我们感兴趣的是 m的动态类型(或运行时类型),因为那才能决定在一个虚拟函数调用里哪个函数将被调用。实际上,对象的动态类型不能是抽象类。因此如果一个给定的虚表包含一个 ___cxa_pure_virtual函数,那么它不是一个候选目标,你可以忽略它。我们可以不为哺乳动物创建一个虚表结构,因为它永远不会被使用(但我希望看到为什么它是有用的)。
因此猫或狗将成为动态类型,我们通过看它们的vtable条目也可以知道哪些函数将被调用。这是虚函数逆向相关的基础知识。在接下来的部分我们将会看到如何处理更大的代码和更复杂的场景。
发表评论
您还未登录,请先登录。
登录