如果应用程序将用户可控制的数据复制到一个不足以容纳它们的内存缓存区中,就会出现缓冲区溢出漏洞。由于目标缓冲区溢出,导致邻近的内存被用户数据重写。攻击者可以根据漏洞的特点利用它在服务器上运行任意代码或执行其他未授权操作。多年来,缓冲区溢出漏洞一直在本地软件中普遍存在,并被视为本地软件开发者必须避免的“头号公敌”。
如果应用程序在未确定大小固定的缓冲区容量足够大之前,就使用一个无限制的复制操作(如C语言中的strcpy)将一个大小可变的缓冲区复制到另一个大小固定的缓冲区中,往往就会造成缓冲区溢出。例如,下面的函数将字符串username复制到一个分配到栈上的大小固定的缓冲区中:
如果字符串username超过32个字符,_username缓冲区就会溢出,攻击者将重写邻近内存中的数据。
在成功利用栈缓冲区溢出漏洞的攻击中,攻击者通常能够重写栈上已保存的返回地址。当调用CheckLogin函数时,处理器将调用函数后执行的指令地址写入栈。结束CheckLogin函数后,处理器从栈中取出这个地址,返回执行这个指令。同时,CheckLogin函数分配到栈上已保存的返回地址旁边的_username缓冲区。如果攻击者能够令_username缓冲区溢出,他就能用他选择的一个值重写缓冲区已保存的返回地址,让处理器访问这个地址,从而执行任意代码。
从本质上讲,堆缓冲区溢出也是由前面描述的相同危险操作造成的,唯一的不同在于这时溢出的目标缓冲区分配在堆上,而不是在栈上:
通常,在堆缓冲区溢出中,目标缓冲区旁不是已保存的返回地址,而是其他以堆控制结构分隔的堆内存块。堆以一个双向链接表的形式执行:在内存中,每个块的前面是一个控制结构,其中包含块的大小、一个指向堆上前一个块的指针以及一个指向堆上后一个块的指针。当堆缓冲区溢出时,邻近的堆块的控制结构被用户控制的数据重写。
与栈溢出漏洞相比,利用这种漏洞实施攻击要更困难一些,但是,一种常见的利用方法是在被重写的堆控制结构中写入专门设计的值,以在将来某个时间重写任何一个关键的指针。控制结构已被重写的堆块从内存中释放后,堆管理器需要更新堆块的链接表。要完成这项任务,它需要更新后一个堆块的反向链接指针,并更新前一个堆块的正向链接指针,以便链接表中的这两个指针指向彼此。为此,堆管理器使用被重写的控制结构中的值。具体来说,为更新后一个块的反向链接指针,堆管理器废弃被重写的控制结构中的正向链接指针,并在这个地址的结构中写入被重写的控制结构中的反向链接指针的值。换句话说,它在一个用户控制的地址中写入一个用户控制的值。如果攻击者精心设计了他的溢出数据,他就能用他选择的值重写内存中的任何指针,其目的是控制指针的执行路径,从而执行任意代码。通常,指针重写的主要目标是随后被应用程序调用的函数指针的值,或者是在下次出现异常时被调用的异常处理器的地址。
注解
最新的编译器与操作系统已经采取了各种措施对软件进行保护,防止编程错误导致缓冲区溢出。这表示,如今现实世界中的溢出漏洞往往比这里描述的示例更难以利用。要想了解更多有关这些漏洞的防御措施及避开它们的方法,请参阅The Shellcoder's Handbook
一书。
如果编程错误使得攻击者可以在一个被分配的缓冲区之后写入一个字节(或少数几字节),就会发生一种特殊的溢出漏洞。
以下面的代码为例,它在栈上分配一个缓冲区,执行一项计数缓冲区复制操作,然后以空字节结束目标字符串:
这段代码复制32 B,然后增加空终止符。因此,如果用户名为32 B或更长,空字节就会写在缓冲区之外,“污染”邻近的内存。这种条件可被攻击者加以利用:如果栈上邻近的数据是调用帧(calling frame)的已保存的帧指针(saved frame pointer),那么将低位字节设为零可能会导致它指向_username缓冲区,因而指向攻击者控制的数据。当调用的函数返回时,攻击者就可以控 制执行流程。
如果开发者忽略在字符串缓冲区中为一个空字节终止符预留空间,这时也会出现一种与上面的漏洞类似的漏洞。下面以前面堆溢出漏洞的“修复”代码为例:
在这段代码中,程序员在堆上建立一个固定大小的缓冲区,然后执行一个计数缓冲区复制操作,旨在确保缓冲区不会溢出。然而,如果用户名比缓冲区更长,那么缓冲区内就会完全填充用户名中的字符,再没有空间在最后附加一个空字节。因此,复制到缓冲区中的字符串就会“丢失”它的空终止符。
一些语言(如C)并不单独记录一个字符串的长度,字符串结束部分用一个空字节表示(也就是说,用零的ASCII字符编码表示)。如果一个字符串“丢失”了它的空终止符,它的长度就会增加,并一直到内存的下一个字节(它碰巧为零)结束。这种无意的结果经常会在应用程序中造成反常行为与漏洞。
我们曾在一个硬件设备的Web应用程序中发现这种漏洞。该应用程序包含一个页面,它接受POST请求的任意参数,并返回HTML表单,其中以隐藏字段的形式包含那些参数的名称与参数值。例如:
因为某种原因,整个应用程序都需要使用这个页面处理各种用户输入,其中许多为敏感数据。然而,如果用户提交的数据等于或超过4096 B,那么返回的表单中还包括在向页面提出的前一个请求中提交的参数,即使这些参数由另外一名用户提交。例如:
确定这种漏洞后,我们就可以继续向这个易受攻击的页面提交超长的数据,解析收到的响应,记录其他用户提交给页面的每一个数据,包括登录证书和其他敏感信息。
造成这种漏洞的根本原因是,在4096 B的内存块中,用户提交的数据被保存为以空字节终止的字符串。这些数据被复制到一个检验操作中,因此不会直接造成溢出。然而,如果提交的是超长的输入,复制操作就会导致空终止符“丢失”,因而字符串会“流入”到内存邻近的数据中。因此,当应用程序解析请求参数时,它会一直解析到下一个空字节为止,因此就会解析出其他用户提交的参数。
向一个确定的目标发送较长的字符串并监控反常结果是查找缓冲区溢出漏洞的基本方法。有些时候,一些细微的漏洞只有通过发送一个特殊长度或者在较小的长度范围内的超长字符串才能检测出来。但是,许多时候,只需向应用程序发送一个超出其预计长度的字符串,就可以探查出漏洞。
程序员常常使用十进制或十六进制的约整数(如32、100、1024、4096等)来创建固定大小的缓冲区。在应用程序中探查明显漏洞的一个简单方法就是,向确定的每一个目标数据发送超长字符串,然后监控服务器对反常输入的响应。