控制流劫持攻击是当前较为主流的攻击方式之一,包括ROP、JOP等等。相应的缓解、防御措施则包括数据执行保护DEP、栈保护、地址随机化ASLR、控制流完整性CFI等等。以上措施是大家比较常见和常用的,也有许多人写了大量的相关文章进行分析。现在我想给大家介绍很少有人提及的一种防御措施——代码指针完整性CPI,希望对大家有所裨益,错漏之处,还请大家不吝斧正。

CPI即code-pointer integrity,是由Volodymyr Kuznetsov(可译作库兹涅佐夫,很带感有没有,推荐大家去看一看他的视频,那英语更带感)等人于2014年提出的一种防御控制流劫持攻击的机制。核心思想是将进程占用的内存划分为安全区(safe region)和常规区(regular region),并基于硬件(也有软件方式,但不常用)对两个区域做了隔离。安全区只能存放敏感指针和元数据(metadata,用来描述敏感指针指向对象的值,地址上下界,以及分配的时序id,如下图所示),同时对安全区的访问要么是在编译时证明安全的,要么是通过运行时安全检查的。对常规区的访问没有任何特殊之处。

内存分区如下图所示:

比如以下这段代码:

在CPI机制下,对应的进程在内存中应该是这样的:

CPI假设攻击者已经有足够强大的能力,他们:

  对进程内存有完全的控制权,但不能修改代码段可以对任意地址

  进行读写不能干涉程序加载这样的假设已经大大高估了攻击者的能力,同时也    能保证代码插桩的进行和区域隔离的实施。

在具体实施时,是通过以下步骤来进行的:

  对源码进行静态分析

静态分析时将会对敏感指针进行识别。如果一个指针的类型是敏感的,那么这个指针就是敏感的。敏感类型主要包括:

  指向函数的指针

  指向敏感类型的指针

  有成员是敏感类型的复合类型(结构体或数组)

  泛型指针(void*, char*, 在定义结构体或类以前就声明的指针)

  用户自定义的敏感类型 (如存储有操作系统UID信息的结构体)

  所有在编译或运行是隐式生成的代码指针(返回地址,C++虚函数表,setjmp   缓存)

另外,所有对敏感指针进行操作的指令也需要被识别,主要包括:

  解引用

  指针运算

  分配或释放内存

由于敏感指针类型里包含char*这样的泛型指针,所以静态分析时会高估静态指针数量,为了减少开销,CPI将作为libc字符串函数参数和指向常量的char*指针认为是不敏感的。

代码插桩

代码插桩的目的在于:

  保证所有敏感指针存储在安全区

  运行时创建和传递这类指针的相关元数据

  解引用时检查元数据

插桩时将会在安全区和常规区都分配空间给敏感指针,但同时只能有⼀个是有效的。这样做能够解决内存布局改变带来的兼容性问题,同时也能避免类似void*这样的指针的敏感性发生改变带来的问题。这样的方案能够按照指针在常规区的位移来计算其在安全区的相应地址。

静态分析过程中已经找到了对敏感指针进行操作的指令,插桩将会对其进行针对性的改写,创建新的或者是直接沿用之前已有的元数据。其中,对敏感指针进行读取和存储的指令将会由CPI机制设计的指令代替以将敏感指针从安全区取出或者是存入安全区。call指令和ret指令的保护将会通过安全栈(safe stack)来进行。

敏感指针的每次解引用都要进行插桩,以便在运行时检查元数据来检测该指针是否安全。泛型指针在安全区和常规区都占有内存,若不是敏感指针,则将其元数据中的下界设置为大于上界,这样一来,访问元数据时就会认定为非法访问,从而转为访问常规区进行相应操作。

以下是插桩过程中提供的部分接口函数:

隔离安全区

实施隔离的具体措施是与系统架构相关的。主要有:

  1. x86-32架构

在此架构下,CPI依赖于硬件段保护,使得安全区只能通过特定、专用的段寄存器访问,实际上是将该寄存器当做程序装载器来使用。考虑如下代码片段:

隔离后实际指令变为:

此处使用gs寄存器来实施隔离,而syscall指令往往也要访问该寄存器,所以CPI改写指令,禁用了相关操作。

  1. x86-64架构

此架构下段保护已无法保证,但仍可以基于信息隐藏完成隔离,因为常规区中的地址不会指向安全区(否则就是敏感指针),信息也就不会泄露(作者认为这是一个事实,但有趣的是,这其实只是一个不总是成立的假设)。该架构下48bit(Linux内核将x86-64的进程地址空间定义为“48 bit – 1 protect page”)的地址空间也可以做到防止暴力破解,攻击者在试图破解之后程序可能会崩溃(这是另外一个作者认为是事实的假设)。

其他架构可以使用地址随机化或者是software fault isolation(翻译没有英文那么准确,姑且用其英文名)。

敏感指针只占很少一部分,且空间分布高度分散。为了节省内存,采用哈希表、多级查找表,或者是将地址作为下标的数组来实现。

  安全栈

为了减少开销和降低复杂度,CPI特别引入了安全栈机制。因为栈上存放的返回地址等数据需要被频繁访问。

绝大多数栈上数据的访问能在编译时就验证其安全性,无需运行时来检查,且大都是通过esp寄存器加上栈内偏移来访问的。因此,CPI把所有这些被证明是安全的对象存放到安全区的某片区域,称为安全栈。如果函数中的内存对象(全局或局部变量,动态分配的内存块等)需要检查,则在常规区给他们分配⼀块独立的栈帧。

安全栈的实施同样需要进行静态分析和插桩,静态分析得安全栈需要包含哪些对象,插桩完成相应结构的填充。另外安全栈还进行了运行时支持(runtime support),为每一个线程分配常规区上的栈,要么作为线程库的一部分,要么用来干预线程的创建和销毁。

为了更高的性能,弱化版本的CPI——Code-Pointer Separation(CPS)被提出。相比CPI,CPS对敏感指针的认定有所放松,只识别代码指针,指向代码指针的指针不再是敏感的。同时,代码指针只指向控制流目标(control flow destination)。控制流目标是代码段的某个位置,包括函数入口和返回地址等等。这样的机制可以防止伪造代码指针,但是无法阻止攻击者对代码指针的读写。同时,由于控制流目标是精确的某一个位置,无需上下界等信息,CPS的敏感指针是在编译时就能确定的,是静态的,不用再分配id(当卸载共享库时情况有所不同,需要特殊处理)所以无需引入元数据,使得安全区范围缩小了,同时对代码指针的访问次数也大大减少。

 形式化证明

为了验证该机制的正确性,作者做了形式化的证明。水平有限,我尝试为大家梳理一下。

首先,定义运行时环境为E,它是一个三元组,为以下形式:

S表示将某变量映射到其类型和地址,Mu是常规区的某一地址,存储着一个值,记作v,Ms是安全区某一地址,存储着某值

和其上下界信息v(b,e),b为下界,e为上界,或者被标记为none。

对Mu和Ms的操作有以下几类:

涵盖了读写和内存分配操作。操作的结果有以下几类:

v(b,e)和v表示安全,OK代表操作成功,OutOfMem、Abort代表错误。lu和ls是有某个左表达式产生的位置(location)。

a表示原子类型int或者是p*。

左表达式lhs包括某变量,结构体成员,指针解引用。

右表达式包括整数、函数地址,左表达式、左表达式地址、指针大小、为右表达式分配内存情况。

特别地,lhs=rhs表示变量赋值等操作。

表示左表达式分配至安全区或是常规区。

表示将右表达式分配至安全区或是常规区,可能还伴有运行时环境的转换(E和)。

表示执行命令c,得到结果r,可能伴有运行时环境的转换(E和)。

表示取某函数地址,得到其位置l,同时返回结果r。

表示分配i单位内存,得到某位置上下界l、l+i,同时返回结果r。

以上三个式子表示对敏感指针的操作(如解引用等)将会被判定,返回结果r。

 

是对读写操作进行判定。对从安全区读出的值进行检查,如果与元数据信息(上下界)相符,则可以进行后续操作,否则返回Abort(错误)。对写入安全区的数据也进行检查,通过之后返回OK。

表示任何经由常规区对安全区的访问都是非法的,返回Abort。

处理了泛型指针的敏感性会动态变化的情形。由于泛型指针在安全区和常规区均占有内存,读取时先在安全区标记为none,然后从常规区直接取值。写操作是类似的,直接写入常规区,然后将安全区标记为none,成功返回OK。

对于常规区的操作无需任何干涉,也不会产生任何问题,直接返回OK。

函数的直接调用是很简单的,如果函数指针位于安全区(ls),则返回OK,位于常规区(lu)则返回Abort。

至此,对于敏感指针操作状态的情况已经讨论完毕,其判断路径是完备的,CPI机制的正确性也就证明了。

测试

为了验证有效性,CPI基于RIPE(runtime intrusion prevention evaluator,运行时入侵防御检测,能自动生成溢出攻击代码,有兴趣的可以看一下https://github.com/johnwilander/RIPE),进行了测试,成功进行了防御。并对新出现的几种能绕过DEP、ASLR、CFI等机制的攻击也实现了防御。

CPI基于SPEC CPU2006标准,对100多个软件包进行了测试,结果如下:

统计结果如下:

可以看到,CPI,尤其是CPS的表现还是相当不错的。

同时,在WEB平台上也做了测试(FreeBSD + Apache + SQLite + mod_wsgi + Python +Django),结果如下:

至此,对 CPI的介绍就完成了。

CPI进行了形式化的证明,证明了其能百分百防御控制流劫持攻击,这一点是毋庸置疑的。但正如前文所提到的,CPI依赖于两个实际上是假设的“事实”,这就为针对它的攻击(至少是针对某一种具体实现方式的攻击)提供了可能。欲知后事如何,请听下回分解。