C++虚函数的攻与防

阅读文章:

虚函数重用攻击方法

  • Counterfeit Object-oriented Programming(S&P’15)

源码级别编译时防御方案

  • CFIXX- Object Type Integrity for C++ Virtual Dispatch (NDSS’18)
  • VTrust- Regaining Trust on Virtual Calls(NDSS’16)
  • Protecting C++ Dynamic Dispatch Through VTable Interleaving(NDSS’16)
  • SAFEDISPATCH- Securing C++ Virtual Calls from Memory Corruption Attacks(NDSS’14)

二进制级别插桩防御方案

  • VPS: Excavating High-Level C++ Constructs from Low-Level Binaries to Protect Dynamic Dispatching(ACSAC ‘19)
  • Strict Virtual Call Integrity Checking for C++ Binaries(CCS17)
  • VTint- Protecting Virtual Function Tables’ Integrity(NDSS’15)
  • VTPin- Practical VTable Hijacking Protection for Binaries(ACSAC’15)
  • vfGuard- Strict Protection for Virtual Function Calls in COTS C++ Binaries(NDSS’15)

C++相关

C++特性与逆向

C++作为一种很难精通的高级语言,本身融合了三种不同的编程方式:

  • C语言代表的过程性语言
  • 以类为代表的面向对象语言
  • 以模板为方法的泛型编程

所以在这些语言特性上的实现,就更加的复杂了,毕竟CPU还是那个仅仅认识各种运算以及存储操作的CPU。比如,在面向对象这个特性中,有一种叫”虚函数“的机制,利用这个机制,子类和父类对一个同名的方法可以有不同的实现,即子类对象和父类对象在调用本类中的一个同名方法时,可以调用的是完全不同的两个函数。在C++本身对这个机制的实现里,就用到了函数指针,或者说间接跳转。而控制这个跳转,是和对象一起存储在用户空间的一段内存中的一段数据,即虚函数表的指针,如果这个内存被破坏,则间接跳转就可能会被控制,进而导致控制流劫持。

image

那么为什么虚函数表的指针会放到对象的内存中的第一个位置呢?答:为了找到这个指针!因为必须根据这个对象的地址找到对象所对应的类实现的虚函数,换句话说就得找到虚表。也是因为这个原因,为了方便,就直接把虚函数表地址放到了,对象内存的第一个位置,也正是因为这样,这段内存如果能被攻击者覆盖,如下图,则程序的控制流可能就会被劫持。所以如果可以更改对象和虚表地址的关联方式,可能就能避免此类攻击!

image

其实可以发现,C++的特性实现肯定是由编译器实现,不过无论是GCC,LLVM和MSVC都通过函数指针表支持C ++多态性,而且都把虚表指针放在了对象内存的第一个位置,这也说明覆盖虚函数指针的这种攻击方式是多平台通用的。

这仅仅与虚函数有关的特性,所以为了理解C++更多的特性实现,则需要研究不同的C++编译器原理,或者对C++编译后的二进制程序进行逆向,找到的有关C++逆向的资料如下:

书籍:

文章:

系列:

C++虚函数机制

这种编译中确定要采用函数指针的编译过程叫做 动态分配 Dynamic Dispatch

image

只有在源码中使用对象的指针进行调用时,在二进制中才会使用这种函数指针的方式进行函数调用,不过一般的对象在源码中的的使用方式都是通过new操作符进行创建,这样的话对象会分配到堆上,并且返回值对对象的地址,一般会直接使用这个指针进行函数的调用。而且虚函数在源码中的使用一般是一个基类的指针类型,赋值为一个子类的指针,然后调用虚方法时可以调用这个子类重写的虚方法。这是在二进制中必然是通过函数指针进行调用的,因为静态编译的时候,并不能确定这里执行的是哪个子类的虚方法。

更多关于虚函数以及其攻击的相关知识还可以在以下书籍中找到对应章节进行学习:

  • 《0day安全: 软件漏洞分析技术(第二版)》6.3(198页)
  • 《加密与解密(第四版)》4.1.4(115页)
  • 《IDA Pro权威指南(第二版)》8.7.2(125页)

也可以参考如下文章:

C++虚函数的攻击

攻击方式

参考:C++虚函数调用攻防战

  • vtable注入:伪造vtable,让虚表指针指向的是攻击者自己构造的函数指针数组
  • vtable重用:不伪造vtable,篡改vtable指针指向已有的vtable,例如COOP
  • vtable破坏:直接篡改vtable,但现在不可行,因为编译器将vtable放到只读内存页,所以现在基本不用考虑

由于C++虚函数表本身不可写,所以对于C++虚函数的攻击也的本质可以称之为C++虚函数表的劫持,本质是篡改了指向虚函数表的虚表指针。而且对象一般都是存在于堆上,所以一般对于虚函数表的覆盖的攻击手段,大都是通过UAF实现的。

相关题目

_IO_FILE的vtable

我们知道 linux中的FILE 结构被一系列流操作函数(fopen()fread()fclose()等)所使用,而且这些FILE结构其实就是对应着linux的伪文件系统中的FILE对象:

image

如果经常打CTF一定会知道_IO_FILE也是PWN题目中经常出现的考点:

image

图片来自lowkey师傅XMan2019夏令营的培训资料

归根结底也是因为_IO_FILE的对象中存在着虚表,这个vtable指向的函数跳转表其实是一种兼容 C++ 虚函数的实现。当程序对某个流进行操作时,会调用该流对应的跳转表中的某个函数。

这个虚表存在的原因就是因为对于linux中各种不同的IO对象(块设备上的文件,驱动设备,伪文件系统中的文件)虽然都是调用的统一的fopen()fread()fclose()函数,但是其实对于不同的对象,这些函数的实现方法肯定是不一样的,也就是为什么存在虚表的原因了。

COOP(S&P’15)

对于虚函数的攻击方面的论文只找到一篇COOP(Counterfeit Object-oriented Programming 伪造的面向对象编程),可能因为学术的重点都是防护工作。COOP这篇论文发布在15年,其本质还是一种vtable重用的攻击方式,文中提出了一种可以绕过一些CFI检查的一种攻击方式,本质是构造了一系列的虚函数重用达到攻击效果。COOP证明了,许多没有精确考虑面向对象C ++语义的防御措施在实践中都可以绕开,并且文章中提到CPS,T-VIP,vfGuard和VTint这些后文也会讲到的一些防御手段可能都不能抵挡COOP攻击,并且文章中认为,不在源码层面做防御是很难抵抗COOP攻击的,所以要重新评估在二进制下对虚函数的防御工作,文章如下:

在ROP在有一个相似的方法,其结果是一系列小段合法函数,每一段代码实现最低限度的功能(例如,载入一个值进RDX中),但把它们组合在一起,却可以实现一些复杂的任务。COOP的一个基本组成部分就是利用主循环函数,在其中可以迭代对象链表或数组,调用每个对象中的虚函数。然后,攻击者把内存中“伪装”的对象组合起来,在某些情况下,可能会覆盖对象,这样就能在主循环中按攻击者安排好的顺序调用合法的虚函数

C++虚函数的防御

源码级别编译时防御

SAFEDISPATCH(NDSS’14)

SAFEDISPATCH- Securing C++ Virtual Calls from Memory Corruption Attacks(NDSS’14)

  • 原理:在虚函数调用的时候检查,和源码中调用的方法是否一致,包括所有可能类的虚方法(梳理类的继承关系),其实就是CFI,不能瞎跳。

  • 插桩处:虚函数调用处

image

  • 可以防止:注入,不同函数名以及非兼容类的重用
  • 不能防止:兼容类同函数名的重用
  • 开销:需要预计算类的继承关系,还需要大量的运行时查找

VTrust(NDSS’16)

VTrust- Regaining Trust on Virtual Calls(NDSS’16)

方案一:virtual function type enforcement
  • 原理:在虚函数调用的时候检查,和源码中调用的方法签名是否一致,本质还是CFI,不要瞎跳。签名包括:
signature = hash(funcName, paramList, qualifiers, classinfo)
  • 插桩处:虚函数实现方法前插入签名,虚函数调用处检查签名

image

  • 可以防止:不具有可写代码段的注入(无法伪造签名),签名不同的重用。
  • 不能防止:具有可写代码段的程序,可伪造签名注入。签名相同的重用。
  • 开销:需要预虚函数签名
方案二:VTable pointer sanitization
  • 原理:修改虚表指针的用法,不让虚表指针直接指向虚表,让其作为索引,类似内存管理中,用逻辑地址查找物理地址的方法,找到真实的虚表。

  • 插桩处:虚函数调用处,链接时添加的一个寻址函数

image

  • 可以防止:具有可写代码段的程序,伪造签名注入
  • 不能防止:猜到索引地址下的任意重用
  • 开销:多了几次访存
结合

故方案一二结合:

  • 可以防止:注入,签名不同的重用。
  • 不能防止:猜到索引地址下的签名相同的重用(攻击面很小)

image

OVT & IVT(NDSS’16)

Protecting C++ Dynamic Dispatch Through VTable Interleaving(NDSS’16)

  • OVT:Ordered(有序) VTables
  • IVT:Interleaved(交织) VTables

  • 原理:在编译时,让虚表根据类的继承关系,按顺序交错存放在内存中。同时在虚函数调用时,嵌入检查代码,检查虚表地址是否在源码中的预期范围内,还是不能瞎跳。(一句话,修改虚表在内存中的布局)

  • 插桩处:虚函数调用处,虚表的存储布局

image

  • 可以防止:注入,不同函数名以及非兼容类的重用
  • 不能防止:兼容类同函数名的重用
  • 开销:需要预计算类的继承关系,计算交错布局

CFIXX(NDSS’18)

CFIXX- Object Type Integrity for C++ Virtual Dispatch (NDSS’18)

  • 开源:https://github.com/HexHive/CFIXX

  • 原理:因为正常的虚表指针在对象的初始化和销毁过程中是不会变的,所以在构造函数中,把虚表指针换到安全区域中存储。
    1. 构造函数把虚表指针换到安全内存里
    2. 根据对象地址生成安全内存的索引
    3. 调用虚函数时用安全内存的虚表指针
    4. 保证安全内存不被找到
  • 插桩处:构造函数,虚函数调用处

image

  • 可以防止:注入,重用
  • 不能防止:无
  • 开销:无预计算,运行时构造函数和虚函数调用多了几次防存

对比分析

防护结果
  SAFEDISPATCH VTrust OVT & IVT CFIXX
vtable注入
vtable重用 部分 部分 部分
防护位置
  SAFEDISPATCH VTrust OVT & IVT CFIX
防止控制数据损坏    
防止控制流劫持  

防止控制数据损坏:即防止虚表的寻址被破坏,保护虚表指针或者更改虚表的寻址模式

防止控制流劫持:本质类似CFI,在调用虚函数之前插入运行时检查。这些检查中的大多数试图验证在对类型A的对象执行虚函数时,所使用的虚函数是A或A的子类的虚方法。

插桩位置
  SAFEDISPATCH VTrust OVT & IVT CFIXX
构造函数      
虚函数调用处
虚函数前加签名      
虚表寻址方式改变      
虚表存储方式改变      
虚表指针的存储方式改变      

二进制级别插桩防御

二进制层面没有源码信息,在COOP的文章中他们认为,没有源码的情况下保护C++虚函数不被攻击是一件很难的事情,毕竟在二进制层面很多的高层语言的信息已经被丢掉了。但是真的就没有什么办法么?

VTint(NDSS’15)

VTint- Protecting Virtual Function Tables’ Integrity(NDSS’15)

vtable注入需要构造一个虚表,那么如果虚表指针指向一个可写的区域,那么一定是被攻击了。vtable重用可以通过区分数据和vtable(自己构造的)进行部分防护,这时COOP还没提出。

  • 原理:在二进制层面,重构可执行程序,把虚表移动到一个自定义的只读段并分配ID,在虚函数调用时检查虚表指针是否指向我们定义的只读段以及ID是否正确。
  • 实现:

image

  1. 解析二进制文件,识别基本信息
  2. 恢复高级信息,如识别构造函数、vtable信息、虚函数调用点
  3. 重写二机制文件,将识别出的vtable拷贝到新的只读内存页,在虚函数调用前添加检查机制
  • 二进制代码修改处:虚函数调用处插入检查代码,虚表的存储位置需要修改

image

  • 可以防止:注入,部分重用(将虚表劫持到只读数据区)
  • 不能防止:重用
  • 开销:分析二进制文件恢复高级信息的前序工作,多了几次访存

VTPin(ACSAC’15)

VTPin- Practical VTable Hijacking Protection for Binaries(ACSAC’15)

这个和其他的不太一样,这个是专门防止通过UAF漏洞下利用C++虚表劫持程序控制流,前文提到,大部分的虚表劫持利用了前提都是通过UAF,不过没太看明白,可能理解不对,不太确定是不是这样保护的虚表指针不被写的

  • 原理:在free对象时搞事,保证虚表指针对应的内存不会被攻击者再次修改。方法可能是:不把虚表指针那8个字节free了,攻击者就没法重新分配到虚表指针对对应的内存单元

image

  • 实现:
  1. free时首先根据RTTI以及虚表指针指向的内存是否只读相关信息,确定是不是存在虚函数调用的对象

  2. 如果不是,正常不把虚表指针那8个字节free了
  3. 如果是,则不把虚表指针那8个字节free了,并且把这8个字节换成指向VTPin自己的函数表的地址
  4. 攻击者试图重新分配到这块内存,但是只能从虚表指针往下开始控制
  5. 如果攻击者试图利用一个悬空指针去调用,则会触发VTPin的函数,便可以捕获此次攻击
  • 二进制代码修改处:Hook free函数
  • 可以防止:所有UAF下的对虚表的攻击
  • 不能防止:非UAF攻击,比如直接溢出
  • 开销:free后的重新分配操作,垃圾回收

vfGuard(NDSS’15)

vfGuard- Strict Protection for Virtual Function Calls in COTS C++ Binaries(NDSS15)

  • 原理:二进制上恢复类型信息,利用类型信息和一些调用约定等信息(非常数学),构造的CFI策略
  • 实现:

image

  • 二进制代码修改处:虚函数调用处
  • 可以防止:注入
  • 不能防止:重用
  • 开销:分析二进制文件恢复高级信息的前序工作

VCI(CCS’17)

Strict Virtual Call Integrity Checking for C++ Binaries(CCS’17)

  • 原理:二进制上恢复类型信息,并且利用过程间数据流分析推断虚函数调用时的情况,构造严格的CFI策略
  • 实现:

image

VCI的输入是一个二进制文件(可执行文件或库),输出是一个二进制文件,该文件通过完整性检查和VCI完整性实施库(libvci)进行了改进。

文中提及这里可以使用类似VTrust的方案一中的方法防止COOP:即只能跳转到参数个数相同的虚函数上,并且在类的继承关系下,这些功能往往是类似的。所以即使发动攻击,从一个正常的虚函数,可以在这种VCI的保护方案下劫持到另一个虚函数,但是这俩函数是类似的,所以不太可能触发对攻击者有用的代码。

  • 二进制代码修改处:虚函数调用处
  • 可以防止:注入,部分重用(包括COOP)
  • 不能防止:重用
  • 开销:分析二进制文件恢复高级信息的前序工作

VPS(ACSAC ‘19)

VPS: Excavating High-Level C++ Constructs from Low-Level Binaries to Protect Dynamic Dispatching(ACSAC ‘19)

  • 原理:CFIXX的二进制版,区别主要是在二进制上找构造函数和虚函数调用要困难一些
  • 实现:

image

  1. 通过控制流图,数据流图,符号执行各种手段在二进制中找到构造函数和虚函数调用点
  2. 在构造函数中插桩代码,把虚表指针存储到一个安全内存中,与CFIXX相同,利用对象的内存地址在安全内存中进行索引,不过文章中并没提到具体算法
  3. 虚函数调用时从安全内存中取出对应的虚函数地址,跳过去执行
  • 二进制代码修改处:构造函数以及析构函数,虚函数调用处
  • 可以防止:注入,重用
  • 不能防止:无
  • 开销:分析二进制文件恢复高级信息的前序工作

对比分析

这几种二进制级别的插桩防御对比如下:

防护结果
  VTint VTPin vfuard VCI VPS
vtable注入 UAF下
vtable重用 部分 UAF下 部分 部分
防护位置
  VTint VTPin vfuard VCI VPS
防止控制数据损坏      
防止控制流劫持    

防止控制数据损坏:即防止虚表的寻址被破坏,保护虚表指针或者更改虚表的寻址模式

防止控制流劫持:本质类似CFI,在调用虚函数之前插入运行时检查。这些检查中的大多数试图验证在对类型A的对象执行虚函数时,所使用的虚函数是A或A的子类的虚方法。

插桩位置
  VTint VTPin vfuard VCI VPS
构造函数      
虚函数调用处    
虚表存储方式改变        
虚表指针的存储方式改变        
Hook free函数        

总结

一个攻防时间表:

  SAFEDISPATCH VTint VTPin vfuard COOP VTrust OVT&IVT VCI CFIXX VPS
时间 NDSS’14 NDSS’15 ACSAC’15 NDSS’15 S&P’15 NDSS’16 NDSS’16 CCS’17 NDSS’18 ACSAC’19
层次 源码 二进制 二进制 二进制 出现 源码 源码 二进制 源码 二进制
vtable注入 UAF下 COOP
vtable重用 部分 部分 UAF下 部分 攻击 部分 部分 部分
防止COOP UAF下   部分 部分 部分

对于虚函数的利用,主要还是因为call指令后面是一个间接的地址。以上的方法大部分思路还是CFI,不能瞎跳。近期出现了类似影子栈的实现方法来保护虚函数表指针。

image

CFI机制可以有效缓解控制流劫持类型的漏洞利用技术。但是,如果漏洞利用过程中不依赖于控制流劫持即可获取任意内存读写能力,CFI机制也无法保证内存数据的完整性和机密性。例如,2014年爆发的Heartbleed漏洞是由OpenSSL库心跳协议解析过程中的内存越界读引起的;攻击者可以利用该漏洞读越界读取服务器内存中的敏感信息。面对这种“简单粗暴”的越界读,CFI也无能为力。现阶段基于软件的CFI实现,很难对函数返回地址有效验证;基于硬件的CFI实现,依赖于新型硬件支持,普及范围有限。此外,实现覆盖操作系统内核、系统服务和用户态应用的全栈CFI尚需时间,攻击者可以对尚未应用CFI或CFI实现不完备的模块攻击——王铁磊@盘古实验室《从研究者视角看漏洞研究之2010年代》

以上的文章中假设的攻击者模型基本都是一个具有任意内存读写的攻击者,想要通过控制虚函数劫持控制流,然后我们使用了一系列的防护手段阻止了攻击者。相当于阻止了一种漏洞的利用方法,给攻击者基本堵上了一条路。