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

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

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

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

但颇为讽刺的是,作为一种肆虐多年的安全隐患,缓冲区溢出漏洞的核心却只是由一种实践性结果衍生出的简单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接受值设定,并以此为起始位置进行后续操作。

点赞

发表评论

电子邮件地址不会被公开。 必填项已用*标注

此站点使用Akismet来减少垃圾评论。了解我们如何处理您的评论数据