CFI与影子堆栈

最近读论文碰到了CFI和影子堆栈相关,记录一下,添加一些自己的感想。

参考https://blog.csdn.net/zko1021/article/details/85250383

http://readm.tech/2016/11/09/cet-shadow_stacks/

本文讨论的原理基于Control-Flow Integrity Principles, Implementations, and Applications这篇论文。

1 回顾为什么需要CFI

1.1 劫持控制流

  • 攻击者能够通过控制流劫持来获取目标机器的控制权,甚至进行提权操作,对目标机器进行全面控制。
  • 早期的攻击通常采用代码注入的方式,通过上载一段代码,将控制转向这段代码执行。
  • 代码重用攻击使得硬件支持下的DEP保护机制仍能被绕过。

1.2 早期防范措施

  • 堆栈金丝雀[Cowan et al. 1998],运行时消除缓冲区溢出[Ruwase and Lam 2004]等。
  • 局限性:缓解范围有限,性能损失高,依赖于硬件修改等。
  • What we need:高可靠性,易于理解,强制执行,可部署性,低开销。

总结下大概早起防范就是金丝雀,和NX保护,以及代码段的随机化。canary保护其实挺强的了,一般的栈溢出是可以做到防护的,但是如果存在类似于任意地址写,格式化字符串漏洞等,还是无法避免返回地址被劫持。

2 CFI概述

CFI关注的是间接指令,所以在这里对汇编语言中不同寻址方式的指令进行补充说明。

在汇编语言中,根据寻址方式的不同可以分为两种跳转指令。一种是间接跳转指令,另一种是直接跳转指令

直接跳转指令的示例如下所示:

1
CALL 0x1060000F

在程序执行到这条语句时,就会将指令寄存器的值替换为0x1060000F。这种在指令中直接给出跳转地址的寻址方式就叫做直接转移。在高级语言中, 像if-else,静态函数调用这种跳转目标往往可以确定的语句就会被转换为直接跳转指令。

间接跳转指令则是使用数据寻址方式间接的指出转移地址,如:

1
JMP EBX

执行完这条指令之后,指令寄存器的值就被替换为EBX寄存器的值。它的转换对象为作为回调参数的函数指针等动态决定目标地址的语句。

在CFI中还有一个比较特殊的分类方式,就是前向和后向转移。将控制权定向到程序中一个新位置的转移方式, 就叫做前向转移, 比如jmp和call指令;而将控制权返回到先前位置的就叫做后向转移,最常见的就是ret指令。

将以上两种分类方式结合起来,前向转移指令call和jmp根据寻址方式不同又可以分为直接jmp, 间接jmp,直接call,间接call四种。而后向转移指令ret没有操作数,它的目标地址计算是通过从栈中弹出的数来决定的。正因为ret指令的特性,引发了一系列针对返回地址的攻击

2.2 核心思想

限制程序运行中的控制转移,使之始终处于原有的控制流图所限定的范围内。
它规定软件执行必须遵循提前确定的控制流图(CFG)的路径。

通过分析程序的控制流图,获取间接转移指令(包括间接跳转、间接调用、和函数返回指令)目标的白名单,并在运行过程中,核对间接转移指令的目标是否在白名单中。

通过二进制代码重写实现:插桩—— IDs ID检查

利用二进制重写技术向软件函数入口及调用返回处分别插入标识符ID和ID_check,通过对比ID和ID_check的值是否一致判断软件的函数执行过程是否符合预期,从而判断软件是否被篡改。

2.3 示例:通过插桩执行CFI

CFI要求在程序执行期间,只要机器代码指令转移控制,只能转移到有效目标,这是由提前创建的CFG确定的。

文中提到,期望在不久的将来部署硬件CFI支持是不现实的,所以该文章仅讨论软件CFI实现(也是有局限性的,在提出和发展那篇里曾提到)。内联CFI插桩可以在当前处理器上的软件中实现,特别是在x86处理器上,只需要适度的开销。

CFI插桩根据给定的CFG修改每个源指令和计算控制流传输的每个可能的目标指令。

示例:

左侧是一个C程序片段,其中函数sort2调用sort的函数排序两次,首先使用lt,然后使用gt。它们作为指向比较函数的指针。右侧显示了这四个函数的二进制代码块的轮廓以及它们之间的所有CFG边。

  • 直接调用的边为浅色虚线箭头
  • 源指令的边为实线箭头
  • 返回边为虚线箭头

因此,CFI检测包括sort2主体中的两个ID,以及从排序返回时的ID检查,使用55(这里是随意使用55来表示)作为ID位模式。同样,因为sort可以调用lt 或者gt,两个比较函数都以ID 17开头; 并且使用寄存器R中的函数指针的调用指令对17执行ID检查。最后,ID 23在sort中标识比较调用点之后的块,因此两个比较函数都检查返回ID 23。

CFI检测不会影响直接函数调用:只有间接调用需要ID检查,并且只有间接调用的函数(例如虚方法)才需要添加ID。

函数返回多个ID检查时,必须在每个函数调用点之后插入ID,无论该函数是否间接调用。剩余的计算控制流通常是switch语句和异常的结果。在两种情况下,每个可能的目标都需要一个ID,并且在发送点需要ID检查。

2.4 CFI插桩代码

选择特定的二进制码序列实现ID和ID检查。

上图中,这里,目标已在ecx中,所以ID检查不必将其移动到寄存器(通常ID检查需要这样做来避免竞争条件)。跳转指令jmp ecx的目标可能是来自堆栈的mov(下图所示)。

在(a)中,ID作为数据插入到目标mov指令之前,并且ID检查使用lea指令修改计算的目标,以跳过四个ID字节。ID检查直接将原始目的地与ID值进行比较。ID位模式嵌入在ID-check cmp操作码字节内。 因此,在(a)中,可能以某种方式影响ecx寄存器的值的攻击者可能会导致跳转到jne指令而不是预期的目标。

(b)通过在ID检查中使用ID-1作为常量并将其递增以在运行时计算ID来避免(a)的微妙之处。 另外,替代方案(b)不修改计算的跳转目标,而是有效地在目标的开始处插入labelID:使用无副作用的x86预取指令来合成labelID指令。(其实这个b我不太懂那个inc eax这个的作用)

2.5 CFI的三个重要假设

实现CFI,三个假设成立至关重要。 这三个假设是:

UNQ. 唯一ID:在CFI检测之后,除了ID和ID检查之外,选择为ID的位模式不得出现在代码存储器中的任何位置。通过使ID足够大(例如32位,对于合理大小的软件)并且通过选择ID使得它们不与软件的其余部分中的操作码字节冲突,可以容易地实现该属性。

NWC. 不可写代码:程序必须无法在运行时修改代码内存。否则,攻击者可能能够绕过CFI,例如通过覆盖ID检查。除了在加载动态库和运行时代码生成期间,NWC在大多数当前系统中已经是正确的。

NXD. 不可执行数据:程序必须不能像执行代码那样执行数据。否则,攻击者可能会导致执行标有预期ID的数据。最新的x86处理器上的硬件支持NXD,Windows XP SP2使用此支持来强制分离代码和数据[Microsoft Corporation 2004]。 NXD也可以用软件实现[PaX Project 2004]。NXD本身(没有CFI)阻止了一些攻击,但不适于那些利用预先存在的代码的攻击,例如“jump-to-libc”攻击。

2.6 CFI执行的阶段

第一阶段,即用于CFI执行的CFG的构建,从程序分析到安全策略规范。实际实施可以使用标准控制流分析技术(例如,[Aho et al. 1985; Atkinson 2002; Wagner and Dean 2001])。

在CFI检测之后(可能在安装时),另一种机制可以建立UNQ假设。无论何时安装或修改软件,都可以更新ID以保持唯一性,就像某些操作系统中的预绑定信息一样[Apple Computer 2003]。

最后,CFI验证阶段可以静态验证直接跳转和类似指令,正确插入ID和ID检查以及UNQ属性。验证可以看作是PCC校对检查的一个特例,其中插桩不需要明确的逻辑校验。建立CFI只需要验证:设备中的设计或实施缺陷不会危及安全性。

2.7 CFI实施

Vulcan [Srivastava et al.2001]:一个成熟的、最先进的x86二进制文件检测系统,既不需要重新编译也不需要源代码访问。该系统以实用的方式解决了二进制代码重写的挑战。

使用Vulcan来构建正在检测的程序的CFG。这个CFG构造正确处理执行计算控制流传输的x86指令,包括函数返回,通过函数指针调用,以及为switch语句和动态调度发出的指令。每个计算出的调用指令可以转到任何采用其地址的函数:通过对二进制文件中的重定位条目进行流不敏感分析来发现这些函数。

3 影子栈

当启用影子堆栈时,Near CALL压入返回地址到数据堆栈和影子堆栈上;Near RET 从影子堆栈和数据堆栈弹出返回地址。 如果指定了可选的“n”操作数,则数据堆栈指针(ESP / RSP)可选地进一步增加“n”个字节,但是影子堆栈指针(SSP)不递增。如果从两个堆栈弹出的返回地址不相同,那么处理器会导致#CP(near-ret)异常。

-------------本文结束感谢您的阅读-------------
+ +