Silver

「这世界并不会在意你的自尊。
这世界指望你在自我感觉良好之前先要有所成就。」
Bill Gates.
Fetched from BILIBILI

C++虚函数逆向(2)

Last updated:Feb.16, 2017 CST 16:33:25

Info

译者注:编译者[email protected]原文地址为Adam Schwalm的博客。本系列的前作也有翻译,见C++虚函数逆向(1)

前言

在上一篇文章中,我描述了如何在一个小型C++程序中进行去虚化(识别虚函数调用中对应的函数体)。然而,这种方法的限制比较多,最主要的是因为这种方法是手动的,比较低效。如果程序中有大量的虚表,手动定位每张虚表,并创建对应的结构体和引用关系,是不现实的。

因此,在这一部分中,我会进一步介绍虚表的详细结构,以及如何通过编程的方法来寻找他们。此外,我还会介绍一下如何尽可能的还原这些虚表之间的关系,并借此还原这些虚表背后对应的类的关系。

但是首先,我要说明一下这些做法对哪些程序有用。在上一篇文章中我说过,大多数和虚表布局有关的具体细节,和编译器有极大的相关性。这是因为由于C++标准需要对各种底层架构进行适配。问题是,如果虚表的具体布局也成为标准的一部分,而刚好这种布局在某些架构上的实现比较低效,那么就会很尴尬。编译器开发者们必须在性能和兼容性之间作出抉择,而他们似乎更偏重前者。

更尴尬的是,由于不同编译器产生的程序经常需要互相调用,他们之间必须有一定的兼容性(这种问题在动态链接的时候尤为突出)。因此,编译器开发者们同意对诸如虚表布局、异常处理和其他的一些事情上,共同遵照某种约定。这些约定中,使用最广泛的是Itanium C++ ABI。这个标准被GCC、clang、ICC和其他很多种编译器使用(然而不包含Visual Studio)。下面的描述对这些编译器都是可用的。

Itanium ABI在某些地方没有作出具体规定。比如,不约定虚表应该表存储在哪个段中。因此下面的文章,更确切的说,是在描述GCC下的行为,也就是下图中高亮的部分。

此外,仍然注意以下几点:

关于虚表

首先回忆一下上一篇文章。那篇文章中我们谈到,虚表可以理解为数据段中的一连串函数指针。这个数组只能被第一个元素的地址索引,因为访问其他元素都可以通过取偏移量的方法来搞定。

.rodata:08048D48 off_8048D48     dd offset sub_8048B6E
.rodata:08048D4C                 dd offset sub_8048BC2
.rodata:08048D50                 dd offset sub_8048BE0

这是在一个二进制文件中的一段内容,看起来符合以上定义。这是在.rodata段中的一个由三个函数指针构成的数组,只有首元素被引用了。实际上,这就是一个虚表。那么通过这个定义来寻找虚表,是十全十美的吗?

观察下面的代码:

#include <iostream>
#include <cstdlib>

struct Mammal {
  Mammal() { std::cout << "Mammal::Mammal\n"; }
  virtual ~Mammal() {}
  virtual void walk() { std::cout << "Mammal::walk\n"; }
};

struct Cat : Mammal {
  Cat() { std::cout << "Cat::Cat\n"; }
  virtual ~Cat() {}
  virtual void walk() { std::cout << "Cat::walk\n"; }
};

struct Dog : Mammal {
  Dog() { std::cout << "Dog::Dog\n"; }
  virtual ~Dog() {}
  virtual void walk() { std::cout << "Dog::walk\n"; }
};

struct Bird {
  Bird() { std::cout << "Bird::Bird\n"; }
  virtual ~Bird() {}
  virtual void fly() { std::cout << "Bird::fly\n"; }
};

//NOTE: this may not be taxonomically correct
struct Bat : Bird, Mammal {
  Bat() { std::cout << "Bat::Bat\n"; }
  virtual ~Bat() {}
  virtual void fly() { std::cout << "Bat::fly\n"; }
};

int main() {
  Bird *b;
  Mammal* m;
  if (rand() % 2) {
    b = new Bat();
    m = new Cat();
  } else {
    b = new Bird();
    m = new Dog();
  }
  b->fly();
  m->walk();
}

看起来有五张虚表,分别是MammalCatDogBirdBat类。然而,既然我说了“然而”,事情就没那么简单。实际上,满足前述判别条件的有六个区域。

当你考虑多重继承的虚表结构的时候,你就知道为什么会有这样了:

注意,Bat类不仅包含了BirdMammal类的虚指针,还包含了他们的两个完整实例(也就是子对象)。而这两个虚指针指向的是另外一张表。因此,有多个父类的类,会为每个父类都准备了一张对应的虚表。它们在Itanium ABI中称作“虚表组”。

虚表组

一个虚表组包含两类东西。一个是第一个父类的虚表,只有一张,称为“主虚表”,另外一个就是其他父类的虚表,可有多张,称为“次虚表”。这些表在二进制文件中是按照源码中的声明顺序紧密相联的。显然,Bat类的虚表组应该像是这样的:

Offset Description Bat's vtable for
0 Address of Destructor 1 Bird
4 Address of Destructor 2 Bird
8 Address of Bat::Fly Bird
12 Address of Destructor 1 Mammal
16 Address of Destructor 2 Mammal
20 Address of Mammal::walk Mammal

每张虚表占用了12个字节。这里需要两个析构器,而且因为Mammal没有覆盖walk方法,我们需要让Bat的虚表中包含Mammal::walk。但是我们找遍文件也没有看到,在.rodata段中有哪六个连续的函数指针。

进一步研究Itanium的标准后,我们发现了原因。一张虚表不仅含有函数指针,还含有其他一些东西。下图是没有虚继承时的虚表结构:

RTTI指针一般会指向一个RTTI结构体。但是因为我们关闭了RTTI,所以这里应该是0。而第一个字段的值有点麻烦。它指的是,在一个子对象中,使用this指针的时候,要从这个对象的起始处添加多少字节。以下面的代码为例:

Bat* bat = new Bat();
Bird* b = bat;
Mammal* m = bat;

对b和m的赋值都是有效的。对b的赋值不需要什么指令,因为由于BirdBat的父类,且是第一个父类,那么在任何一个Bat对象中,Bird类的子对象都是第一个子对象。因此,指向Bat对象的指针必然是指向Bird类的指针,和单继承一样。

但是,对m的赋值,就需要一些工作了。BatMammal子对象并不在首部,因此编译器需要为bat指针的值加上一个偏移量,让m能指到Mammal上。这个偏移量是Bird类实例化后的大小,再加上对齐。这个值取反后,就会存到前述的第一个字段Offset to Top中。1

这个字段,让我们能更方便的识别虚表组。虚表组包含多个连续的虚表,且其中每个虚表头部的Offset to Top值都是递减的。观察下图:

上面的代码编译之后的二进制文件中有六张虚表。2号表的第一个字段是-4,其他的都是0。RTTI指针也都是0,和我们预期一致。这个-4告诉我们:

编程寻找虚表

根据上述理论,我们可以用下面这个小脚本来寻找二进制文件中所有的虚表和虚表组:

""" A simple script to locate vtable groups in binaries with the Itanium ABI.
Note that this script does not account for virtual inheritance or (more notably),
cases were the vtable contains null pointers. This may happen in more recent
compilers with purely abstract types.
"""

import idaapi
import idautils

def read_ea(ea):
    return (ea+4, idaapi.get_32bit(ea))

def read_signed_32bit(ea):
    return (ea+4, idaapi.as_signed(idaapi.get_32bit(ea), 32))

def get_table(ea):
    ''' Given an address, returns (offset_to_top, end_ea)
    for the table  located at that address or None if there
    is no table'''

    ea, offset_to_top = read_signed_32bit(ea)
    ea, rtti_ptr = read_ea(ea)
    if rtti_ptr != 0:
        return None
    func_count = 0
    while True:
        next_ea, func_ptr = read_ea(ea)
        if not func_ptr in idautils.Functions():
            break
        func_count += 1
        ea = next_ea
    if func_count == 0:
        return None
    return offset_to_top, ea

def get_table_group_bounds(ea):
    ''' Given an address, returns the (start_ea, end_ea) pair
    for the table group located at that address'''
    start_ea = ea
    prev_offset_to_top = None
    while True:
        table = get_table(ea)
        if table is None:
            break
        offset_to_top, end_ea = table
        if prev_offset_to_top is None:
            if offset_to_top != 0:
                break
            prev_offset_to_top = offset_to_top
        elif offset_to_top >= prev_offset_to_top:
            break
        ea = end_ea
    return start_ea, ea

def find_tablegroups(segname=".rodata"):
    ''' Returns a list of (start, end) ea pairs for the
    vtable groups in 'segname'
    '''
    seg = idaapi.get_segm_by_name(segname)
    ea = seg.startEA
    groups = []
    while ea < seg.endEA:
        bounds = get_table_group_bounds(ea)
        if bounds[0] == bounds[1]:
            ea += 4
            continue
        groups.append(bounds)
        ea = bounds[1]
    return groups

在IDAPython中加载上述脚本后,运行find_tablegroups,即可得到所有找到的虚表组的地址。现在你可以为所欲为,比如根据这些信息为张虚表建立结构体,等等。

但是,只知道虚表组在哪没什么用。我们还需要得知和这些虚表对应的不同类型之间,有什么样的关系。这样,我们才能了解每个虚函数调用中,有哪些函数可能被调用,从而得知这个对象在继承关系中处于怎样的位置。

恢复类型关系

搞定这个问题,最简单的方法是,共用两个函数指针的两张虚表一定有某种关系。尽管不能恢复这些关系,但也足够给我们一些可用的信息了。

但是,考虑一下C++中构造器和析构器的行为,我们可以做的更好。一个构造器需要做这些事情:

  1. 调用父类的构造器
  2. 初始化这个类型对应的虚表指针
  3. 初始对象内部的成员
  4. 运行构造器中的其他代码

析构器则相反:

  1. 将虚表指针指向这个类的虚表
  2. 运行析构器的其他代码
  3. 销毁对象成员
  4. 调用父类析构器

注意,虚表指针在析构器中又被重新设置了一次。考虑到在析构器中我们需要让虚函数调用发挥作用,这么做是正确的。假设我们将Bird类改个名字,叫fly。如果你要析构一个Bat对象,那么在调用Bird类的析构器时,应该调用的是Bird::fly而不是Bat::fly,因为这个对象已经不再是一个Bat类的对象。因此,Bird类的析构器一定要更新一下虚表。

那么现在我们知道了,每个析构器都要调用父类的析构器,而这些析构器因为要重置虚表指针,也会和虚表之间产生引用关系。因此,我们可以通过跟踪析构器的行为,重新构造出继承关系。类似的思路在构造器上也能实现。

考虑第一个虚表中的第一个项目(应该是一个析构器):

注意这里有两次赋值,都是在处理虚表指针,也就是刚才我们描述的析构器工作流程中的第一步。这个类看起来没有任何成员,因为析构器跳过了第二步和第三步,直接进行第四步。从其他函数在虚表中的位置(一个在6号表的头部,另一个在3号表的头部),可以看出他们都是析构器。对所有虚表都这样研究一下,我们可以得到这么一个拓扑:

这和源码中描述的继承关系相符。

识别构造器

原理很简单:所有把虚表的地址赋值给虚指针而不是析构器的函数是构造器。这样我们能找到六个这样的函数:

Constructor Table
sub_8048AEC Table 1/2
sub_8048A64 Table 3
sub_80489A8 Table 4
sub_80488EC Table 5
sub_8048864 Table 6

去虚化

来看一下主函数:

很明显,在28和29行的调用是虚函数。此外,从上面的表格中,我们可以知道在13、16、22和25行的函数是构造器。有了这些准备,我们可以用第一篇文章中的知识,进行去虚化:

这里,我把v0的类型设置为type_8048D40*。这是和第一张、第二张虚表以及第13行的构造器相关的类型。同理,第16行的构造器和第五张虚表有关联,我创建了一个名为type_8048D98的类型(后面的地址是虚表开始的地址,你也可以随意改个名字)。第28和29行的v2v3也能用这种方法处理好。

到此,尽管源代码中包含可以帮助我们更容易识别类型和方法的字符串等信息,我们完全没有凭借这些信息,就完成了“去虚化”。

小结

这个过程仍然非常笨拙,但至少我们前进了一步,现在我们基本上可以自动探测虚表了。现在,自动化创建和类对应的结构体已经有了可能,而自动定位构造器的位置也不是毫无可能。我们可以准备重构类型之间的继承树了。下一篇文章中,我们会研究更多相关的内容。


  1. 译者注:还看不懂的话自己写个程序调一下 

Contact webmaster at:
[email protected]