C++的虚表和动态派发的特性紧密关联。一个函数可能接受一个某类A的对象,然后调用其某个虚方法。具体会调用哪个函数无法在编译的时候确定,从反汇编上看,函数的地址也是动态获取的,有时候会给我们的逆向带来分析的难度。

举个例子,这里有一个call_vir函数:

int call_vir(base1 *b) {
  return b->b1();
}

反汇编后会变成这个样子:

__int64 __fastcall call_vir(__int64 (__fastcall ***a1)(_QWORD))
{
  return (**a1)(a1);
}

C++ ABI

关于虚表的具体内存排布与ABI规范有关。ABI制定了一套二进制程序兼容性的规范。主要规定的有传参的方式,C++函数命名的Mangle策略等,虚表结构也包含在内。

在linux平台上,gcc编译器一般使用的是Itanium C++ ABI,arm架构也是指定的这套ABI。关于虚表的部分可以在这里找到Itanium C++ ABI

Virtual Table

虚表在只读的数据段上,编译器自动生成的构造函数会设置对象的虚表。在Itanium ABI下,虚表位于对象内存的第一个位置。

虚表具体内容如下1

  • 第一个参数是指向this指针的偏移,如果采用了多继承,那么后面的虚表会设置该值为一个负数的偏移。
  • 第二个参数是RTTI的类型相关信息,可以利用该信息自动解析出类名。
  • 后面是一些函数指针。

Pure Virtual

C++的纯虚函数是用来定义一个抽象接口。

class base2 {
  virtual int b2() = 0;
  int data;
};

设置为0表示该函数没有实现,如果子类没有重载该方法就无法通过编译。 实际上,编译器会将其函数指针替换成一个___cxa_pure_virtual函数。

040E0 __ZTV5base2     DCQ 0                   ; DATA XREF: base2::base2(void)+C↑o
__const:00000001000040E0                                         ; offset to this
__const:00000001000040E8                 DCQ __ZTI5base2         ; `typeinfo for'base2
__const:00000001000040F0                 DCQ ___cxa_pure_virtual

Multiple Inheritance / Secondary Virtual Table

我们都知道,C++public继承表示的是is-a的关系。那么,一个有两个父类A,B的对象既可以被当作A,也可以被当作B来使用。

具体在编译器的实现上,也是如此。对象的存储结构是A和B紧接着,附有两个虚表,都位在前八位。这样,如果有某个接受B类的函数,类型转换就把this的指针加上16字节,就完成了转换。

这样也就带来一个副作用,也即下面的等式会不成立:

Derived obj;
assert((void *)dynamic_cast<Derived *>(&obj) == (void *)dynamic_cast<Base *>(&obj));

下图是一个多继承的例子。(来自2

---
title: Class Hierarchy
---
classDiagram
    class base1 {
        +int data
        +b1() int
    }
    class base2 {
        +int data
        +b2() int
    }
    class der2 {
        +int data
        +b2() int
    }
    class derived {
        +d() int
        +b2() int
    }
    base1 <|-- derived
    base2 <|-- der2
    der2 <|-- derived

derived的构造

void __fastcall derived::derived(derived *this)
{
  base1::base1(this: this);
  der2::der2(this: (derived *)((char *)this + 16));
  *(_QWORD *)this = &off_100004010;
  *((_QWORD *)this + 2) = &off_100004038;
}

虚表如下:

__const:0000000100004000 ; `vtable for'derived
__const:0000000100004000 __ZTV7derived   DCQ 0                   ; DATA XREF: derived::derived(void)+2C↑o
__const:0000000100004000                                         ; offset to this
__const:0000000100004008                 DCQ __ZTI7derived       ; `typeinfo for'derived
__const:0000000100004010 off_100004010   DCQ __ZN5base12b1Ev     ; base1::b1(void)
__const:0000000100004018                 DCQ __ZN7derived1dEv    ; derived::d(void)
__const:0000000100004020                 DCQ __ZN7derived2b2Ev   ; derived::b2(void)
__const:0000000100004028                 DCQ -16                 ; offset to this
__const:0000000100004030                 DCQ __ZTI7derived       ; `typeinfo for'derived
__const:0000000100004038 off_100004038   DCQ __ZThn16_N7derived2b2Ev ; `non-virtual thunk to'derived::b2(void)

Thunk

一个可能的问题是,子类重载了父类的方法,但是此时由于偏移的存在,this指向的并非是整个对象。C++对此采取了Thunk的机制,该函数唯一的作用就是对传入的指针加上offset的偏移(为负数),实现转换,再调用实际的函数。

Proximity

这部分参考3

在一个编译模块内部,其数据排布的位置是临近的。这意味着某些我们所关心的数据,虚表,或者类型信息可能都在某个数据段很靠近的位置上。

Analysis Tools

对一个使用了C++虚函数的二进制程序,我们主要关注是否可以:

  • 首先,找到虚表的位置(一些强大的分析工具可以自动查找,不过也有一定使用条件)
  • 其次,怎样快速构建出虚表上函数的类型
  • 对使用了改虚表的对象,设置对象类型,辅助分析(可以手动修改)

IDA Pro

IDA提供了对虚函数这一C++特性的支持。

首先,IDA可以自动找到虚表。其实现是依赖于RTTI的,因为当指定了编译参数-fno-rtti后,IDA就无法识别出任何虚表。 (RTTI确实会包含类型名称等信息)

可以在IDA中以受限的C++语法生成类型声明。在IDA的类型窗口,可以写入一个class的声明。例如,输出下面的代码:

  class base1 { virtual int b1(); int data; };
  class base2 { virtual int b2(); int data; };
  class der2 : public base2 { virtual int b2(); int data; };
  class derived : public base1, public der2 { virtual int d(); };

对于像这样的简单的继承关系(单继承),IDA可以自动生成虚表类型和对象类型:

__int64 __fastcall call_vir(__int64 (__fastcall ***a1)(_QWORD))
{
  return (**a1)(a1);
}

在我们将参数类型修正为base2后,ida的结果如下:

int call_vir(base2 *b) {
  return b->b2();
}

对于更复杂的例如有多继承的类,可以手动创建类型(满足下列条件)

  - VFT pointer must have the "__vftable" name
  - VFT type must follow the "CLASSNAME_vtbl" pattern

Angr

vtable的查找采取了下面的策略:

如果被代码块引用,而且本身是指向函数的指针,就认为是vtable的开头。

实例代码:

        p = angr.Project(os.path.join(test_location, "x86_64", "cpp_classes"), auto_load_libs=False)
        vtables_sizes = {0x403CB0: 24, 0x403CD8: 16, 0x403CF8: 16, 0x403D18: 16}
        vtable_analysis = p.analyses.VtableFinder()
        vtables = vtable_analysis.vtables_list
 
        assert len(vtables) == 4
 
        for vtable in vtables:
            assert vtable.vaddr in [0x403CB0, 0x403CD8, 0x403CF8, 0x403D18]
            assert vtables_sizes[vtable.vaddr] == vtable.size
 

Binary Ninja

struct base1 __packed {
    struct vtable_for_base1* vtable;
	int data;
};
 
struct base2 __packed {
    struct vtable_for_base2* vtable;
	int data;
};
 
struct __base(base1, 0) __base(base2, 0x10) der2 __packed {
    struct vtable_for_der2* vtable;
	int data;
};
 
struct __data_var_refs vtable_for_base1
{
	void (* b1)(struct base1* thiz);
};
 
struct __data_var_refs vtable_for_base2
{
	void (* b2)(struct base2* thiz);
};
 

Footnotes

  1. https://llvm.org/devmtg/2021-11/slides/2021-RelativeVTablesinC.pdf

  2. https://docs.hex-rays.com/user-guide/user-interface/menu-bar/view/c++-type-details

  3. https://hardwear.io/usa-2023/presentation/analyzing-decompiled-c++vtables-and-objects-in-GCC-binaries.pdf