目前网上有很多关于逆向C++的文章,但是涉及到虚函数的逆向只占很小的一部分。不过,在这里我想花一些时间来写逆向虚函数的内容。这其中涉及到很多类以及继承函数,所以我认为可以提供一些逆向的技巧。如果你已经熟悉虚函数的逆向过程,那么直接进入第2部分。这部分主要是提及一些准备工作。
另外需要提及以下几点:
代码没有经过RTTI(RTTI将在稍后讨论)编译。
使用32位x86平台
二进制文件已被抽离
大多数虚函数的实现细节不规范,不同编译器的情况也是不同的。出于这个原因,我们将专注于GCC编译器。
所以一般来说,我们需要分析的二进制文件是经过g++ -m32 -fno-rtti -fnoexceptions -O1 file.cpp编译的,然后使用strip进行抽离。
0x01目标
在大多数情况下,我们不能指望“devirtualize”虚函数调用。直到运行时才能取得所需的信息。相反,我们工作的目标是,以确定哪些函数可能会在特定点调用。在后面的部分,我们将重点缩小可能性。
0x02基础内容
假设已经熟悉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是cat或dog取决于rand的输出。当然编译器无法提前知道这些,所以它是如何调用正确的函数得?答案是,每种类型都有一个虚函数,编译器会将虚函数表插入到生成的二进制文件中。这种类型的每个实例会提供一个额外的成员称为vptr,指向该对象正确的虚表。代码初始化这个指针的正确的值,将被添加到构造函数中。
然后,当编译器需要调用一个虚拟函数,会先访问正确的虚表,找到该对象的虚函数并调用。这意味着,在虚表中的条目必须以相同的顺序应对每个相关类型(每个类的run可能是在索引1中,每一个walk在索引2,等等)。
所以我们希望找到二进制文件的三个虚表Mammal,cat和dog。我们可以使用.rodata找到相邻的函数:
再看一下主函数:
我们可以看到,在这两个分支都分配了4个字节大小的空间。这是有意义的,因为在结构体中的唯一数据是由编译器加入的vptr。在15和17行,可以看到存在虚函数的调用,编译器间接调用(获取vptr),并加上12访问在vtable中的第4项。在第17行中,得到虚表中的第2项。然后程序从表中检索的虚函数指针并调用它。
再看一下虚表,第4项是sub_80487AA,sub_804877E和_cxa_purevirtual。如果我们看一下这两个“sub”函数的内容,可以发现他们定义walk为Dog和Cat(如图所示)。通过消除该_cxa_pure_virtual函数必须属于虚函数表的Mammal。这是有道理的,因为Mammal没有定义walk,而这些“pure_virtual”是由GCC插入,当这些函数是虚函数时。这样,表1必须是Mammal对象,2是用于Cat和表3是Dog。
比较奇怪的是,每个虚表中存在5个项目,但是却只有4个虚函数:
1. run
2. walk
3. move
4. the destructors
附加条目是一个“额外”的析构函数。在这里,GCC将插入多个析构函数。其中的第一个将简单地销毁对象的成员。第二个将删除已为对象分配内存(第17行)。在某些情况下,可以是在某些虚拟继承情况中使用的第3版。
在完成对“sub_”函数的内容回首,我们发现虚函数表的布局如下:
| Offset | Pointer to |
|--------+-------------|
| 0 | Destructor1 |
| 4 | Destructor2 |
| 8 | run |
| 12 | walk |
| 16 | move |
但是,请注意,在Mammal 表头两项都是零。这是GCC的较新版本缘故。编译器使用具有纯虚函数的类与空指针代替析构函数项(即,抽象类)。
有了这一切之后,我们重命名一下。最后是这样:
请注意,由于Cat和Dog都没有实现move,它们都是继承Mammal,因此vtables中的move项目是相同的。
0x03结构体
下面开始定义一些有用的结构体。我们已经看到,唯一的成员Mammal,Cat和Dog的结构将是他们的vptrs。因此,我们可以快速地定义这些:
下一步是有点复杂。我们要为每个虚表创建一个结构体。这里的目标是获得反编译器输出并展示实际上是哪个函数被调用的。然后,我们可以通过这些可能性检查所有的选项。
为了实现这一目标,结构体的成员将具有相应的功能,指向的内容如下:
我们需要设置的vptr类型的每个结构是对应的Vtable类型。例如,该类型的vptr为Cat,应该是CatVtable。此外,我已经设置每个虚表项的类型是一个函数指针。这将有助于IDA正确显示。所以类型Dog__run应该是void () (Dog*)(因为这是Dog__run签名)。
如果我们回到主函数的反编译代码,我们现在可以重命名局部变量m,并设置其类型为Cat或Dog。后来我们看到:
现在,我们可以很容易地看到被调用的可能函数。如果m是Cat,那么第15行会调用Catwalk,如果是Dog然后它会调用Dogwalk。显然,这是一个简单的例子,但是这就是一般的思路。
我们还可以设置类型m是Mammal*的,但如果我们按如下的方式做可能会有一些问题:
注意,如果真正的类型m是Mammal然后在第15行调用将是一个纯虚函数。这本不应该发生。还有在17行这显然会造成一个空指针调用的问题。因此,我们可以得出这样的结论m不能是Mammal。
这似乎很奇怪,因为m实际上是声明为Mammal*。然而,该类型是编译时类型(静态类型)。我们感兴趣的是动态类型(或运行时类型)m,因为这将决定哪个虚函数将被调用。事实上,动态类型的对象可以根本不可能是抽象类型。所以如果一个给定的虚函数表中包含的一个___cxa_pure_virtual函数,可以忽略它。我们可以没有创建虚表结构,Mammal,因为它永远不会被使用(但我希望看到为何有用)。
本文翻译于alschwalm,如若转载,请注明来源于嘶吼: <http://www.4hou.com/technology/2927.html>