攻击本地编译型应用程序

过去,在本地执行环境中运行的编译型软件一直受到缓冲区溢出与格式化字符串(format string)等漏洞的困扰。如今,绝大多数的Web应用程序都是使用在托管执行环境中运行的语言和平台编写的,这个环境中不存在上述典型漏洞。使用C#和Java这类语言的一个主要优点在于,程序员不必再担心缓冲区管理与指针算法等问题;这些问题曾给以本地语言(如C和C++)开发的软件造成重大影响,并且是这些软件中绝大多数严重漏洞的根源所在。

但是,有时也会遇到用本地代码编写的Web应用程序。而且,许多主要使用托管代码编写的应用程序同样包含本地代码或调用在非托管环境中运行的外部组件。除非渗透测试员确切地知道所针对的应用程序并不包含任何本地代码,否则就有必要对它进行一些基本的检查,查明其中是否存在任何常见的漏洞。

在打印机与交换机等硬件设备上运行的Web应用程序常常使用某种本地代码。其他可能的目标包含:任何其名称(如dll或exe)表示它使用了本地代码的页面或脚本,以及任何已知调用遗留外部组件的功能(如日志机制)。如果认为所攻击的应用程序包含大量的本地代码,那么就有必要对应用程序处理的每个用户提交的数据进行测试,包括每个参数的名称与参数值、cookie、请求消息头及其他数据。

本章主要介绍3种典型的软件漏洞:缓冲区溢出、整数漏洞和格式化字符串漏洞。对每一种情况,我们将首先描述一些常见的漏洞,然后说明在Web应用程序中探查这些漏洞所需采取的实际步骤。这个主题涉及的内容非常广,它不在本书讨论的范围之内。关于本地软件漏洞,要想了解更多详细信息及如何发现它们,我们推荐以下参考书。

The Shellcoder's Handbook, 2nd Edition, by Chris Anley, John Heasman, Felix Linder, and Gerardo Richarte (Wiley, 2007)

The Art of Software Security Assessment by Mark Dowd, John McDonald, and Justin Schuh(Addison-Wesley, 2006)

Gray Hat Hacking, 2nd Edition, by Shon Harris, Allen Harper, Chris Eagle, and Jonathan Ness(McGraw-Hill Osborne, 2008)

 注解 本章描述的漏洞远程探查可能会给应用程序带来严重的拒绝服务风险。与验证机制不完善及路径遍历等漏洞不同,仅查找各种典型的软件漏洞也可能会在目标应用程序中造成无法处理的异常,导致应用程序终止运行。如果准备在一个现有的应用程序中探查这些漏洞,在开始测试前,必须确保应用程序所有者接受测试带来的风险。

缓冲区溢出漏洞

如果应用程序将用户可控制的数据复制到一个不足以容纳它们的内存缓存区中,就会出现缓冲区溢出漏洞。由于目标缓冲区溢出,导致邻近的内存被用户数据重写。攻击者可以根据漏洞的特点利用它在服务器上运行任意代码或执行其他未授权操作。多年来,缓冲区溢出漏洞一直在本地软件中普遍存在,并被视为本地软件开发者必须避免的“头号公敌”。

栈溢出

如果应用程序在未确定大小固定的缓冲区容量足够大之前,就使用一个无限制的复制操作(如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等)来创建固定大小的缓冲区。在应用程序中探查明显漏洞的一个简单方法就是,向确定的每一个目标数据发送超长字符串,然后监控服务器对反常输入的响应。

渗透测试步骤

(1)向每一个目标数据提交一系列稍大于常用缓冲区大小的长字符串。例如:

(2)一次针对一个数据实施攻击,最大程度地覆盖应用程序中的所有代码路径。

(3)可以使用 Burp Intruder 中的字符块有效载荷来源自动生成各种大小的有效载荷。

(4)监控应用程序的响应,确定所有反常现象。无法控制的溢出几乎可以肯定会在应用程序中引起异常。在远程进程中探测何时出现这种异常相当困难,需要寻找的反常现象包括以下几项。

HTTP 500状态码或错误消息,这时其他畸形(而非超长)输入不会产生相同的结果。

内容详细的消息,表示某个本地代码组件发生故障。

服务器收到一个局部或畸形响应。

服务器的 TCP 连接未返回响应,突然关闭。

整个Web应用程序停止响应。

(5)注意,如果一个堆溢出被触发,这可能会在将来而非立即导致系统崩溃。因此,必须进行实验,确定一种或几种造成堆“腐化”的测试字符串。

(6)“一位偏移”漏洞可能不会造成系统崩溃,但可能会导致反常行为,如应用程序返回意外的数据。

有些时候,测试字符串可能会被应用程序自身或其他组件(如Web服务器)实施的输入确认检查所阻止。在URL查询字符串中提交超长数据时通常会出现这种情况,应用程序会在针对每个测试字符串的响应中以“URL过长”之类的常规消息反映这一点。在这种情况下,应当进行实验,确定URL允许的最大长度(一般约为2000个字符),并调整缓冲区大小,以使测试字符串符合这个要求。但是,即使实施了常规过滤,溢出可能依然存在;因为长度足够短、能够避开这种过滤的字符串也可能触发溢出。

其他情况下,过滤机制可能会限制在一个特定参数中提交的数据类型或字符范围。例如,当将提交的用户名传送给一个包含溢出漏洞的功能时,应用程序可能会确认该用户名是否仅包含字母数字字符。为实现测试效率最大化,渗透测试员应当设法确保每个测试字符串仅包含相关参数允许的字符。满足这种要求的一个有效方法是,截获一个包含应用程序所接受的数据的正常请求,然后使用其中已经包含的相同类型的字符,创建一个可能通过任何基于内容的过滤的长字符串,再使用这个字符串轮流测试每一个目标参数。

即使确信应用程序中存在缓冲区溢出漏洞,但是,要远程利用它执行任意代码仍然极其困难。NGSSoftware公司的Peter Winter-Smith就盲目缓冲区溢出利用的可能性进行了一些有趣的研究。欲知详情,请参阅以下内容:www.ngssoftware.com/papers/NISR.BlindExploitation.pdf。

整数漏洞

如果应用程序在执行某种缓冲区操作前对一个长度值运用某种算法,但却没有考虑到编译器与处理器整数计算方面的一些特点,往往就会出现与整数有关的漏洞。有两种类型的漏洞最值得关注:整数溢出与符号错误。

整数溢出

当对一个整数值进行操作时,如果整数大于它的最大可能值或小于它的最小可能值,就会造成整数溢出漏洞。这时,数字就会“回绕”,使一个非常大的数字变得非常小,或者与之相反。

下面以前面堆溢出漏洞的“修复”代码为例:

在这段代码中,应用程序求出用户提交的用户名的长度,增加一个长度安置字符串最后的空字节,再给它分配一个相应长度的缓冲区,然后将用户名复制到这个缓冲区内。如果使用正常长度的输入,这段代码就能够正常运行。但是,如果用户提交一个65 535个字符的用户名,就会造成整数溢出。一个长度较短的整数包含16位,它足以保存0~65 535之间的值。如果提交一个长度为65 535的字符串,程序会在这个字符串后面增加一个长度,使得这个值“回绕”而变为0。于是应用程序为它分配一个长度为0的缓冲区,把用户名复制到它里面,因而造成堆溢出。这样,即使程序员试图确保目标缓冲区足够大,攻击者仍然能够制造溢出。

符号错误

如果应用程序使用有符号和无符号的整数来表示缓冲区的长度,并且在某个地方混淆这两个整数,或者将一个有符号的值与无符号的值进行直接比较,或者向一个仅接受无符号的值的函数参数提交有符号的值,都会出现符号错误。在上述两种情况下,有符号的值都会被当做其对应的无符号的值处理,也就是说,一个负数变成一个大正数。

下面以前面栈溢出漏洞的修复“代码”为例:

在这段代码中,函数以用户提交的用户名和一个表示其长度的有符号整数为参数。程序员在栈上建立一个固定大小的缓冲区,检查用户名的长度是否小于缓冲区的大小,如果是这样,就执行计数缓冲区复制,确保缓冲区不会溢出。

如果len参数为负数,这段代码就能够正常运行。然而,如果攻击者能够向函数提交一个负值,那么程序员的保护性检查就会失效。仍然可以成功将它与32进行比较,因为编译器会把这两个数字当做有符号的整数处理。因此,这个负值被提交给strncpy函数,成为它的计数函数。因为strncpy仅接受无符号的整数为参数,所以编译器将len值隐含地转换成这种类型;因而负值被当做一个大的正数处理。如果用户提交的用户名字符串长度大于32B,那么缓冲区就会溢出,这种情况和标准栈溢出类似。

通常,实施这种攻击必须满足一个前提,即长度参数由攻击者直接控制。例如,它由客户端JavaScript计算,并在请求中将它所属的字符串一起提交。但是,如果整数变量足够小(例如,非常短),且程序在服务器端计算它的长度,那么攻击者仍然可以通过向应用程序提交一个超长的字符串,借由整数溢出引入一个负值。

查找整数漏洞

自然地,任何时候,只要客户端向服务器提交整数值,我们就可以在这些位置探查整数漏洞。通常这种行为发生在以下两种不同的情况下。

应用程序通过查询字符串参数、cookie或消息主体,以正常形式提交整数值。这些数字一般使用标准的 ASCII 字符,以十进制表示。这时,表示一个同样被提交的字符串长度的字段是我们测试的主要目标。

另外,应用程序可能提交嵌入到二进制数据巨对象中的整数值。这些数据可能源自一个客户端组件,如ActiveX控件,或者通过客户端在隐藏表单字段或cookie中传送(请参阅第5章了解相关内容)。在这种情况下,与长度有关的整数漏洞更难以发现。它们一般以十六进制的形式表示,通常出现在与其关联的字符串或缓冲区之前。请注意,上述二进制数据可能会通过Base64或类似的方案编码,以便于通过HTTP传送。

渗透测试步骤

(1)确定测试目标后,需要提交适当的有效载荷,以触发任何漏洞。轮流向每一个目标数据发送一系列不同的值,分别表示不同有符号与无符号整数值的边界情况。例如:

0x7f与0x80(127与128)

0xff与0x100(255与256)

0x7ffff与0x8000(32 767与32 768)

0xffff与0x10000(65 535与65 536)

0x7fffffff与0x80000000(2 147 483 647与2 147 483 648)

0xffffffff与0x0(4 294 967 295与0)

(2)如果被修改的数据以十六进制表示,应该发送每个测试字符串的little-endian与big-endian 版本[1],例如,ff7f及7fff。如果十六进制数字以ASCII形式提交,应该使用应用程序自身使用的字母字符,确保这些字符被正确编码。

(3)与上述查找缓冲区溢出漏洞时一样,应该监控应用程序响应中出现的反常事件。

格式化字符串漏洞

如果用户可控制的输入被当做格式化字符串参数提交给一个接受可能被滥用的格式说明符的函数(如C语言中的printf系列函数),就会产生格式化字符串漏洞。这些函数接受的参数数量不定,其中可能包含不同的数据类型,如数字和字符串。提交给函数的格式化字符串中包含的说明符告诉函数:变量参数中应包含何种数据,以及这些数据以什么格式表示。

例如,下面的代码输出一条包含以十进制表示的count变量值的消息:

最危险的格式说明符为%n。这个说明符不会导致什么数据被打印。相反,它使已经输出的字节数量被写入到以相关变量参数提交给函数的指针地址中。例如:

它输出:

如果格式化字符串中的说明符比提交给函数的变量参数多,而函数又无法探查到这一点,那么它就会继续处理调用栈中的参数。

如果攻击者能够控制提交给printf之类函数的全部或部分格式化字符串,他就可以利用上述行为重写进程内存的重要部分,并最终执行任意代码。由于攻击者控制着格式化字符串,所以他能够控制函数输出的字节数量以及栈上被输入的字节数量重写的指针。这样,攻击者就能够重写一个已保存的返回地址或者一个指向异常处理器的指针,进而控制代码执行,就像在栈溢出中一样。

查找格式化字符串漏洞

在远程应用程序中探查格式化字符串漏洞的最有效方法是,提交包含各种格式说明符的数据,并监控应用程序的任何反常行为。与不受控制地触发缓冲区溢出漏洞可能造成的后果一样,在一个易受攻击的应用程序中探查格式化字符串漏洞可能会导致系统崩溃。

渗透测试步骤

(1)轮流向每个目标参数提交包含大量格式化说明符%n与%s的字符串:

注意,基于安全考虑,一些格式化字符串操作可能会忽略%n说明符。相反,提交%s说明符将会使函数废弃栈上的每一个参数,如果应用程序易于受到攻击,就可能会导致非法访问。

(2)Windows FormatMessage函数以一种不同的方式使用printf系列函数中的说明符。为测试调用这个函数是否易于受到攻击,应该使用以下字符串:

(3)记得将%字符URL编码成%25。

(4)与上述查找缓冲区溢出漏洞时一样,应该监控应用程序响应中出现的反常事件。

小结

与针对Web应用程序的攻击相比,本地代码中的软件漏洞造成的威胁相对较小。大多数应用程序在托管执行环境下运行,本章描述的典型软件漏洞并不会发生。然而,有些时候,这类漏洞可能会频繁发生,并影响到许多在硬件设备与其他非托管环境下运行的Web应用程序。向服务器提交一组特殊的测试字符串并监控其响应,即可发现大多数软件漏洞。

本地应用程序中的一些漏洞(如本章描述的“一位偏移”漏洞)相对较易被攻击者利用。但是,许多时候,由于攻击者只能远程访问易受攻击的应用程序,利用它们就变得非常困难。

与查找大多数其他类型的Web应用程序漏洞不同,如果应用程序易受攻击,即使是在其中探查典型的软件漏洞也可能会导致拒绝服务风险。因此,在进行这种测试前,必须确保应用程序所有者接受与其相关的潜在风险。

问题

欲知问题答案,请访问http://mdsec. net/wahh。

(1)如果不采用特殊的防御措施,为什么栈缓冲区溢出比堆溢出更容易被攻击者利用?

(2)在C与C++语言中,字符串的长度如何决定?

(3)与在因特网上运行的所有权Web应用程序中存在的溢出漏洞相比,非定制网络设备中存在的缓冲区溢出漏洞为什么更可能被攻击者所利用?

(4)下面的模糊漏洞字符串为什么无法确定许多格式化字符串漏洞?

(5)假设在一个大量使用本地代码组件的Web应用程序中探查缓冲区溢出漏洞,发现了某个请求的一个参数可能存在漏洞,然而无法让监控到的反常行为再次发生。有时,提交一个长度较长的值会立即造成系统崩溃,有时则需要重复提交几次才能导致崩溃。另外,如果提交大量“良性”请求也会引起系统崩溃。

什么原因最有可能导致应用程序出现这种行为?

注释

[1] big-endian和little-endian是用来表述一组有序的字节数存放在计算机内存中时的顺序的术语。big-endian是将高位字节(序列中最重要的值)先存放在高地址处的顺序,而little-endian是将低位字节(序列中最不重要的值)先存放在低地址处的顺序。——译者注

浙ICP备11005866号-12