Return-into-libc 攻击及其防御

本文首先分析了 return-into-libc 的攻击原理,分别介绍了在不同平台进行传统 return-into-libc 攻击的实验过程和结果。然后,本文进一步引入并解释了返回导向编程的攻击方式,这种攻击可以弥补传统 return-into-libc 攻击的不足,使得攻击更灵活、更有效。最后,本文给出了针对这些攻击方法的防御手段。本文可以帮助读者了解 return-into-libc 攻击以及如何在系统中防止攻击的发生。

前言

缓冲区溢出攻击是最常见的利用程序缺陷的攻击方法,并成为了当前重要的安全威胁之一。

在各种安全报告中,缓冲区溢出漏洞始终是其中很重要的一部分。缓冲区溢出攻击很容易被攻击者利用,因为 C 和 C++等语言并没有自动检测缓冲区溢出操作,同时程序编写人员在编写代码时也很难始终检查缓冲区是否可能溢出。利用溢出,攻击者可以将期望数据写入漏洞程序内存中的任意位置,甚至包括控制程序执行流的关键数据(比如函数调用后的返回地址),从而控制程序的执行过程并实施恶意行为。

缓冲区溢出的常用攻击方法是将恶意代码 shellcode 注入到程序中,并用其地址来覆盖程序本身函数调用的返回地址,使得返回时执行此恶意代码而不是原本应该执行的代码。也就是说,这种攻击在实施时通常首先要将恶意代码注入目标漏洞程序中。但是,程序的代码段通常设置为不可写,因此攻击者需要将此攻击代码置于堆栈中。于是为了阻止此种类型的攻击,缓冲区溢出防御机制采用了非执行堆栈技术,这种技术使得堆栈上的恶意代码不可执行。而为了避开这种防御机制,缓冲区溢出又出现了新的变体 return-into-libc 攻击。return-into-libc 的攻击者并不需要栈可以执行,甚至不需要注入新的代码,就可以实现攻击。因此,如果希望编写的程序可以安全运行,就需要知道什么是 Return-into-libc 攻击,它的攻击原理以及可能的防御方式和手段。

数据执行保护策略与 return-into-libc 攻击

如前言所述,在缓冲区溢出攻击中攻击者需要将漏洞程序的控制流转移到攻击的代码。例如,攻击者可以通过溢出漏洞程序的缓冲区篡改函数的返回地址以使其指向已放置的恶意代码 shellcode,这样在函数返回时就会跳转到相应的恶意代码来执行。 也就是说,这种攻击方式需要首先将恶意代码注入(写入)目标程序中,并且在以后跳转到并执行此段代码。

针对上述这种攻击行为方式(先写后执行),研究者提出了数据执行保护策略(DEP)来帮助抵抗缓冲区溢出攻击。安全策略可以控制程序对内存的访问方式,即被保护的程序内存可以被约束为只能被写或被执行(W XOR X),而不能先写后执行。目前,这种安全策略已经在系统已经得到广泛的应用 。前言中所述的不可执行堆栈就是该策略的一个特例,即堆栈可写但不可执行。数据执行保护策略虽然对程序运行时的内存访问提供了安全保护,保证内存只能被写或者被执行而不能先写后执行。但是不幸的是,这种保护方式并不是完全有效的,其仍然不能抵御不违反 W XOR X 保护策略的攻击方式。

Return-into-libc 攻击方式就不具有同时写和执行的行为模式,因为其不需要注入新的恶意代码,取而代之的是重用漏洞程序中已有的函数完成攻击,让漏洞程序跳转到已有的代码序列(比如库函数的代码序列)。攻击者在实施攻击时仍然可以用恶意代码的地址(比如 libc 库中的 system()函数等)来覆盖程序函数调用的返回地址,并传递重新设定好的参数使其能够按攻击者的期望运行。这就是为什么攻击者会采用 return-into-libc 的方式,并使用程序提供的库函数。这种攻击方式在实现攻击的同时,也避开了数据执行保护策略中对攻击代码的注入和执行进行的防护。

Return-into-libc 攻击原理

Return-into-libc 攻击可以将漏洞函数返回到内存空间已有的动态库函数中。而为了理解 return-into-libc 攻击,这里首先给出程序函数调用过程中栈帧的结构。

图 1.函数调用时栈帧的结构

函数调用时栈帧的结构

图 1 给出了一个典型的函数调用时的栈帧结构,该栈从高位地址向低位地址增长。每当一个函数调用另一个函数向低地址方向压栈,而当函数返回时向高地址方向清栈。例如,当 main() 调用 func(arg_1,arg_2,arg_3) 时,首先将所有参数arg_1,arg_2 和 arg_3入栈。图 1 中参数从右向左依次被压入栈中,这是因为 C 语言中函数传参是从右向左压栈的。然后,call 指令会将返回地址压栈,并使执行流转到 func()。返回地址是 call 指令的下一条指令的地址,这个用于告知 func ()函数返回后从 main()函数的哪条指令开始执行。进入 func 函数后,通常需要将 main()函数的栈底指针 ebp 保存到栈中并将当前的栈顶指针 esp 保存在 ebp 中作为 func 的栈底。接下来,func 函数会在栈中为局部变量等分配空间。因此,调用函数 func()时的栈帧结构如图 1 所示。

而当 func()执行完成返回时 leave 指令将 ebp 拷贝到 esp 中清空局部变量在栈中的区域,然后从堆栈中弹出老 ebp 放回 ebp 寄存器使 ebp 恢复为 main()函数的栈底。然后 ret 指令从栈中获取返回地址,返回到 main()函数中继续执行。

攻击者可以利用栈中的内容实施 return-into-libc 攻击。这是因为攻击者能够通过缓冲区溢出改写返回地址为一个库函数的地址,并且将此库函数执行时的参数也重新写入栈中。这样当函数调用时获取的是攻击者设定好的参数值,并且结束后返回时就会返回到库函数而不是 main()。而此库函数实际上就帮助攻击者执行了其恶意行为。更复杂的攻击还可以通过 return-into-libc 的调用链(一系列库函数的连续调用)来完成。

Return-into-libc 攻击实验

x86 平台攻击实验

作者在 Ubuntu x86 系统中进行了 return-into-libc 攻击实验。实验通过使漏洞程序跳转到 libc 库函数的 system()函数并执行 system(“/bin/sh”)来实现的攻击。实验主要涉及一个漏洞程序和一个攻击程序。攻击时,攻击程序首先将溢出缓冲区的内容写入文件中,而漏洞程序则将此文件内容读入缓冲区造成其溢出。更进一步的攻击可以参见参考资源中的“return-to-libc 攻击实验”。

清单 1.漏洞程序核心内容

清单 1 是目标漏洞程序,缓冲区 buffer 在读入文件 badfile 时被溢出。攻击时,需要在 bof 的返回地址即 buf[24-27]这四个字节存入 system()函数的入口地址,接着在buf[28-31]的这四个字节放置exit函数的入口地址作为返回地址,最后在buf[32-35]这四个字节放置 system 的参数”/bin/sh”的地址。如果溢出成功,则当 bof 返回时会跳转到 system()函数并最终调用 exit 函数。

为此,需要获得system()exit()函数的入口地址,同时还需要获得system的参数"/bin/sh"的地址。

第二步:"/bin/sh"放置在环境变量BIN_SH中,并通过 getenv()函数获得其大致地址 0xbffffe1c。但实际字符串 "/bin/sh" 的地址还需要进一步确认。

最后一条命令打印出来的实际上字符串“/bin/sh”ASCII 编码,因此可以推断 “/bin/sh” 字符串在 0xbffffe23附近。在实际攻击中通过实验可以发现字符串实际位于地址 0xbffffe24

第三步:用 GDB 获取 system()exit()的入口地址。

第四步:在获得了三个地址后就可以得到清单2 中的攻击程序,并实施攻击。

清单 2.攻击程序核心内容

实施攻击

攻击实验说明 return-into-libc 攻击可以在 x86 平台中成功实施,执行了 system(“/bin/sh”)获得了root权限,那么在 x86_64 平台中呢?

x86_64 平台攻击实验

在 x86_64 平台的实验采用了与 x86 平台类似的方式。我们为假 system()函数构造了一个假的栈帧内容,并让其执行特定的命令“/bin/sh”,但攻击并没有成功。这是因为在 x86_64 的 CPU 平台中程序执行时参数不是通过栈传递的而是通过寄存器,而 return-into-libc 需要将参数通过栈来传递。因此 system()函数始终不能获得正确的参数。为了验证这一点,我们通过 gdb 跟踪进入 system()后的过程。

system()函数通过 rdi 寄存器获得参数“/bin/sh”的地址,因此在 gdb 中我们重新设定 rdi 寄存器的值为字符串地址后,攻击就可以实施了。因此,说明攻击确实是仅仅因为参数通过寄存器而非栈传递而导致了失败。虽然传统 return-into-libc 的方式未能成功,对于 x86_64 平台仍然可以进一步通过下一节中讨论的返回导向编程来实施.。

返回导向编程

前面实验中的 Return-into-libc 攻击用库函数的地址来覆盖程序函数调用的返回地址,这样在程序返回时就可以调用库函数从而使攻击得以成功实施。但是由于攻击者可用的指令序列只能为应用程序中已存在的函数,所以这种攻击方式的攻击能力有限。此外,如上一节中的讨论,攻击只能在 x86 的 CPU 平台中实施而对 x86_64 的 CPU 平台中无效。这是因为在我们实验的 x86_64CPU 平台中程序执行时参数不是通过栈传递的而是通过寄存器,而 return-into-libc 需要将参数通过栈来传递。如果 system()的参数需要通过寄存器传递%rdi 那么攻击就会失败,攻击者也不能控制攻击时的控制流。

由于这种 return-into-libc 攻击方式的局限性,返回导向编程(Return-Oriented Programming, ROP)被提出,并成为一种有效的 return-into-libc 攻击手段。返回导向编程攻击的方式不再局限于将漏洞程序的控制流跳转到库函数中,而是可以利用程序和库函数中识别并选取的一组指令序列。攻击者将这些指令序列串连起来,形成攻击所需要的 shellcode 来从事后续的攻击行为。因此这种方式仍然不需要注入新的指令到漏洞程序就可以完成任意的操作。同时,它不利用完整的库函数,因此也不依赖于函数调用时通过堆栈传递参数。

返回导向编程攻击时,攻击者首先需要选取构建 shellcode 的指令,指令可以来自于应用程序二进制代码也可以来自于链接库。这些指令串连起来就可以形成整个 shellcode 的功能。最简单来讲,选取的每个连续指令序列都以“return”指令结束,这样如果攻击者在栈中放入后一个以“return”指令结束的指令序列的首指令地址,则在前一个”return”指令执行并返回时会 pop 栈中的后一个指令序列的首指令地址,并从前一个指令序列跳转到下一个指令序列执行。以此类推,就可以串连形成一个 ROP 链完成整个攻击。

例如,在 x86_64 平台的攻击中,在向 system()函数传递参数时需要将%rdi 设定为特定的值,并”call”system 函数。这个功能可以通过构建 ROP 链来实现。“x86_64 buffer overflow exploits and the borrowed code chunks exploitation technique”中给出了一个实例, 如图 2。

图 2.ROP 链及栈内容构建

ROP 链及栈内容构建

清单 3.ROP 实例执行的指令序列

1.第 1 句汇编指令将 system()函数的地址放入 rbx 寄存器。然后返回执行第 3 句汇编指令。

2.第 3-6 句汇编指令将 rbx 寄存器内容传入 rax,即用 rax 保存 system()函数的地址。

3.最后两句汇编指令设定寄存器 rdi 的值,并调用 eax 指向的 system()函数。

从上面的例子可以看出,ROP 攻击代码的指令流在形式上具有一定的特征,即 ROP 代码中包含有大量的“return”指令。 同时,每一小段指令序列通常都比较短小,一般只包含两到三个汇编语句,它们仅仅完成整个 shellcode 的一部分工作。这些指令通过“return”指令串连起来,实现最终 shellcode 的执行。其与传统 return-into-libc 攻击不同,在传统攻击中每个指令序列实际上是整个函数,而不是 ROP 攻击中的几条汇编指令。因此 ROP 攻击在一个更低的抽象层来进行攻击,更加灵活。构建 ROP 链有很多的技巧,具体可以参见参考资源中关于返回导向编程的论文内容。

防御机制

对普通缓冲区溢出攻击的防御,一方面需要程序员使用能够防止缓冲区溢出的函数,警惕攻击的发生。另一方面,这种防御可以由系统提供。比如数据执行保护机制(DEP),该机制可以保护程序的内存使其不能同时被写和被执行,从而防止了代码注入式的缓冲区溢出攻击。但是,这些机制仍然不能有效抵御 return-into-libc 和返回导向编程这种重用已有代码的攻击,因此还需要进一步的解决方案。

目前对于 return-into-libc 和返回导向编程攻击,地址空间布局随机化(Address Space Layout Randomization,ASLR)机制是最为有效的防御机制之一。ASLR 可以实现对进程的堆、 栈、代码和共享库等的地址在程序每次运行的时候的随机化, 大大增加了定位到需要利用的代码的正确位置的难度,因此也就大大增加了 return-into-libc 和返回导向编程攻击的难度以及对攻击的防御能力。由于程序运行时的地址被随机化,在攻击时攻击者无法直接定位到所需利用的随机化后的内存地址,而只能依赖于对这些数据、代码运行时的实际地址的猜测。因此攻击者猜对的可能性比较低,很难成功发起攻击。同时,也容易导致程序运行时崩溃,因而减小了检测到攻击的难度。

PaX

Pax 是一个内核补丁,最开始其主要特征是不允许任何数据段可执行,但这对于 return-into-libc 和返回导向编程攻击这种防护是不够的。因此,为了防御此类攻击,PaX 增加了对代码和数据的内存地址进行随机化的功能。目前这些功能已经在 Linux 系统中得到了广泛应用。如果在配置内核过程中设置了 CONFIG_PAX_RANDMMAP 选项,库函数、堆栈和程序基址等都可以被映射到内存中的一个随机地址。PaX 对程序运行时进程的地址空间进行了随机化,其不用对程序本身进行改动,增强了对这种重用已有合法代码攻击方式的防御。但这种方式的不足在于,PaX 技术不能对程序内部的代码或数据在内存中的顺序等进行变化,增加攻击难度。

地址混淆

地址混淆(Address Randomization) 方法不仅可以随机化栈、堆、动态库、函数和静态数据等的内存基址,还可以实现程序数据相对地址的随机化(包括变量或函数的顺序的变化等)。它较 PaX 技术的优势在于不仅可以抵御 PaX 中利用基地址的猜测的攻击,还可以抵御利用相对地址猜测的攻击。这种技术的提出者 Bhatkar 等后来又在此基础上提出了使用源代码转换的方法随机化 C 程序。在程序每次装载和运行时,进程的虚拟内存空间都会被随机化一次。

结束语

与普通缓冲区溢出攻击相比,return-into-libc 攻击的防御难度更大。它可以避开数据执行保护策略,成为一种更有效、危险性更高的缓冲区溢出攻击。为此,读者需要理解 return-into-libc 攻击的攻击原理以及如何在系统中防止攻击的发生。目前对于 return-into-libc 攻击,地址空间布局随机化 ASLR 机制是最为有效的防御机制之一,它包括内核补丁 PaX 和地址混淆等技术。ASLR 可以对进程的堆、 栈、代码和共享库等的地址在程序每次运行的时候均随机化一次,增加了攻击者成功发起攻击的难度,同时更容易导致攻击时程序运行的崩溃,使得检测机制也更容易检测到此种攻击。本文给出的这些防御机制可以帮助读者保护程序并使其避开 return-into-libc 攻击带来的安全问题。

[via@ibm]