安全漏洞是如何造成的:缓冲区溢出

译文
安全 漏洞
自1988年莫里斯蠕虫诞生以来,缓冲区溢出漏洞就威胁着从Linux到Windows的各类系统环境。

自1988年莫里斯蠕虫诞生以来,缓冲区溢出漏洞就威胁着从Linux到Windows的各类系统环境。

缓冲区溢出漏洞长久以来一直是计算机安全领域的一大特例。事实上,世界上首个能够自我传播的互联网蠕虫——诞生于1988年的莫里斯蠕虫——就是通过Unix系统中的守护进程利用缓冲区溢出实现传播的。而在二十七年后的今天,缓冲区溢出仍然在一系列安全隐患当中扮演着关键性角色。声威显赫的Windows家族就曾在2000年初遭遇过两次基于缓冲区溢出的成规模安全侵袭。而就在今年5月,某款Linux驱动程序中遗留的潜在缓冲区溢出漏洞更是让数百万台家庭及小型办公区路由设备身陷风险之中。

[[147704]]

但颇为讽刺的是,作为一种肆虐多年的安全隐患,缓冲区溢出漏洞的核心却只是由一种实践性结果衍生出的简单bug。计算机程序会频繁使用多组读取自某个文件、网络甚至是源自键盘输入的数据。程序为这些数据分配一定量的内存块——也就是缓冲区——作为存储资源。而所谓缓冲区漏洞的产生原理就是,写入或者读取自特定缓冲区的数据总量超出了该缓冲区所能容纳量的上限。

事实上,这听起来像是一种相当愚蠢、毫无技术含量的错误。毕竟程序本身很清楚缓冲区的具体大小,因此我们似乎能够很轻松地确保程序只向缓冲区发送不超出上限的数据量。这么想确实没错,但缓冲区溢出仍在不断出现,并始终成为众多安全攻击活动的导火线。

为了了解缓冲区溢出问题的发生原因——以及为何其影响如此严重——我们需要首先谈谈程序是如何使用内存资源以及程序员是如何编写代码的。(需要注意的是,我们将以堆栈缓冲区溢出作为主要着眼对象。虽然这并不是惟一一种溢出问题,但却拥有着典型性地位以及极高的知名度。)

堆叠起来

缓冲区溢出只会给原生代码造成影响——也就是那些直接利用处理器指令集编写而成的程序,而不会影响到利用Java或者Python等中间开发机制构建的代码。不同操作系统有着自己的特殊处理方式,但目前各类常用系统平台则普遍遵循基本一致的运作模式。要了解这些攻击是如何出现的,进而着手阻止此类攻击活动,我们首先要了解内存资源的使用机制。

在这方面,最重要的核心概念就是内存地址。内存当中每个独立的字节都拥有一个与之对应的数值地址。当处理器从主内存(也就是RAM)中加载或者向其中写稿数据时,它会利用内存地址来确定读取或写入所指向的位置。系统内存并不单纯用于承载数据,它同时也被用于执行那些构建软件的可执行代码。这意味着处于运行中的程序,其每项功能都会拥有对应的地址。

在计算机制发展的早期阶段,处理器与操作系统使用的是物理内存地址:每个内存地址都会直接与RAM中的特定位置相对应。尽管目前某些现代操作系统仍然会有某些组成部分继续使用这类物理内存地址,但现在所有操作系统都会在广义层面采用另一种机制——也就是虚拟内存。

在虚拟内存机制的帮助下,内存地址与RAM中物理位置直接对应的方式被彻底打破。相反,软件与处理器会利用虚拟内存地址保证自身运转。操作系统与处理器配合起来共同维护着一套虚拟机内存地址与物理内存地址之间的映射机制。

这种虚拟化方式带来了一系列非常重要的特性。首先也是最重要的,即“受保护内存”。具体而言,每项独立进程都拥有属于自己的地址集合。对于一个32位进程而言,这部分对应地址从0开始(作为首个字节)一直到4294967295(在十六进制下表示为0xffff'ffff; 232 - 1)。而对于64位进程,其能够使用的地址则进一步增加至18446744073709551615(十六进制中的0xffff'ffff'ffff'ffff, 264 - 1)。也就是说,每个进程都拥有自己的地址0,自己的地址1、地址2并以此类推。

(在文章的后续部分,除非另行强调,否则我将主要针对32位系统进行讲解。其实32位与64位系统的工作机理是完全相同的,因此单独着眼于前者不会造成任何影响,这只是为了尽量让大家将注意力集中在单一对象身上。)

由于每个进程都拥有自己的一套地址,而这种规划就以一种非常简单的方式防止了不同进程之间相互干扰:一个进程所能使用的全部参考内存地址都将直接归属于该进程。在这种情况下,进程也能够更轻松地完成对物理内存地址的管理。值得一提的是,虽然物理内存地址几乎遵循同样的工作原理(即以0为起始字节),但实际使用中可能带来某些问题。举例来说,物理内存地址通常是非连续的;地址0x1ff8'0000被用于处理器的系统管理模式,而另有一小部分物理内存地址会作为保留而无法被普通软件所使用。除此之外,由PCIe卡提供的内存资源一般也要占用一部分地址空间。而在虚拟地址机制中,这些限制都将不复存在。

那么进程会在自己对应的地址空间中藏进什么小秘密呢?总体来讲,大致有四种觉类别,我们会着重讨论其中三种。这惟一一种不值得探讨的也就是大多数操作系统所必不可少的“操作系统内核”。出于性能方面的考量,内存地址空间通常会被拆分为两半,其中下半部分为程序所使用、上半部分由作为系统内核的专用地址空间。内核所占用的这一半内存无法访问程序那一半的内容,但内核自身却可以读取程序内存,这也正是数据向内核功能传输的实现原理。

我们首先需要关注的就是构建程序的各类可执行代码与库。主可执行代码及其全部配套库都会被载入到对应进程的地址空间当中,而且所有组成部分都拥有自己的对应内存地址。

其次就是程序用于存储自身数据的内存,这部分内存资源通常被称为heap、也就是内存堆。举例来说,内存堆可以用于存储当前正在编辑的文档、浏览的网页(包括其中的全部JavaScript对象、CSS等等)或者当前游戏的地图资源等等。

第三也是最重要的一项概念即call stack,即调用堆——也简称为栈。内存栈可以说是最复杂的相关概念了。进程中的每个分线程都拥有自己的内存栈。栈其实就是一个内存块,用于追踪某个线程当前正在运行的函数以及所有前趋函数——所谓前趋函数,是指那些当前函数需要调用的其它函数。举例来说,如果函数a调用函数b,而函数b又调用函数c,那么栈内所包含的信息则依次为a、b和c。

安全漏洞是如何造成的:缓冲区溢出

在这里我们可以看到栈的基本布局,首先是名为name的64字符缓冲区,接下来依次为帧指针以及返回地址。esp拥有此内存栈的上半部分地址,ebp则拥有内存栈的下半部分地址。

调用堆栈属于通用型“栈”数据结构的一个特殊版本。栈是一种用于存储对象且大小可变的结构。新对象能够被加入到(即’push‘)该栈的一端(一般为对应内存栈的’top‘端,即顶端),也可从栈中进行移除(即’pop’)。只有内存栈顶端的部分能够通过push或者pop进行修改,因此栈会强制执行一种排序机制:最近添加进入的项目也会被首先移除。而首个添加进入的项目则会被最后移除。

调用堆栈最为重要的任务就是存储返回地址。在大多数情况下,当一款程序调用某项函数时,该函数会按照既定设计发生作用(包括调用其它函数),并随后返回至调用它的函数处。为了能够切实返回至正确的调用函数,必须存在一套记录系统来注明进行调用的源函数:即应当在函数调用指令执行之后从指令中恢复回来。这条指令所对应的地址就被称为返回地址。栈用于维护这些返回地址,就是说每当有函数被调用时,返回地址都会被push到其内存栈当中。而在函数返回之后,对应返回地址则从内存栈中被移除,处理器随后开始在该地址上执行指令。

栈的功能非常重要,甚至可以说是整个流程的核心所在,而处理器也会以内置方式支持这些处理概念。以x86处理器为例,在x86所定义的各个寄存器当中(所谓寄存器,是指处理器内的小型存储位置,其能够直接由处理器指令进行访问),最为重要的两类就是eip(即指令指针)以及esp(即栈指针)。

esp始终容纳有栈顶端的对应地址。每一次有数据被添加到该栈中时,esp中的值都会降低。而每当有数据从栈中被移除时,esp的值则相应增加。这意味着该栈的值出现“下降”时,则代表有更多数据被添加到了该栈当中,而esp中的存储地址则会不断向下方移动。不过尽管如此,esp所使用的参考内存位置仍然被称为该内存栈的“顶端”。

eip 为现有执行指令提供内存地址,而处理器则负责维护eip本身的正常运作。处理器会从内存当中根据eip增量读取指令流,从而保证始终能够获得正确的指令地址。x86拥有一项用于函数调用的指令,名为call,另一项用于从函数处返回的指令则名为ret。

call 会获取一个操作数,也就是欲调用函数的地址(当然,我们也可以利用其它方式来获取欲调用函数的地址)。当执行call指令时,栈指针esp会通过4个字节(32位)来表现,而紧随call之后的指令地址——也就是返回地址——则会被写入至当前esp的参考内存位置。换句话说,返回地址会被添加至内存栈中。接下来,eip会将该地址指定为call的操作数,并以该地址为起始位置进行后续操作。

ret 的作用则完全相反。简单的ret指令不会获取任何操作数。处理器首先从esp当中的内存地址处读取值,而后对esp进行4字节的数值增量——这意味着其将返回地址从内存栈中移除出去。这时eip接受值设定,并以此为起始位置进行后续操作。

【视频】

在实际操作中了解call与ret。

如果调用堆栈当中只包含一组返回地址序列,那么问题当然就很简单了。但真正的难点在于,其它数据也会被添加到该内存栈当中。内存栈的自身定位就是速度快且效率高的数据存储位置。存储在内存堆上的数据相对比较复杂;程序需要全程追踪内存堆内的当前可用空间、当前所使用数据片段各自占用多大空间外加其它一系列需要关注的指标。不过内存栈本身则非常简单;要为某些数据腾出空间,只需要降低栈指针即可。而在数据不需要继续驻留在内存中时,则增加栈指针。

这种便捷性让内存栈成为一套逻辑空间,能够存储归属于函数的各类变量。每项函数拥有256字节的缓冲空间来读取用户的输入内容。简单来讲,我们只需要在栈指针中减去256这一数值就能创建出该缓冲区。而在函数执行结束时,向栈指针内添加添加256就能丢弃这个缓冲区。

安全漏洞是如何造成的:缓冲区溢出

当我们正确使用程序时,键盘输入内容会被存储至name缓冲区中,随后为null(即0)字节。帧指针与返回地址则保持不变。

但这种处理方式也存在局限。内存栈并不适合保存规模庞大的对象;内存的整体可用容量通常在线程创建之时就被确定下来了,而且通常大小为1 MB。因此,那些大型对象必须被保存在内存堆中。栈也不适合保存那些需要长久存在,甚至生命周期比单一函数调用更长的对象。由于每个分配的内存栈都会在函数执行完成后被撤销,因此任何存在于该栈中的对象将无法在函数结束后继续驻留。不过存在于内存堆中的对象则不受此类限制,它们能够独立于函数之外实现长期驻留。

内存栈存储机制并不只适用于程序员在程序中明确创建的命名变量,同时亦可用于存储其它任何程序可能需要的数值。从传统上讲,这算是x86架构的一大问题。X86处理器并不能提供太多寄存器(寄存器的总体数量只有8个,而且其中一部分,例如eip与esp,还需要留作特定用途),因此函数几乎无法在寄存器中长期保留所有数值。为了在不影响现有数值以供今后检索的同时释放寄存器空间,编译器会将寄存器中的数值添加到内存栈当中。在此之后,相关数值可以pop方式从栈内转移回寄存器。用编译器的术语来讲,这种节约寄存器空间并保证数值可重复使用的操作被称为spilling。

最后,内存栈通常被用于向函数传递参数。调用函数会将每个参数添加到内存栈中,而受调用函数之后则能够将这些参数移除出去。这并不是惟一一种参数传递方式——举例来说,也可以在寄存器内部进行参数传递——但却是最为灵活的方式。

函数在内存栈上的所有具体内容——包括其本地变量、spilling寄存器操作以及任何准备传递给其它函数的参数——被整体称为一个“栈帧”。由于栈帧中的数据会被广泛使用,因此需要一种能够实现快速引用的办法与之配合。

栈指针也能完成这项任务,但它的实现方式有些尴尬:栈指针总会指向内存栈的顶端,因此它需要在添加与移除的数据之间来回移动。举例来说,某个变量可能以esp + 4地址作为起始位置,而在有另外两个数值被添加到栈中时,就意味着该变量现在的访问位置变成了esp + 12。而一旦某个数值被移除出去,那么该变量的位置又变成了esp + 8。

这倒不是什么无法克服的障碍,编译器本身能够很轻松地加以解决。不过这仍然无法真正回避栈指针以“内存栈顶端”作为起始位置的问题,特别是在手工编码的汇编程序当中。

为了简化实现流程,最常见的办法就是使用一个次级指针——其需要始终将数据保存在每个栈帧的底部(起始)位置——我们往往将该值称为帧指针。在x86架构中,甚至还有名为ebp的专门寄存器用于存储这一值。由于这种机制不会对特定函数造成任何内部变更,因此我们可以利用它作为访问函数变量的一种固定方式:位于ebp – 4位置的值在整个函数中始终保持自己的ebp – 4位置。这种效果不仅有助于程序员理解,同时也能够显著简化调试程序的处理流程。

安全漏洞是如何造成的:缓冲区溢出

以上截图来自Visual Studio,其中显示了某简单x86程序完成上述操作的过程。在x86处理器当中,名为esp的寄存器负责容纳顶端内存栈中的地址——在本示例中为0x0018ff00,以蓝色高亮表示(在x86架构中,内存栈实际上会不断向下推进并指向地址0,但其仍然会以栈顶端为起点进行地址调用)。该函数只拥有一个栈变量,即name,以粉色高亮表示。其缓冲区大小固定为32字节。由于属于惟一一个变量,因此其位置同样为0x0018ff00,与该内存栈的顶端保持一致。

x86还拥有一个名为ebp的寄存器,以红色高亮表示,其通常专门用于保存帧指针的位置。帧指针的位置紧随栈变量之后。帧指针之后则为返回地址,以绿色高亮表示。返回地址所引用的代码片段地址为0x00401048。在这条指令之后的是call指令,很明显返回地址会从调用函数剩余的地址位置处执行恢复。

安全漏洞是如何造成的:缓冲区溢出

遗憾的是,gets()实在是个极其愚蠢的函数。如果我们按住键盘上的A键,那么该函数会不间断地一直向name缓冲区内写入“A”。在此过程中,该函数一直向内存中写入数据,覆盖帧指针、返回地址以及其它一切能够被覆盖的内容。

在以上截图当中,name属于会定期被覆盖的缓冲区类型。其大小固定为64字符。在这里的示例中,它被填写进一大堆数字,并最终以null结尾。从上图中可以清楚地看到,如果name缓冲区的写入内容超出了64字节,那么该内存栈中的其它数值也会受到影响。如果有额外的4字节内容被写入,那么该帧指针就会被破坏。而如果写入的内容为额外8个字节,那么帧指针与返回地址将双双被覆盖。

很明显,这会导致程序数据遭到破坏,但缓存区溢出还会造成其它更加严重的后果:通常会影响到代码执行。之所以会出现这种情况,是因为缓冲区溢出不仅会覆盖数据,同时也可能覆盖内存栈中的返回地址乃至其它更为重要的内容。返回地址负责控制处理器在完成当前函数之后,接下来执行哪些指令。返回地址正常来说应该处于调用函数之内的某个位置,但如果由于缓冲区溢出而被覆盖,那么返回地址的指向位置将变得随机而不可控制。如果攻击者能够利用这种缓冲区溢出手段,则能够选定处理器接下来要执行的代码位置。

在这一过程中,攻击者可能并没有什么理想的、便捷的“设备入侵”方法可供选择,但这并不会影响恶意活动的发生。用于覆盖返回地址的缓冲区同时也可以被用于保存一小段可执行代码,也就是所谓shellcode,其随后将能够下载一段恶意可执行代码、开启某个网络连接或者是实现其它一些攻击手段。

从传统角度讲,这确实是种令人有些意外的、小处引发的大问题:总体而言,每款程序在每次运行时都会使用同样的内存地址——即使在经过重启之后也不例外。这意味着内存栈上的缓冲区位置将永远不会变化,所以用于覆盖返回地区的值也可以不断重复加以使用。攻击者只需要一次性找出对应地址,就能够在任何运行着存在漏洞的代码的计算机上再度实施攻击。#p#

攻击者的工具箱

在理想状态下——当然,这是从攻击者的角度出发的——被覆盖的返回地址可以就是缓冲区的所在位置。当程序从文件或者网络处读取输入数据时,往往就会符合这一条件。

不过在其它情况下,攻击者则需要动用一点小技巧。在负责处理我们能够直接阅读的文本内容的函数中,0字节(或者称为‘null’)通常会被特殊处理;它表示一条字符串的结尾,而用于操作这些字符串的函数——包括复制、比较以及整合等——将会在接触到null字符后直接中止。这意味着如果该shellcode中包含有null字符,那么执行程度到这里一定会停止。

【视频】

查看整个缓冲区溢出过程。在这段视频中,我们将shellcode添加到了缓冲区内,而后通过执行以棋牌室返回地址。我们的shellcode运行了Windows计算器程序。

安全漏洞是如何造成的:缓冲区溢出

为了利用这种溢出手段而非单纯向内存栈中写入大量“A”以破坏一切内容,攻击者需要在缓冲区中添加shellcode:这是一小段可执行代码,其能够执行攻击者所选定的一系列操作。在此之后,返回地址会被缓冲区所引用的地址所覆盖,进而在从某函数调用返回后将处理器定向至shellcode执行位置。

为了实际这一目标,攻击者可以选择多种技术手段。代码片段可以将包含有null字符的shellcode转换为具备同等作用的形式以避免出现问题。它们甚至能够处理更为严格的限制,例如一条已被篡改的函数可能只接收能够通过标准键盘进行输入的结果。

内存栈本身的地址中通常也包含有null字节,这同样会引发问题:这意味着返回地址无法直接被设定为栈缓冲区的地址。一般来讲这倒不是什么大问题,毕竟那些可用于填写缓冲区的函数(当然,也会造成潜在的溢出隐患)会自行写入一个null字节。但在某些情况下,它们则可被用于将null字节添加到正确的位置当中,从而篡改内存栈中的返回地址。

即使无法进行返回地址篡改,这种状况也可被攻击者们用于重新定向。程序及其全部相关库的存在意味着,内存当中可以驻留可执行代码。大部分此类可执行代码都能够拥有属于自己的“安全”地址,也就是说其中不包含任何null字节。

攻击者们要做的就是找到一个包含一条指令的可用地址,例如x86架构中的call esp,其会将栈指针的值作为函数地址看待并加以执行——这显然非常适合用来承载shellcode。攻击者随后会利用callesp指令的地址来覆盖返回地址;如此一来,处理器会在该地址处进行一次额外的跳转,但最终仍会运行该shellcode。这项利用其它地址强行实现代码执行的方法被称为“trampolining”,也就是蹦床。

安全漏洞是如何造成的:缓冲区溢出

有时候我们很难利用缓冲区地址来覆盖返回地址。为了解决这个问题,我们可以利用目标程序(或者其对应库)中的特定可执行代码片段地址来覆盖返回地址。这部分代码片段能帮助我们对缓冲区位置进行转换。

之所以这种方式能够奏效,是因为正如前面提提到,程序及其配套库在每时运行时都会使用同样的内存地址——即使是多次启动甚至在不同设备之上都不会改变这一点。而非常有趣的是,用于提供“蹦床”的库本身并不需要执行call esp指令。该库只需要提供两个字节(在本次示例中为0xff与0xd4)并保证彼此相邻即可。它们可以作为其它指令中的组成部分甚至直接以数字形式存在;x86对于这类内容并不挑剔。另外,x86的指令长度可以相当之长(最高为15字节!)并指向任意地址。如果处理器从中间部分读取某条指令——例如在一条长度为4字节的指令中从第二个字节开始读取——那么最终的执行结果可能会完全不同、但却仍然切实生效。考虑到这一点,攻击者确实可以很轻松地找到可资利用的“蹦床”。

不过有时候,攻击活动无法直接将返回地址篡改为所需位置。不过由于内存布局总是非常相似,不同设备或者不同运行进程之间的设定几乎完全相同。举例来说,某个可利用的缓冲区的具体位置可能会出现变化,而存在差异的几个字节则取决于系统名称或者IP地址。另外,软件的小型更新可能也会让内存地址出现稍许变动。为了解决这一问题,攻击者只需要找到返回地址的大概正确位置即可,而不必保证其完全符合实际情况。

面对这类状况,攻击者的处理办法也很简单,这就是使用所谓“NOP sled”技术。相较于直接向缓冲区内写入shellcode,攻击者可以在真正的shellcode之前编写数量庞大的多条“NOP”指令(所谓NOP也就是’no-op‘,是指那些不会真正执行的指令),有时候可以多达数百条。要运行该shellcode,攻击者只需要将返回地址设定在这些NOP指令当中的某个位置即可。只要该地址被包含在NOP当中,处理器就会快速将其略过并直接执行真正的shellcode。

你的错、他的错——都是C的错

导致上述攻击得以实现的核心bug——具体来讲,就是向缓冲区内写入超出其容纳能力的内容——听起来可以很轻松地加以避免。将这些问题完全归咎于C编程语言及其各类兼容性分支方案——例如C++以及Objective-C——或许有些夸张,但也不能说毫无道理。C语言本身已经相当陈旧,但却应用广泛且作为我们操作系统以及各类软件的基础性元素存在。正是由于C语言的流行,才让这些本来可以轻松避免的bug长期生效并影响到无数开发者与用户。

作为C语言自身阻碍安全开发实践的一项实例,我们在这里要着重谈谈gets()。作为一项函数,gets()会获取一条参数——也就是一个缓冲区——并从标准输入内容中(通常意味着’键盘输入内容‘)读取一行数据,而后将其添加到缓冲区当中。细心的朋友可能已经注意到,gets()当中并不会对将被添加至缓冲区内的参数长度作出限制,而且作为C语言设计中的一种有趣现象,我们没办法利用gets()了解缓冲区的实际大小。这是因为gets()并不会对输入内容的大小作出任何要求:它只负责从标准输入内容中读取数据——直到电脑前的操作者按下回车——而后尝试将全部内容添加到缓冲区内,即使操作者写入的内容远远超出了缓冲区容纳能力,gets()也完全不予理会。

很明显,这项函数属于彻头彻尾的安全隐患。由于我们无法制约通过键盘输入的文本内容总量,因此也就不可能避免由gets()引发的缓冲区溢出结果。C语言标准的制定者们确实意识到了这个问题,并在1999年的再版C语言规范中对gets()加以弃用,最终在2011年的更新中将其完全移除。但它的存在——以及不时出现的实际使用——证明了C语言确实给用户们挖了一个非常危险的潜在陷阱。

而作为诞生于1988年的世界首个可通过互联网传输的自我复制恶意软件,莫里斯蠕虫利用的恰恰是这项函数。BSD 4.3 fingerd程序会通过端口79对网络连接进行监听,也就是我们常说的finger端口。事实上,finger也是一个非常古老的Unix程序,其作为网络协议存在并负责识别是谁登录到了远程系统当中。它的使用方式分为两种;其一是远程系统可以利用它来查询当前已经登录的每位用户,其二则是用于查询特定用户名并告知我们与该用户相关的部分信息。

每当有连接出现在finger的后台进程当中,它都会利用gets()从网络中读取数据并将其添加到内存栈中一个512字节的缓冲区内。在通常操作中,fingerd会随后生成finger程序,并在可能的情况下向其传递相关用户名。该finger程序才是真正负责监听用户接入或者提供与特定用户相关信息的主体,而fingerd本身仅仅负责监听网络并在需要时启动finger。

鉴于惟一的“真实”参数基本只会是用户名,因此512字节的缓冲区设定已经不算小了。应该没人会设定一个长达512位的用户名——不过系统本身并不会对此作出强制要求,因为在这里负责内容获取工作的正是臭名昭著的gets()函数。当我们通过网络发出超过512字节的用户名时,fingerd就会乖乖地造成缓冲区溢出状况。而这也正是Robert Morris的具体作法:他向fingerd发送了537字节的数据内容(其中包含537个字节外中一个换行符,这直接导致gets()停止读取输入数据),顺利实现缓冲区溢出并覆盖了返回地址。在此之后,返回地址被轻松设置为内存栈中的缓冲区地址。

莫里斯蠕虫的可执行负载非常简单。它会发起400条NOP指令,从而让内存栈布局出现轻微的变化,而后再接上一小段代码片段。这些代码会生成一条shell,即/bin/sh。这是攻击负载当中很常见的选择;fingerd程序会以root权限运行,因此在遭到攻击并被迫运行shell时,该shell也将拥有root权限。另外,fingerd会被引导至网络当中,这意味着其接收的“键盘输入内容”可以实际来源于网络传输,并将输出结果通过网络发送出去。这两大特性都明显昭示其为shell所利用的潜在可能性,也就是说这一root shell现在已经能够为攻击者所远程操控。

尽管想要绕开gets()并不困难——事实上即使是在莫里斯蠕虫刚刚诞生的时候,就出现了能够彻底禁用gets()的fingerd修复版本——但C语言的其它一些组成部分仍然难以被忽略,甚至几乎不可能被彻底修复。C语言对于文本字符的处理方式就是一种常见的问题根源。正如之前所提到,C语言在处理字符串时会在读取至null字节后中止。在C语言中,一条字符串就是一段字符序列,其末尾以null字节作为字符串中止标记。C语言当中有一系列函数负责操作这些字符串。其中最典型的例子要数strcpy()——负责从来源处将一条字符串复制至目标位置——以及strcat()——负责从来源处将一条字符串添加至目标位置——这对奇葩了。这两项函数都没有对指向目标缓冲区的参数作出长度限制,因此添加之后会不会造成缓冲区溢出根本就不在这二者的考量范围之内。

即使C语言的字符串处理函数能够对指向缓冲区的参数长度作出限制,同样的错误及溢出状况仍然得不到彻底解决。C语言分别为strcat()与strcpy()提供一对姐妹函数,分别名为strncat()与strncpy()。名称当中额外的n代表的正是其所获取参数的长度。但正如很多资深C语言程序员们所知,这个n并不是将要写入的缓冲区的具体大小;相反,它其实是来源处将要进行复制的字符数量。如果来源提供的数据量超出了对应字符限制(因为达到了null字节的位置),那么strncpy()与strncat()将会通过向目标位置复制更多null字节的方式来补足差额。换句话来说,这些函数仍然完全不关心目标缓冲区的实际大小。

与gets()不同,我们其实有能力以安全方式使用以上函数,只不过有点困难罢了。C++与Objective-C都针对C语言的函数库提供更理想的替代方案,这使得我们能够更轻松且更安全地实现字符串操作——不过由于向下兼容的考量,某些C语言中的陈旧特性仍然被继承了下来。

除此之外,二者还包含了C语言的一大根本性缺陷:缓冲区自身并不了解自己的确切大小,而且C语言也根本不会验证缓冲区之上所执行的读取与写入操作——这就使得缓冲区溢出成为了可能。正是同样的机制导致OpenSSL当中曝出了Hearbleed漏洞,但值得强调的是,它并不算是溢出、而属于读取越界。OpenSSL当中的C代码会尝试读取超出缓冲区容纳能力的内容,并最终导致敏感信息泄露至外部环境。#p#

修复此类漏洞

无需赘言,随着人类智慧的进一步发展,我们如今已经拥有了更多更出色的语言选项——它们会对指向缓冲区的读取与写入操作进行验证,这就彻底阻断了溢出问题的发生。由Mozilla打造的Rust等编译语言、安全运行时环境的杰出代表Java以及.NET,外加Python、JavaScript、Lua以及Perl等虚拟化脚本语言都彻底解决了缓冲区溢出的问题(当然,.NET仍然允许开发人员直接关闭所有保障措施,在这种选项设置之下缓冲区溢出会再度成为可能)。

缓冲区溢出目前仍然作为安全领域的一大关注重点存在,同时也是C语言持久生命力的有效证明。任何存在这一问题的遗留代码都有可能引发重大的安全事故。但目前世界上仍在运行的C代码依旧数不胜数,其中包括众多主流操作系统的内核以及OpenSSL等高人气代码库。即使开发人员倾向于使用C#这样安全性更出色的语言,他们也仍然需要使用大量由C语言编写而成的第三方库。

性能水平则是C语言继续被广泛使用的另一大理由,虽然关于这方面的具体判断方式仍然比较模糊。确实,经过编译的C与C++代码能够带来更理想的执行速度表现,而且在某些情况下起到了无可替代的重要作用。然而目前大多数用户所使用的处理器在绝大部分情况下都处于资源闲置的状态;如果我们能够牺牲百分之十的总体性能来让自己的浏览器获得更为坚实的安全保障,包括缓冲区溢出以及其它众多潜在安全隐患,那么相信大家绝对会选择这种方式。只要有厂商愿意开发出这样值得依赖的浏览器,我们就能够根据自己的实际需要作出权衡。

尽管如此,C语言和它的整个大家族却仍然广泛存在——当然也包括由其带来的缓冲区溢出风险。

目前已经有不少相关举措努力阻止溢出错误影响到开发人员以及使用者。在开发过程中,我们可以选择多种工具对源代码进行分析,并通过程序运行来检测其中是否存在危险结构或者溢出错误,这就避免了此类bug被实际添加到软件成品当中。AddressSantizer等新型工具以及Valgrind等传统方案都可以实现上述功能。

然而,这些工具需要开发人员的积极采用方能奏效,否则就是一堆毫无意义的0和1——也就是说仍有相当多的程序并没有将其纳入开发流程。另有一些系统层面的保护手段,能够在缓冲区溢出问题真正发生之后尽可能保证其它软件免受其侵害。在这方面,操作系统以及编译器开发者们已经采取了一系列方案,旨在提高攻击者使用这些溢出漏洞的难度。

某些系统的存在目的正是让一部分特定攻击活动变得更难实现。当前的多套Linux系统补丁就能够确保系统库全部被加载在底端内存地址处,从而保证其地址中至少包含一个null字节。在这种情况下,攻击者将很难利用C字符串处理方式在缓冲区溢出攻击中使用这些地址。

其它防御机制也更为普遍。目前很多编译器都拥有某种类型的内存栈保护机制,其会将一个名为“canary”(意为金丝雀)的运行时检测值写入到返回地址存储位置附近的内存栈末尾。在每项函数执行结束之前,系统都会检查该值以确定返回指令是否遭到了修改。如果该canary值发生了变化(因为其在缓冲区溢出中被覆盖),那么该程序将立即崩溃而非继续执行。

而最重要的单项保护手段之一正是名为W^X(意为’单纯写入或执行‘)、DEP(意为’数据执行保护‘)、NX(意为‘不执行’)、XD(意为‘执行禁用’)、EVP(意为‘增强病毒保护’,AMD公司往往比较喜欢使用这一术语)、XN(即‘从不执行’)等一系列措施。它们所采取的概念非常简单。这些系统会尽可能让内存拥有可写入能力(适用于缓冲区)或者可执行能力(适用于库及程序代码),但不会使其二者兼备。因此,即使攻击者能够使缓冲区出现溢出并控制其中的返回地址,处理器最终仍然会拒绝执行对应的shellcode。

无论具体使用什么样的名称,这都是一项重要的技术,这主要是因为其能够在无需额外成本的前提下起效——这类方案使用的是处理器自身内置的、作为虚拟内存硬件支持而存在的保护机制。

正如之前所提到,在虚拟内存当中每个进程都拥有属于自己的内存地址。操作系统与处理器会共同保持一套映射机制,从而令虚拟地址指向其它位置;有时候一个虚拟地址可能会对应一个物理内存地址,但有时候其会对应磁盘上某个文件的一部分,有时候甚至会因为尚未分配而不对应任何对象。这是映射机制是高度细化的,通常以4096字节为一个区块——也就是我们所说的page单位。

用于存储这一映射的数据结构不仅包含有每个page的位置(物理内存、磁盘以及无位置),同时(通常)也包含有另外三个用定义page保护的字位:即该page是否可以读取、其是否可以写入以及其是否可以执行。在这样的保护之下,进程对应的内存区域能够被标记为可读取、可写入但不可执行。相反,程序的可执行代码片段以及库则会被标记为可读取、可执行但不可写入。

NX的一大出色之处在于,操作系统通过更新获得对应的支持能力之后,它就能够以追溯方式应用于现有程序。某些程序偶尔也会在运行中遇到问题。Java以及.NET当中所使用的即时编译器就会在运行时环境下在内存中生成可执行代码,这些代码则要求内存同时具备可写入性与可执行性(不过严格来讲,这些代码一般不会同时要求这两种能力)。在NX出现之前,内存始终同时具备可读取性与可执行性,因此这些即时编译器完全无需针对其可读取/可写入缓冲区作出任何调整。但在NX出现之后,即时编译器必须要确保将内存保护机制从读取-写入变更为读取-执行。

市场对于NX这类安全方案的需求非常明确,特别是对于微软阵营来说。早在2000年初,两大蠕虫的相继出现就证明了微软公司的系统代码当中存在着一些严重的安全问题:Code Red于2001年7月感染了35万9千套运行有微软IIS Web Server的Windows 2000系统,而随后的SQL Slammer则于2003年1月侵入了超过7万5千套运行有微软SQL Server数据库的系统。这些都让软件巨头陷入严重的被动局面当中。

这两种蠕虫利用的都是内存栈中的缓冲区溢出漏洞,而且令人吃惊的是虽然距离莫里斯蠕虫诞生已经分别过去了13年和15年,但它们的开发方式几乎完全相同。三者都将恶意负载添加到内存栈的缓冲区内,并通过覆盖返回地址的方式加以执行。(惟一的区别在于,这两位相对年轻的继任者使用了‘蹦床’技术。相较于当初直接将返回地址设置为内存栈地址的方式,这二者将返回地址设置成了一条能够传递至内存栈并执行的指令。)

当然,这些蠕虫方案在其它多个方面也算有所发展。Code Red的负载不仅能够实现自我复制,同时也会侵入网页并试图执行拒绝服务攻击。SQL Slammer则囊括了一切感染其它计算设备并在网络上进行传播的功能组件,同时将自身体积控制在数百字节水平——这意味着受感染的机器上不会留下明显的痕迹,而且重新启动之后这些痕迹就会彻底消失。这两种蠕虫也都开始以互联网作为着眼重点,这也使它们超越了老祖宗莫里斯蠕虫、成功感染了更多计算机设备。

不过问题的关键在于,这样一种能够被直接利用的缓冲区溢出漏洞已经算是古董级别的隐患了。正是由于两种蠕虫病毒的相继出现,才使人们对使用Windows接入互联网并作为服务器系统产生了质疑情绪。面对重重压力,微软公司表示将开始认真对待安全问题。Windows XP SP2就是第一款真正让安全意识融入其中的成品。它对软件进行了一系列调整,包括提供软件防火墙、调整IE以避免工具栏乃至插件的静默安装——当然,也实现了对NX的支持。

在硬件层面支持NX在2004年之后成为主流,当时英特尔公司刚刚推出了其奔腾4处理器。而操作系统对于NX的支持也在Windows XP SP2迈出第一步后成为了业界共识。Windows 8在这方面表现得更加果断,干脆不支持未配备NX硬件的陈旧处理器。#p#

后NX时代

随着NX支持能力的逐步普及,缓冲区溢出也在当下找到了新的实现途径——换言之,攻击者们发现了一系列能够有效绕开NX的技术手段。

其中最早的一种与前面提到的“蹦床”机制非常相似,它能够通过来自其它库或者可执行代码的指令绕开系统在内存栈缓冲区内对shellcode的控制。不同于以往寻找可执行代码片段来直接将shellcode传递至内存栈当中,攻击者们如今转而开始特色确实拥有实际作用的代码片段。

而其中最理想的选项也许要数Unix的system()函数了。这项函数会获取一个参数:一条字符串的地址代表着一条将被执行的命令行,从传统角度讲该参数会被传递至内存栈当中。攻击者可以创建一条命令行字符串,并将其添加至内存栈中以实现溢出效果,而且由于在传统角度上内存中所承载的内容不会发生位置变动,因此该字符串的地址将以已知形式存在、并作为内存栈中配合攻击活动的组成部分。在这种情况下,被覆盖的返回地址不会再被设置为缓冲区地址,而是被设置为system()函数的地址。当造成缓冲区溢出的函数执行完成后,它不会返回至调用函数处,而是运行system()以执行攻击者选定的命令。

这就巧妙地绕过了NX的保护。作为系统库的组成部分,system()函数始终处于执行状态。这种漏洞利用方式并不需要在内存栈中执行代码,而只需要从内存栈中读取已有命令行。这项技术被称为“return-to-libc”(即回库),最初是由俄罗斯计算机安全专家Solar Designer于1997年发明的。(libc也就是Unix库的名称,其负责实现多种关键性函数,包括system()。Unix库通常会被载入到每个单独的Unix进程当中,而这也使其成为攻击活动的首选目标。)

虽然确切有效,但这项技术在某种程度上亦可以被扼制。一般来讲,函数并不会从内存线中获取自己的参数,而倾向于将其传递到寄存器当中。在命令行字符串中传递参数以实现执行虽然想法不错,但却往往会因为其中出现的恼人null字节而导致运转停止。另外,这会让多个函数同时调用变得非常困难。虽然并非无法解决——同时提供多个返回地址而非一个——但我们将完全无法变更参数顺序、使用返回值或者实现其它操作。

安全漏洞是如何造成的:缓冲区溢出

相较于利用shellcode填写缓冲区,我们现在选择利用返回地址与数据序列进行填充。这些返回地址会在目标程序及其库之内传递对现有可执行代码片段的控制权。每个代码片段都会执行一项操作而后返回,将控制权传递给下一个返回地址。

在过去几年当中,return-to-libc技术被广泛用于突破现有安全保护措施。2001年末,安全业界就曾记录下多种通过扩展return-to-libc执行多函数调用的方法,并提供了解决null字节问题的办法。这些技术并未受到严格限制,因此2007年由此衍生出的另一种复杂度更高的攻击手段开始出现——这种消除了大部分上述限制的方案正是ROP,即“返回导向编程”技术。

其基本设计思路与“回库”以及“蹦床”差不多,但却从普适性方面更进了一步。“蹦床”是利用单一代码片段将可执行shellcode添加到缓冲区当中,而ROP则是利用大量被称为“gadget”的代码片段。每个gadget都遵循一种特定模式:它会执行某些操作(包括向寄存器中添加一个值、向内存中写入或者添加两个寄存器等等),而后加上一条返回指令。x86的固有特性让“蹦床”的设计思路在这边再度起效;进程当中所加载的系统库中包含着成百上千个能够被解释为“执行一项操作,而后返回”的序列,因此它们也成为了实现ROP攻击的潜在基础。

这些gadget彼此之间通过一条长返回地址序列(也可以是其它任何有用或者必需的数据)被串连在一起,并作为缓冲区溢出的组成部分被写入至内存栈当中。返回指令则很少甚至完全无需借助处理器中的calling函数——而是单纯利用returning函数——在gadget之间跳转。值得注意的是,人们发现可资利用的gadget的数量与种类如此之多(至少在x86平台上是如此),攻击者几乎能够利用它们实现任何目标。这一奇特的x86子集在特定使用方式之下往往会呈现出完备的图灵特性(虽然其具体功能范围取决于特定程序所加载的库类型,并以此决定哪些gadget能够切实起效)。

正如“回库”技术一样,所有可执行代码实际上都来源于系统库,因此NX保护也就无处发力了。这套方案的出色灵活性意味着,攻击者能够甚至能够实现原本依靠“回库”技术所难于完成的任务,包括调用从寄存器内获取参数的函数或者将来自某一函数的返回值作为另一函数的参数等等。

ROP负载可谓变化多端。有时候它们只以简单的“创建一个shell“形式的代码出现,但大多数情况下攻击者都会利用ROP来调用某项系统函数,从而变更某一内存page的NX状态、将其由可写入转变为可执行。通过这种方式,攻击者将能够利用便捷的非ROP负载在执行过程中实现ROP。

随机性提升

NX的这一弱点早已为安全专家们所了解,而这同时也成为最大的薄弱环节在各类攻击活动中反复出现:攻击者们在动手之前就已经掌握了内存栈与系统库的确切内存地址。正因为各类攻击活动皆以此类知识作为基础,因此解决安全隐患的最佳途径就是使这些知识失去效用。有鉴于此,地址空间布局随机化(简称ASLO)技术应运而生:它会对内存栈的位置、内存内库以及可执行代码的位置进行随机化处理。一般来讲,这些位置在程序每次运行时、系统启动时或者二者同时发生时都会出现变化。

这极大地增加了攻击活动的实施难度,因为几乎在一夜之间,攻击者根本不知道哪些ROP指令片段会驻留在内存当中、甚至弄不清楚要实现溢出的缓冲区到底在哪里。

ASLR在多个方面与NX携手合作,因为它主要负责封杀“回库”以及“返回导向编程”这两大NX未能堵住的缺口。然而遗憾的是,它的介入深度有些过度。除了即时编译器以及少数其它非常用程序之外,NX几乎能够被成功添加到任何现有软件当中。但ASLR在这方面则问题多多,程序及库需要确保自身的正常运行不会受到内存地址随机变化的影响。

举例来说,在Windows当中,DLL就基本不会受到内存地址随机化的影响。DLL在Windows系统上始终支持利用不同内存地址加载数据,但EXE文件就没这么幸运了。在ASLR出现之前,EXE文件会始终以0x0040000作为起始加载位置,并安全地以此为运行前提。但ASLR出现之后,情况就完全不同了。为了确保不出现差错,Windows在默认条件下要求EXE可执行文件对ASLR提供支持,并提供启用选项。不过出于安全的考虑,即使对应程序并未明确表达支持能力,Windows仍然默认在所有可执行程序及库中启用该选项。而且在大多数情况下,结果还是令人满意的。

比较糟心的情况出现在x86 Linux系统当中。在使用ASLR的情况下,Linux平台的性能损失可能高达26%。除此之外,这套方案明确要求可执行程序与库以ASLR支持模式进行编译。这意味着管理员根本无法像在Windows环境中那样对ASLR进行授权。(x64也没能彻底解决Linux的性能损失问题,不过损失程度得到了显著降低。)

在ASLR启用之后,它能够为系统提供良好的缓冲区溢出状况保护。不过ASLR本身还远远称不上完美——举例来说,其能够提供的随机水平就比较有限,而且这种情况在32位系统中表现得尤为严重。尽管内存空间所能提供的地址数量高达40亿个,但并不是所有地址都能够被用于加载库或者旋转内存栈。

相反,其分配方式会受到各种约束,而且其中一部分还属于泛用性目标。总体而言,操作系统倾向于将各个库以相邻方式进行加载,以保证各个进程的地址空间首尾相连,这样就能尽可能多地为应用程序运行提供充裕的内存容量。大家当然也不希望让一个库以256 MB为单位遍布在整个内存空间当中——256 MB是我们能够作为整体进行分配的最大内存单位,这种作法会限制应用程序处理大型数据库集的能力。

可执行文件和库通常在启动后必须进行加载,且至少被包含在一个page当中。通常来讲,这意味着它们必须以4096整数倍的形式进行加载。平台也可以对内存栈采用类似的协议;举例来说,Linux会以16字节的整数倍形式启动内存栈。迫于内存的压力,系统有时候需要对随机性进行进一步削减,从而保证一切能够顺利运行。

这种变化看起来影响不大,但却意味着攻击者有时候可以猜测到某个地址的可能位置,而且有相当高的机率猜测成功。即使猜对的可能性非常低——例如二百五十六分之一——在某种情况下攻击者依然足以利用其实施恶意活动。当攻击某台会自动重启崩溃进程的Web服务器时,256次攻击中有255次出现崩溃完全不是什么大问题。只需要经过简单重启,攻击者就能再次尝试下一个内存地址。

不过在64位系统当中,由于地址空间变得更加庞大,单纯的猜测就不足以解决问题了。攻击者面对的很可能是上百万个——甚至数十亿个——潜在内存地址,机率如此之低也就不值得我们为之忧心了。

另外,对于攻击者来说,猜测与崩溃这段手段不适用于浏览器之类的场景;没有哪个用户会连续对浏览器进行256次重启来“帮助”攻击者完成试探。也就是说,在这种情况下NX与ASLR的联手协作将让攻击者们变得无机可乘。

但如果有其它帮助手段存在,情况就不一样了。在浏览器当中的一种常见实现途径在于利用JavaScript或者Flash——二者都包含着有能力生成可执行代码的即时编译器——向内存中塞进大量经过精心构建的可执行代码。由此生成的大型NOP sled也就是我们目前经常提到的“heap spraying”(也就是堆喷射)技术。另一种实现方式则是找出某个有可能泄露库或者栈内存地址的次级漏洞,从而帮助攻击者获得构建自定义ROP返回地址组所必需的相关信息。

第三种方法在浏览器当中比较常见:利用那些不支持ASLR的代码库。举例来说,Adobe PDF插件或者微软Office浏览器插件的某些早期版本就不支持ASLR,而且Windows在默认情况下不会强制在非ASLR代码中启用该功能。如果攻击者能够强制载入这类库(举例来说,通过在隐藏的浏览器帧中加载某个PDF文件),那么他们就能够直接绕过ASLR,即利用这些非ASLR库容纳自己的ROP负载。

一场永远休止的战争

攻击技术与保护技术之争就像是猫与老鼠的竞逐。像ASLR以及NX这样强大的保护系统能够提高安全漏洞的利用门槛,从而在一定时期内彻底阻止缓冲区溢出这类简单漏洞的肆虐。然而聪明的攻击者们仍然能够找到其它安全缺陷,并将它们组合起来以继续发动攻势。

这场军备竞赛仍在不断升级。微软公司的EMET(即‘增强缓解体验工具包’)当中包含一系列半实验性保护方案,旨在检测“堆喷射”乃至其它任何以ROP为基础尝试利用特定高危函数的行为。不过在这场永无休止的数字化对抗中,这些安全技术同样在持续遭到淘汰。这并不是说它们没有作用——各类新型保护技术的出现确实提高了漏洞利用的难度与成本——但大家必须正视一个现实,即警惕之心须长久保持。

英文:How security flaws work: The buffer overflow

责任编辑:蓝雨泪 来源: 51CTO.com
相关推荐

2015-09-22 14:49:41

网络安全技术周刊

2020-08-10 08:37:32

漏洞安全数据

2018-11-01 08:31:05

2019-02-27 13:58:29

漏洞缓冲区溢出系统安全

2017-01-09 17:03:34

2022-08-09 08:31:40

C -gets函数漏洞

2011-11-15 16:00:42

2014-07-30 11:21:46

2020-10-27 09:51:18

漏洞

2018-01-26 14:52:43

2009-09-24 18:16:40

2019-03-06 09:00:38

ASLRLinux命令

2010-09-29 15:59:04

2010-12-27 10:21:21

2017-08-30 20:49:15

2011-02-24 09:21:31

2019-01-11 09:00:00

2015-03-06 17:09:10

2010-10-09 14:45:48

2010-09-08 15:43:18

点赞
收藏

51CTO技术栈公众号