众多的安全漏洞中,栈溢出(stack-based buffer overflows)算是非常常见的了。一方面因为程序员的疏忽,使用了 strcpy、sprintf 等不安全的函数,增加了栈溢出漏洞的可能。另一方面,因为栈上保存了函数的返回地址等信息,因此如果攻击者能任意覆盖栈上的数据,通常情况下就意味着他能修改程序的执行流程,从而造成更大的破坏。

对于栈溢出漏洞,传统的攻击方式是嵌入攻击代码,然后修改栈上的返回地址,使它指向攻击代码段,从而执行攻击者指定的代码。本科时候上过一门计算机系统基础,其中的某一个lab就要求学生做这么一件事。

现在的安全技术已经能比较好的防范传统的栈溢出攻击了。常见的技术有这么几种:

随机空间,前面提到攻击者需要修改栈上返回地址,使它指向注入的代码起始地址。但如果用户栈的起始地址是随机分布的,甚至每次新建一个栈帧时的地址都有一定程度的随机波动,要获得准确的返回地址就很困难了。这个技术大大增加了代码嵌入攻击的难度,但是却没用从理论上杜绝成功的可能性。攻击者可以使用大量的空指令(nop),并在可以修改的区域重复添加攻击代码,以此增加攻击成功的几率。

W ^ X,把所有的可读可写页标记为不可执行,也就是说攻击者无法添加或修改可执行代码,这样包含了嵌入代码的页面就无法被攻击者调用执行了。Windows 的 DEP、 Linux 的 PaX 都利用了这一项技术。

此外还有一些诸如 StackGuard 等栈保护手段,不过由于它们对性能影响很大,实际中使用并不广泛。

这些手段使得在受保护的进程中利用栈溢出嵌入恶意代码并执行变得几乎不可能,然而这并不意味着栈溢出漏洞没有利用的价值了。聪明的黑客们想到了另外一种自定义程序行为的途径:利用程序或者动态库中原有的代码。这些代码虽然本身是良性的,但适当利用的话,同样可以产生恶意的效果。

最简单的手段就是著名的 return-to-libc 攻击。libc 中有一些函数可以用于执行其他的进程,例如 execve 和 system。这些函数很容易被攻击者利用,只要找到一个栈溢出漏洞,并适当的构造函数调用参数,并使栈上返回地址指向这些函数的起始地址,攻击者就能以这个程序的权限执行任意其他程序了!注意这里所有执行的代码都是合法的,所以前面提到的W^X技术对此就无能为力了。

return-to-libc 这种攻击方式也有一个局限,就是需要代码库中有 system 这样符合要求的函数,如果对于内核代码,或是检查调用来源的库,return-to-libc 就不那么给力了。于是另一种理论上更强大、也更难构造的攻击方式浮出水面,也就是标题的 return-oriented 攻击。

关于 return-oriented 攻击,我之前的一篇博文已经介绍过这个概念了,这里再解释一下。

一般程序中都包含着大量的返回指令(ret),它们通常位于一个函数的结尾,或是中途返回的地方。而这些返回指令之前的一两条指令,成为了 return-oriented 攻击指令的来源。攻击者要做的就是把这些零零碎碎的指令拼接起来,拼成一段恶意的代码。这里的难点有两个地方,一是怎么找到符合要求的代码片段,二是找到之后怎么拼接。

先来看第一个问题,可用的代码片段虽然多,但是都是固定的。这就意味着原来的一条指令现在可能需要多条指令执行后得到相同的效果了。举例来说,要把一个寄存器赋值为 4 的话,可能没有现成的直接赋值的代码片段,需要一条赋值为 1 的指令,和三条寄存器加 1 的指令拼凑而成。这样通过拼凑,受限的指令可以完成一些基本的操作,再由这些基本的操作,组成一段有实际意义的攻击代码。这里涉及到不少编译相关的知识,具体细节就不赘述了。

关于第二个问题,因为前面找到的代码片段都是以 ret 指令结尾的,所以只要把栈上的返回地址改成片段1的起始地址,代码片段1执行之后就会通过 ret 指令返回,此时读取的返回地址还是在被攻击的栈上,所以攻击者只要把对应位置的值改成代码片段2的起始地址,就能紧接着执行代码片段2了,如此循环,只要栈够大,就可以把攻击片段跑完。

对于 Linux 内核、glibc 这些庞大的程序来说,ret 指令前面一两条指令组成的代码库非常巨大,基本上可以达到图灵完备的要求了,也就是说,只要栈够大,任何程序都能由这些代码片段表达出来。另外这里为什么强调“一两条指令”呢?当然四五条甚至十几条指令的复用也是可以的,只是这样会大大增加搜索空间,要通过这些可能的代码片段生成一个程序需要太多的时间了。

那么如何防范 return-oriented 攻击呢?之后的博文里,我会介绍一些和它相关的国外研究。