安全破绽是怎样形成的:缓冲区溢出(1)

自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接受值设定,并以此为肇端地位停止后续操纵。

【视频】

在实际操纵中懂得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,其随后将能够下载一段恶意可履行代码、开启某个网络连接或许是实现别的一些攻打手段。

从传统角度讲,这确实是种令人有些意外的、小处引发的大成绩:总体而言,每款顺序在每次运转时都会应用同样的内存地点——即使在经过重启之后也不例外。这意味着内存栈上的缓冲区地位将永远不会变化,所以用于笼罩前往地区的值也能够一直重复加以应用。攻打者只须要一次性找出对应地点,就能够在任何运转着存在破绽的代码的计算机上再度实施攻打。

1
内容导航

 第 1 页:


转载自:https://netsecurity.51cto.com/art/201509/490090.htm

声明: 除非转自他站(如有侵权,请联系处理)外,本文采用 BY-NC-SA 协议进行授权 | 嗅谱网
转载请注明:转自《安全破绽是怎样形成的:缓冲区溢出(1)
本文地址:http://www.xiupu.net/archives-4609.html
关注公众号:嗅谱网

赞赏

wechat pay微信赞赏alipay pay支付宝赞赏

上一篇
下一篇

相关文章

在线留言

你必须 登录后 才能留言!