C++虚函数逆向(1)

译者注译者Silver@XDSEC原文地址https://alschwalm.com/blog/static/2016/12/17/reversing-c-virtual-functions/作者为Adam Schwalm译文首发于Freebux本来想投36x赚点稿费但是他们的编辑器实在太难用了就投了支持MarkdownFreebux结果后来才知道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::walkCat类的虚表中第一项指向的是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类的23分别是属于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++虚函数逆向的第一步下一步我们会介绍如何处理更多更复杂的二进制程序和继承关系