cgnail's weblog | A quantum of academic

Control Flow Integrity for COTS Binaries (2013)

| categories notes 
tags 漏洞检测  控制流完整性  程序修改  binary分析  UsenixSec 

原文: Control Flow Integrity for COTS Binaries

利用静态分析设计了可以用于大规模stripped、PIC二进制代码的CFI保护工具bin-CFI。不依赖编译器特性、(二进制代码中的)调试/符号/重定位信息。并提出衡量CFI效果的一个指标:AIR(average indirect target reduction)。bin-CFI的AIR非常接近最高分,能有效的抵御ROP和JOP,且负载低于10%。

反汇编

反汇编策略分为两种:线性反汇编和递归反汇编。前者从给定段的第一条指令开始顺序反汇编,直至结束;后者从二进制文件的入口(对于共享库来说,每个导出函数都是入口)并行开始顺序反汇编,如果碰到直接跳转的目标,则加入入口集合,继续反汇编,直至遇到无条件跳转停止。

线性反汇编的问题在于如果代码中存在为了对齐而加入的填充指令,则会弄错指令起始位置,甚至一错再错,级联放大。递归反汇编虽然不会在填充指令上出错,但对于仅能从间接跳转到达的代码则翻译不了。虽然重定位信息会保存间接跳转的目标,从而提高递归反汇编的能力,但是,Linux常见的stripped库中,重定位信息已经被剥掉了。

本文采用的策略是:先用线性反汇编遍历一遍,再检查填充指令导致的错误,最后改正错误。改正后,为了保证正确性,基于查错阶段得到的位置信息重新反汇编,并继续查错。如果没错,就此结束反汇编;如果有错,则继续改正,如此迭代。由于查错的正确率比较高,大部分实验都不需要迭代。

错误检查的策略基于以下策略:

  • 如果有无效指令,则一定出错
  • 如果直接跳转到模块之外,则认为出错,因为跨模块的调用都是通过GOT和PLT间接完成的
  • 如果直接跳转到指令的中间,则认为或者目标有错,或者直接跳转有错

因为翻译错误源自于错误识别了填充指令,所以错误修正从错误点开始,按以下方式寻找填充指令段的起止:

  • 向前,直至最近的无条件跳转作为起始点,如果此处仍然有错,则继续向前
  • 向后,根据静态分析结果定位结束点,特别地,以大于错误指令地址的最小间接跳转目标作为结束点。

间接控制流(Indirect Control Flow, ICF)分析

将ICF目标分为5类:

  1. 代码指针常数(CK),包括编译时计算出来的代码地址
  2. 计算得到的代码地址(CC),包括运行时计算出来的代码地址
  3. 异常处理地址(EH),包括用于处理异常的代码地址
  4. 导出符号地址(ES),从.dynamic节中的动态符号表中获得
  5. 返回地址(RA),即call指令后面那条指令的地址,反汇编时获得

对于CK,没有有效的办法可以区别代码中的指针和其他类型常数,于是采用保守的判断规则扫描整个代码段和数据段

  • 它的值必须落在当前模块的代码段内,对于共享库,指针通常是(代码段或GOT的)偏移量
  • 它必须指向代码的指令边界

对于CC,虽然理论上指针算数可能多种多样,难以界定计算的是数值还是指针,但实际代码中,考虑到代码的可维护性/移植性等问题,指针算数出现的情况很少,作者只观察到一种上下文中会出现指针算数:跳转表(jump tables)

跳转表常见于switch命令,但为了兼顾某些手写的汇编代码,采用如下规则计算CC的目标。

  • 跳转目标是过程内的
  • 目标地址的计算只涉及简单的操作,如加法和乘法
  • 除了一个数被用作索引,计算式中剩下的数都是常数
  • 所有的计算都集中在某个指令窗口内,比如50条指令

计算CC首先需要识别函数边界、构造控制流图。然后识别间接跳转指令。最后穷举索引变量的值域,计算每个跳转目标,检查是否落在当前区域内。

  • cfg的构造需要全部符号表,单个库不可能提供这些,于是默认两个相邻已知符号之间的未知代码为另一个函数
  • 代码中计算跳转目标通常为*(CE1 + Idx) + CE2的形式,其中CE1/2应该是常数表达式
  • 先检查*(CE1 + Idx)在不在代码段,再检查*(CE1 + Idx) + CE2是不是也在,有一个不符合,Idx就认为不合格。Idx的区间某些时候可以用静态分析获得,但如果从别的模块传入,则只能估计其取值范围。

对于EH,一般由基地址+偏移量的方式构造,二者分别在.eh_frame.gcc_except_table段中,以DWARF格式保存。利用katana工具解析之,并计算EH。

CFI的定义和评估

提出了AIR(Average Indirect Target Reduction)作为CFI效果好坏的标准。标准计算间接跳转的跳转目标在实施CFI之后缩小的百分比的平均值。

文章认为,CFI依据其对跳转目标的取舍可分为三类:

  1. reloc-CFI。要求间接调用(IC)、间接跳转(IJ)指向函数的地址,返回指令确实返回调用指令后面的那条指令。这种方法没法进一步区分跳转目标是从属于哪一个IC或IJ的。
  2. strict-CFI。它的ICF目标是通过上述的分析计算出来的,此外,它还考虑了EH的目标。
  3. bin-CFI,也就是本文的方法。在2的基础上引入PLT的考虑。它比2要弱,因为它允许返回指令的目标可以是RA/EH,也可以是CK/CC,PLT的目标可以是ES,也可以是CK/CC。

实现

objdump的基础上做汇编的纠错。在汇编上加入CFI保护代码后,将汇编代码转成对象文件。然后将对象文件中的代码抽取出来,利用objcopy把这些代码注入原始的ELF文件。

需要修改的文件包括:各个ELF文件、链接器ld.so(修改了300行代码)、系统调用sigactionsignal

插入的代码作为单独的代码段,于是函数指针值会发生改变。采用动态二进制翻译的技术,构造保存新老地址的表,在运行时,利用专门的翻译函数addr_trans进行翻译,如下所示。

060c0:    call  *%ecx           L_060c0: push $060c2 
o60c2:    ……              =>             movl %eax, %gs:0x44
                                         movl %ecx, %eax
                                         jmp addr_trans
                                L_060c2: ……         

即,将调用ICF目标的call修改为push+jmp,目标地址交给addr_trans翻译后再使用。(实际工作中,为了提高cpu在执行ret时对分支的命中率,这部分插入代码会用一个call封装起来。)插入代码中的标号对应它在原始代码中的地址。新老地址表中的老地址,必须在静态分析得到的集合里出现。这个表称做模块翻译表(module translation table,MTT)。

翻译函数先检查地址的范围,然后做地址转换。 地址转换依赖于上面说的新老地址表。表的形式为 <origin_addr, L_origin_addr>,除此之外,还在表项里保存了跳转控制流到L_origin_addr的代码。对于外部符号,需要减去基地址再保存到表里,然后在链接的时候再补上正确的基地址。也就是说需要修改链接器。

文章修改了所有的共享库,包括glibcld.soaddr_trans在处理跨模块的跳转(外部符号)时分两步走。

  1. 先构造全局翻译表(global translation table,GTT),将ICF目标映射到目标模块的地址翻译例程上。由于共享库在内存中是4K对齐的,所以GTT用一个2^20=1M大小的数组既可以表示。
  2. 在目标模块中查找MTT找到目标。

GTT需要在库被调用时更新,完成这件工作最好的位置是连接器ld.so。此外,还修改了连接器的部分代码使得能正确识别被修改的库的入口,以及修改了对return的延迟绑定,用间接跳转取代ret,并对跳转目标根据上面的策略加以限制,从而保证bin-CFI方案的准确度。

对系统调用sigactionsignal的修改是由于EH作为控制流跳转,其跳转是由内核完成的,而内核没有文章的地址翻译例程。修改包括:保存信号处理函数的地址、修改信号处理函数的参数使得它跳转到一个封装函数,封装函数负责调用用户制定的新的信号处理函数。

实验

benchmark采用了SPEC CPU2006,以及coreutils、binutils等常见工具(包括共享库)。总共修改了786个共享库,修改的代码超过300MB。先实验反汇编的适用性,再用AIR检查bin-CFI的效果。结果显示,运行时的负载不超过10%,修改后的文件大约增加40%的大小。

对CFI保护效果的实验采用了RIPE1测试集,这是个针对return-to-libc、ROP和代码注入的测试集。实验显示,Ubuntu 11.10的DEP可以抵抗大部分的攻击,而无论DEP是否打开,CFI都能抵抗更多的攻击,但仍有一些函数指针覆盖的攻击无法抵抗。作者解释是由于测试程序众包含了攻击代码的缘故,这样CFI不会认为指向攻击代码的跳转是非法的。

对于ROP的保护的实验还采用了ROPGadget2,一个ROP gadget的生成器/编译器。实验显示经过CFI改写后,工具只能发现有限的几种gadget,而且无法利用它们构建有效的攻击。

bin-CFI无法处理动态生成的代码以及混淆代码,好在系统本身这样的代码并不多。一般来说CFI对付return-to-libc的效果不好,但由于这种攻击主要用到“返回到glibc的导出函数”的技术,因此可以被bin-CFI捕获。

相关工作

ROP的攻防

ROP最先在RISC上发现,后来扩展到CISC架构,并延伸到内核空间。之后又衍生出JOP,不需要ret指令也可以复用代码。这使得依赖于ret检测的一类防护措施失效。

另一种保护方法修改了binary的生成过程,保证其中没有可利用的gadget。这显然需要源代码。第三种办法是对gadget的细粒度随机化,如指令位置随机化(Instruction Location Randomization, ILR)。但是,某些大型程序里返回地址还用在了PIC数据访问等地方,这样的随机化会带来麻烦,另外,它的空间开销比较大。

CFI

CFI的基本思想是利用静态分析得到控制流图,然后在运行时保证控制流不超出CFG。这需要匹配ICF的源和目标地址。然而,细粒度的匹配依赖于精确的指针分析,这很难实施。所以只能做如上所示的粗粒度匹配。

有些工作基于编译器,在代码的生成阶段实施CFI保护;另外一些则直接对binary进行重写。Control-flow locking实现了更细的保护,但对于间接尾调用处理得不太好。MoCFI针对移动平台,此类平台的指令集可能没有显式的返回指令。

CCFIR的工作基于支持ASLR的Windows平台,需要binary中的重定位信息。这些信息可识别代码指针,于是就不需要运行时地址翻译了。但在Unix平台,则很少提供这样的信息。

  1. John Wilander, Nick Nikiforakis, Yves Younan, Mariam Kamkar, and Wouter Joosen. 2011. RIPE: runtime intrusion prevention evaluator. In Proceedings of the 27th Annual Computer Security Applications Conference (ACSAC ‘11). ACM, New York, NY, USA, 41-50. 

  2. J. Salwan. ROPGadget. http://shell-storm.org/project/ROPgadget

If you liked this post, you can share it with your followers !