C++虚函数逆向(2)
译者注:译者Silver@XDSEC,原文地址https://alschwalm.com/blog/static/2016/12/17/reversing-c-virtual-functions/,作者为Adam Schwalm。本系列的前作也有翻译,见C++虚函数逆向(1)
前言
在上一篇文章中,我描述了如何在一个小型C++程序中进行去虚化(识别虚函数调用中对应的函数体)。然而,这种方法的限制比较多,最主要的是因为这种方法是手动的,比较低效。如果程序中有大量的虚表,手动定位每张虚表,并创建对应的结构体和引用关系,是不现实的。
因此,在这一部分中,我会进一步介绍虚表的详细结构,以及如何通过编程的方法来寻找他们。此外,我还会介绍一下如何尽可能的还原这些虚表之间的关系,并借此还原这些虚表背后对应的类的关系。
但是首先,我要说明一下这些做法对哪些程序有用。在上一篇文章中我说过,大多数和虚表布局有关的具体细节,和编译器有极大的相关性。这是因为由于C++标准需要对各种底层架构进行适配。问题是,如果虚表的具体布局也成为标准的一部分,而刚好这种布局在某些架构上的实现比较低效,那么就会很尴尬。编译器开发者们必须在性能和兼容性之间作出抉择,而他们似乎更偏重前者。
更尴尬的是,由于不同编译器产生的程序经常需要互相调用,他们之间必须有一定的兼容性(这种问题在动态链接的时候尤为突出)。因此,编译器开发者们同意对诸如虚表布局、异常处理和其他的一些事情上,共同遵照某种约定。这些约定中,使用最广泛的是Itanium C++ ABI。这个标准被GCC、clang、ICC和其他很多种编译器使用(然而不包含Visual Studio)。下面的描述对这些编译器都是可用的。
Itanium ABI在某些地方没有作出具体规定。比如,不约定虚表应该表存储在哪个段中。因此下面的文章,更确切的说,是在描述GCC下的行为,也就是下图中高亮的部分。
此外,仍然注意以下几点:
- 示例代码编译时没有使用RTTI(如果启用的话,下面的工作会简单很多)
- 程序不包含虚继承。这是因为讨论这个问题需要很长的篇幅,也很复杂,而虚继承的使用并不常见,我觉得用如此篇幅来描述这个问题是不合适的。
- 下文描述的是32位程序。
关于虚表
首先回忆一下上一篇文章。那篇文章中我们谈到,虚表可以理解为数据段中的一连串函数指针。这个数组只能被第一个元素的地址索引,因为访问其他元素都可以通过取偏移量的方法来搞定。
.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();
}
看起来有五张虚表,分别是Mammal
、Cat
、Dog
、Bird
和Bat
类。然而,既然我说了“然而”,事情就没那么简单。实际上,满足前述判别条件的有六个区域。
当你考虑多重继承的虚表结构的时候,你就知道为什么会有这样了:
注意,Bat
类不仅包含了Bird
和Mammal
类的虚指针,还包含了他们的两个完整实例(也就是子对象)。而这两个虚指针指向的是另外一张表。因此,有多个父类的类,会为每个父类都准备了一张对应的虚表。它们在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的赋值不需要什么指令,因为由于Bird
是Bat
的父类,且是第一个父类,那么在任何一个Bat
对象中,Bird
类的子对象都是第一个子对象。因此,指向Bat
对象的指针必然是指向Bird
类的指针,和单继承一样。
但是,对m
的赋值,就需要一些工作了。Bat
中Mammal
子对象并不在首部,因此编译器需要为bat
指针的值加上一个偏移量,让m
能指到Mammal
上。这个偏移量是Bird
类实例化后的大小,再加上对齐。这个值取反后,就会存到前述的第一个字段Offset to Top
中。1
这个字段,让我们能更方便的识别虚表组。虚表组包含多个连续的虚表,且其中每个虚表头部的Offset to Top
值都是递减的。观察下图:
上面的代码编译之后的二进制文件中有六张虚表。2号表的第一个字段是-4,其他的都是0。RTTI指针也都是0,和我们预期一致。这个-4告诉我们:
- 2号表是一个虚表组中的第二张表
- -4取反后是4,也就是说这个虚表组中第一张表(也就是1号表,因为虚表组中的表是紧密相联的)对应的实例大小为4。谨记,两张虚表共同构成了这个虚表组,而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++中构造器和析构器的行为,我们可以做的更好。一个构造器需要做这些事情:
- 调用父类的构造器
- 初始化这个类型对应的虚表指针
- 初始对象内部的成员
- 运行构造器中的其他代码
析构器则相反:
- 将虚表指针指向这个类的虚表
- 运行析构器的其他代码
- 销毁对象成员
- 调用父类析构器
注意,虚表指针在析构器中又被重新设置了一次。考虑到在析构器中我们需要让虚函数调用发挥作用,这么做是正确的。假设我们将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行的v2
和v3
也能用这种方法处理好。
到此,尽管源代码中包含可以帮助我们更容易识别类型和方法的字符串等信息,我们完全没有凭借这些信息,就完成了“去虚化”。
小结
这个过程仍然非常笨拙,但至少我们前进了一步,现在我们基本上可以自动探测虚表了。现在,自动化创建和类对应的结构体已经有了可能,而自动定位构造器的位置也不是毫无可能。我们可以准备重构类型之间的继承树了。下一篇文章中,我们会研究更多相关的内容。
译者注:还看不懂的话自己写个程序调一下