C++和其他的一些面向对象语言支持虚表的结构,用以实现动态派发的功能。

从编译器实现上看,编译器为该对象添加了一个指向虚表的指针,也就是vtable,虚表存储的是函数指针的数组。

在一些c语言的项目,例如内核代码中,也常见类似的设计,例如:

struct object {
	struct obj_ops ops;
}
 
struct obj_ops {
	int (*op1)(int a, int b);
}

由于动态派发是面向对象语言的基本特性,其他面向对象的编译型语言也有类似的结构,例如rust的dyn Trait(类型擦除),swiftany Protocol等。

C++ ABI

ABI制定了一套二进制程序兼容性的规范。主要规定的有传参的方式,C++函数命名的Mangle策略。

在linux平台上,gcc编译器一般使用的是Itanium C++ ABI,arm架构也是指定的这套ABI。

MSVC则使用的是另外一套体系。

本文是在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);
};
 

Radare2

av

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