内存战争 (2013)
20 Sep 2014 | categories notes常见的内存保护措施有Stack cookies、异常处理验证(exception handler validation),数据执行保护(Data Execution Prevention)和地址空间布局随机化(Address Space Layout Randomization)。但这些保护措施对于面向返回值的编程(Return-Oriented Programming)、信息泄漏、用户脚本和即时编译(Just-in-time compilation)无效。
大多数学术界提出的保护措施没有得到工业界的应用的原因有两方面:性能损失和(与现有系统的)兼容性。
攻击流程
通常基于内存的攻击可以分为6个阶段/步骤:
- 使指针无效。包括指针越界和指针悬空。
- 然后引用该指针,以触发错误。可分为读引用和写引用。
- 修改/利用(新的)对象(指针,数据)。修改对象可以是数据指针、代码、代码指针、数据变量,以及利用对象的办法主要是输出数据变量。
- 修改对象(指针、数据)偏离源程序的流程。包括指向到指定攻击代码(代码破坏攻击,Code corruption acttack)、指向shellcode/gadget的地址、修改变量值为特定值和翻译输出数据(信息泄漏,Information leak)。
- 使用修改后的对象。包括引用指针间接跳转/调用、引用指针作为返回值,和使用修改后的数据变量(数据攻击,Data-only attack)。
- 执行恶意代码。包括执行可用gadget/函数和执行注入的shellcode(均属于控制流劫持攻击,Control-flow hijack attack)。
加黑名词为具体的4类攻击方式,这些攻击方式有的需要全部的6个步骤才可以实现,有的则不是,最早的在第4阶段就可以实现。我们将前三步合起来称为内存破坏(Memory corruption),那么,从步骤4开始的4类攻击方式的根源均在于此。
针对不同的攻击方式以及不同的攻击阶段,存在相对应的保护策略:
- 内存安全(Memory Safety)策略
- 代码完整性(code integrity)策略
- 代码指针完整性(Code Pointer Integrity)策略
- 控制流完整性(Control-flow Integrity)策略
- 数据完整性(Data Integriry)
- 数据流完整性(Data-flow Integriry)
Step 1: 使指针无效(非法?)
一般说来,使指针无效分为指针越界(空间破坏,spatial error)和对象释放后引用(悬空指针,时间破坏,temporal error)两种。
- 空间上使内存无效的方法即利用编程bug使指针越界。如:
- 分配失败导致的空指针
- 缓冲区(上下)溢出
- 索引越界(可能存在整数溢出的索引。)
- 时间上对内存的破坏,即在释放对象后,不重新初始化指向对象的指针就引用之,通常被称为use-after-free漏洞。大部分此类破坏针对堆上的对象,但也存在指向局部变量的指针在被赋给全局指针后逃逸出局部作用域的情况。这些逃逸的指针在函数返回或栈上的局部变量值被删掉后就会变成悬空指针。
Step 2: 引用指针,触发错误
一般分为两类引用,使用指针进行读操作,和使用指针进行写操作(包括free
)
Step 3: 利用错误,进行破坏
对于空间破坏
-
读取这些无效指针,如越界读取函数指针数组,就可以转移程序的控制流;而读取此类指针还可以泄漏信息:
printf (user_input);
可以通过在字符串中嵌入无效指针,如"%3$x"
,即可以读出栈中第三个整数的值。 -
如果攻击者用这些无效指针写入内存,则无论变量或代码都有可能被覆盖,如缓冲区溢出和索引bug常见的覆盖函数返回值地址或virtual table指针。同样,将敏感信息写入无效指针并在输出函数中引用之,也可以造成信息泄漏。
对于时间破坏
和空间破坏类似,只是需要被释放的对象被新的对象重用。当新老对象类型不同时,攻击者就可能访问额外的内存。
- 对于读引用,悬空指针可能指向被攻击者控制的新对象。如果老对象中有虚函数,那么在调用它时就会将新对象中对应位置的内容翻译成老对象的vtable指针,从而破坏vtable指针。同样,新对象可能包含敏感信息,读引用会泄漏之。
- 对于写引用,悬空指针可能是某个局部变量的逃逸指针,并指向栈。这样它就可以覆盖敏感数据,如返回值。Double free是use-after-free的一种特例,这种情况下,被攻击者控制的新对象会被翻译成堆上的元数据,从而存在被任意写入的危险。
内存错误使得攻击者可以读/修改程序的内部状态,此外,它还存在级联效应,一处错误可以导致更多的错误。这些错误可以看成对内存安全(Memory Safety)策略的违反。找出所有的这类bug是不可能的,所以需要能实施内存安全策略的自动化方案。
###代码破坏攻击(Code corruption attack)
最简单的修改程序执行的方式就是覆盖内存中的程序代码。代码完整性(Code Integrity)策略用于保护程序代码不被重写。这一点只需将所有包含代码的内存页置为只读即可。通常cpu支持这一点,但对于自修改代码或JIT编译,则有一段时间内生成的代码是在可写页上的。
###控制流劫持攻击(Control-flow hijack attack)
如果代码段不能修改,那么就想办法破坏代码指针,修改控制流。相应的保护策略叫代码指针完整性(Code Pointer Integrity)。
假设可以通过缓冲区溢出破坏返回地址,那么攻击者需要指定修改后的返回地址,如payload的地址,如果这个地址攻击者无法确定,攻击就无法成功了。ASLR(Address Space Layout Randomization)技术可以提供这样的保护。
假设破坏的是函数指针,那么攻击者下一步需要将破坏后的函数指针载入指令指针(IP)。而指令指针不能直接修改,所以只能通过执行间接控制流转移指令,如间接函数调用、间接jmp
或ret
。这种行为是对控制流完整性(Control-flow Integrity)策略的违反。
控制流劫持的最后一步是执行payload。可以通过不可写数据(Non-executable Data)策略保护栈、堆之类的内存页不可执行,而它和代码完整性结合则称为W^X(Write XOR Execute)策略。该策略由于有硬件支持所以很容易实现,但对于JIT或自修改代码则不好用。ISR(Instruction Set Randomization)技术通过加密代码阻止执行,但非常慢。
为了绕开不可执行数据(段)的保护,攻击者采取了重用现有代码的办法,重用的代码可以是现有函数(return-to-libc攻击)或将散落在原有代码中的指令序列(gadgets)串接起来实施的恶意操作。这叫面向返回的编程(Return Oriented Programming),因为攻击者利用ret
指令串联函数或gadgets。现有技术并不能很好的阻止这种重用。
###数据攻击(Data-only attack)
这种攻击方式不改变控制流也能获得权限、泄漏数据等,比如直接修改用于权限授权的变量的数值。这种攻击也叫非控制数据攻击。由于安全敏感数据只存在于语义层面,所以要保护所有数据的完整性,这种策略称为数据完整性(Data Integriry),它包括了代码完整性和代码指针完整性。
常见保护手法称为数据空间随机化(Data Space Randomization)。它将内存中数据的形式,而不是位置,进行随机化。它为每个变量生成一个key或者掩码,对其进行加密,而读/存数据的操作也进行修改,加入了加解密的操作。该方法需要在修改代码前进行静态的指针分析。他的平均负载在15%左右,但指针分析需要全局指向图,这对于模块化是个问题。
###信息泄漏(Information leak)
从上可知,任何内存错误都可以用于泄漏内存内容,信息泄漏通常用于绕开基于随机化的保护措施,如ASLR。相应的对策是全数据空间随机化。
##现在采用的保护措施和攻击手段
保护措施可分为概率的和确定性的。前者指各类随机化措施,后者指通过低层次的参考监控器实现安全策略。低层次的参考监控器可以在硬件(如W^X)或程序代码内实现,前者显然可选手段较少,后者又可分动态和静态插入代码两类。动态性能损耗较大,因为要在运行时加代码。静态插入则可以在编译时加或者利用二进制重写技术加。
现在广泛采用的保护措施主要有栈粉碎(smashing)保护、DEP/W^X和ASLR,以及保护堆元数据和异常处理的SafeSEH和SafeHOP。
栈粉碎保护、SafeSEH和SafeHOP的基本思想是在返回地址和缓冲区之间放置随机数作为哨兵(canary,金丝雀),在使用返回值前检查这个值是否被篡改。显然它只能检查溢出攻击,对直接修改则无效。
DEP/W^X可以保护代码注入攻击,但对ROP和利用libc中gadget的攻击则无效。
ASLR可以随机设置代码段数据段的地址,但它的随机化仍是可预测的,尤其是32bit机器,堆喷射(heap spraying)或JIT喷射(JIT spraying)可以通过多次填充同样的payload而使随机化失效。而即便是64bit机器,信息泄漏攻击也仍然可以奏效。
信息泄漏通常用于获得当前代码地址,构造payload需要这个地址。在远程攻击中,获得该信息尤为具有挑战性。但由于现在的浏览器、PDFreader以及办公软件允许执行用户控制的脚本,使得攻击者可以在运行时动态构造payload。
最近的攻击会综合利用信息泄漏和ROP绕过ASLR和W^X:改写函数指针或vtable,通过间接跳转劫持控制流。信息泄漏则是通过破坏指针后再读取内容的方式,如修改string对象的长度域,再读取之。一些措施(如自变换指令重定位,Self-Transforming Instruction Relocation)可以增加ROP的难度,但对return-to-libc攻击则无能为力。
内存安全(策略)
用于阻止各类内存破坏攻击。主要方法是在源代码、IR或二进制代码上插入参考监控器。
对于空间安全,开始人们对所有指针边界进行跟踪,然而这种方法需要对源代码加注,不够实用,而且对指针表现方式的修改会导致内存布局的变化,破坏了二进制的兼容性。为了解决兼容性问题,人们转而对对象边界进行跟踪。
对于时间安全,主要是为了防护use-after-free/double free的问题。主要方法可分为三类:
-
专门的分配器。主要思想是释放的内存只能被同种对象重用,并进行对齐。但显然这种方法不能保护对栈上对象的危险重用
-
基于对象的方法。利用影子内存,标记被释放的对象的位置。但如果该位置由新的指针重新指向,非法访问就无法检测到了。
-
基于指针的方法。同时维护指针的边界信息和分配信息可以实现全面的内存安全。现有的办法负载仍然较高,而且和未保护的库交互时存在同样的二进制兼容性问题
数据与数据流完整性
此项策略弱于内存安全,它们保护数据攻击,但不针对信息泄漏。数据完整性阻止数据破坏,数据流完整性则发现破坏。
数据完整性不考虑时间安全,只保护非法内存的写,而不管读操作。
针对对象的完整性保护,先用指针分析得到(可能会越界的,如偏移量是计算得到的)指针指向的对象集合,然后在代码中插入用影子内存跟踪对象的创建和释放的代码。在每次写操作解引用指针的时候对检查引用的位置是否在影子内存里。
针对指向集合的完整性保护(WIT),是对上述方法的加强:每个解引用只能写它自己指向集合的对象。由于它也记录间接调用的指针,所以也能阻止劫持攻击。但它没有考虑时间错误,通过逃逸的悬空指针重写返回地址仍然是可能的,然这样的攻击现实中很少见。
由于两者只保护写操作,所以可以将指针读入寄存器再破坏,同样信息泄漏也是可能的。
数据流完整性,先做静态的达定分析(reaching definition analysis),获得变量/指针的达定集,每个达定集一个id;运行时,在影子内存中为每个对象标记其被写指令的id,每当读取对象时,比较影子内存中的id是否在该对象达定集中。
此节两个保护措施至今仍不是二进制兼容的,当应用在未保护的环境中时,会有假警报
控制流劫持保护
分成两类:代码指针完整性和控制流完整性,前者阻止指针的破坏,后者发现之。
对于不变的指针,如全局偏移表或者虚函数表vtable,可以把他们放到只读区域以防止改写,但其他的指针,如用户定义的函数指针、返回值,是必须放在可写区域。此外,即便内存中所有代码指针的完整性都可以保证,劫持仍然是可行的,比如大部分的use-after-free通过悬空指针读入错误的vtable以劫持控制流,完全不需要改写。
控制流完整性,用于保护第5步的劫持攻击。由于直接跳转无法被劫持,所以只需保护间接控制流转移。
动态返回值的完整性:最著名的控制流劫持被称为“栈粉碎”(stack smashing),它通过缓冲区溢出改写保存在栈上的返回地址。一开始的保护方法是为返回地址加哨兵/金丝雀,它并不能保护间接跳转,也不能防止直接改写和信息泄漏。但它开销少、兼容性好,所以使用很广。
后来采用影子栈的技术。在返回值入栈时同时保存在影子栈中,这样当函数返回时,对两处保存的返回值做比较,以保证不被篡改。这种方法,即便在影子栈不受保护的时候,保护效果也不错。为了保护影子栈,可以采用守卫页或适时开关影子栈所在页的写保护的办法。可惜两种方法要么保护效果不理想,要么效率损失10倍多。
完整的控制流完整性不仅保护返回值,也防止间接跳转。这需要标记所有call/jump的目标。一些工作将标记信息(如id之类)放到专门的影子内存里,一些则直接放到代码里。这些方法都需要用指针分析得到指向集合,但精确的指针分析需要全局cfg,这对模块化和动态库重用的环境来说不太现实。所以这种方法对于单内核或hypervisor管用,对动态链接的应用则不好用。一种折衷的方式是让跳转地址一直落在所有指向集合的合集里。好处是不用指针分析了,坏处是如果lib被调换了则处理不了。