C++
和其他的一些面向对象语言支持虚表的结构,用以实现动态派发的功能。
从编译器实现上看,编译器为该对象添加了一个指向虚表的指针,也就是vtable
,虚表存储的是函数指针的数组。
在一些c语言的项目,例如内核代码中,也常见类似的设计,例如:
由于动态派发是面向对象语言的基本特性,其他面向对象的编译型语言也有类似的结构,例如rust的dyn Trait
(类型擦除),swift
的any 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++
的纯虚函数是用来定义一个抽象接口。
设置为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字节,就完成了转换。
这样也就带来一个副作用,也即下面的等式会不成立:
下图是一个多继承的例子。(来自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
的构造
虚表如下:
__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
的声明。例如,输出下面的代码:
对于像这样的简单的继承关系(单继承),IDA
可以自动生成虚表类型和对象类型:
在我们将参数类型修正为base2
后,ida
的结果如下:
对于更复杂的例如有多继承的类,可以手动创建类型(满足下列条件)
- VFT pointer must have the "__vftable" name
- VFT type must follow the "CLASSNAME_vtbl" pattern
Angr
vtable
的查找采取了下面的策略:
如果被代码块引用,而且本身是指向函数的指针,就认为是
vtable
的开头。
实例代码:
Binary Ninja
Radare2
av