计算机和网络安全简史:第四期
题图:缓冲区溢出攻击基本原理示意图
从漏洞开始,对于大多数非计算机专业的人来说可能就比较抽象了。维基百科给出的计算机漏洞的定义是“能削弱计算机系统整体安全的瑕疵”(Vulnerabilities are flaws in a computer system that weaken the overall security of the system.)。往大了说,漏洞(Vulnerability)其实可以认为是一种bug,但在我认知看来,BUG更多的指的是程序在业务层面的错误,就比如游戏BUG导致萝卜上房或者存档损坏。而漏洞主要指的是可以直接对软件或者操作系统本身的安全性造成危害的那一类bug。并且更重要的是,漏洞可以被有意利用。
漏洞的属性是多维度的,目前业界按以下几个维度对漏洞进行分类:
在计算机安全领域,0day漏洞就是核弹,因为0day漏洞没有公开的特征和信息,也就没有办法在漏洞被利用之前实施检测和防御,一旦被恶意利用会造成极为严重的后果。因此像微软、谷歌和Adobe等大型软件公司会以高昂的价格公开征集自家产品和服务的0day漏洞,单个0day漏洞奖金最高可达数十万美元。由此也诞生了一类专门以出售0day漏洞为职业的研究人员:漏洞猎人。
(Microsoft Bug Bounty Program)
(Google and Alphabet Vulnerability Reward Program)
(Adobe Bug Bounty Program)
(大部分厂商是和hackerone.com这样的漏洞悬赏平台合作,比如Nintendo Bug Bounty Program)
限于篇幅,本文挑漏洞中最典型的一类漏洞介绍:缓冲区溢出漏洞。
在漏洞成因分类中,缓冲区溢出漏洞属于边界条件错误。在讲原理之前想请大家一起回忆一个事儿:还记得当年玩PSP的时候,在神奇电池发现之前早期的破解刷机过程吗?我记得大概应该是这样的:机器买到之后,先用一些办法将系统版本降到2.00;再在记忆棒的相册文件夹里拷贝一张TIFF格式的图片文件;开机之后立即进相册找到这张图片,机器屏幕就会一闪,然后就进入自制系统了。
(西班牙黑客Dark~AleX在PSP初期的破解功不可没)
不知道各位有没有想过这个过程中究竟发生了什么?那张图片是做什么用的?如果你在电脑上打开那张TIFF图片,大概率电脑会告诉你图片格式损坏,无法读取。这张图片的真正用途,是用来触发PSP固件的TIFF图片的缓冲区溢出漏洞。
要弄明白缓冲区溢出漏洞,首先要弄清楚什么是缓冲区。简单来说缓冲区(Buffer)是指程序为了容纳数据,在内存中开辟的一块内存空间,这块内存空间一旦申请,其大小就会被固定下来。申请缓冲区的目的当然是为了用于容纳数据,但如果写入缓冲区的数据比缓冲区更大,而且编译器和程序自身都没有检查写入数据的长度(术语叫边界检查,Boundary Checking),就会把数据数据写入缓冲区以外的内存空间,覆盖了其他数据,这就造成了缓冲区溢出。
这里还可以阅读@韩大老师这篇介绍内存管理文章作为背景知识。韩大老师的文章大概提到了指针越界会引发的问题,这篇文章可以详细告诉你具体会引发什么样的问题、如何利用以及如何防御。
一般情况下,这种意外的内存覆盖会导致程序崩溃、死机或者运行出错。但如果漏洞的利用者通过调试弄清楚了程序在运行时的内存空间结构(术语叫内存布局),就可以通过构造溢出数据的长度以及内容的方式,让溢出到缓冲区外的数据精确覆盖一个指定的内存地址的内容(大部分情况下是函数的返回地址),而这个内存地址正好容纳着指向程序接下来要执行的指令的指针,就会使得溢出到这个地址的数据被当作指令指针被执行起来。这就实现了缓冲区溢出所能造成的最为危险的一类漏洞:任意代码执行。而通过这种方式被执行的代码叫Shellcode。
这也解释了为什么早期图片破解是有一定概率的,不保证每次都成功。之前机核就有播客节目里有悲催老哥好不容易拿到PSP了,结果整整一节课都没能刷机成功。这就是因为进程的内存布局每次运行的时候不一定是一样的,系统状态不同或者之前运行了其他功能都可能导致进程的内存布局发生变化。而注入的指令的内存地址和内容都是写死不可更改的,如果内存布局不是能够触发代码执行的状态,破解就失败了。这一点在后续还会提到。
(感谢@喵的小腿肉 指路,终于找到了……)
还记得第一期文章开始提到的冯·诺依曼?冯·诺依曼提出过一个重要的概念:存储程序,即程序可以使用和数据相同的方式共享存储空间,这就是我们所说的“冯·诺依曼架构”(与之对应的是程序和数据分开存储的“哈佛架构”)。因为实现成本低且硬件利用效率较高,现在主流的计算机都采用冯·诺依曼架构。但这也产生了一系列问题,其中之一就是在某些情况下数据可能会被错误地当做程序来执行,缓冲区溢出攻击就是冯·诺依曼架构面临的问题。
(在当今的部分CPU里,数据和指令储存在不同的高速缓存里,在CPU层面使用了哈佛架构)
回到PSP的TIFF漏洞。TIFF(Tagged Image File Formation)是一种格式复杂的图片文件,目前由ADOBE公司维护,可包含多个图像数据以及自定义标签,这种复杂性为其埋下了安全隐患。PSP带的libtiff漏洞如果用正规化术语来描述的话大概是这样的:由于PSP 2.00到2.80版本固件使用的libTIFF第三方组件带有缓冲区溢出漏洞,导致可以通过加载精心构造的TIFF文件的方式在PSP设备上执行任意代码。
要利用缓冲区溢出漏洞,首先通过FUZZ测试、逆向工程或者直接对源代码进行审计等方式找到不安全的内存操作,例如上文提到的没有检查源数据的长度就往缓冲区里拷贝;然后使用调试器(或者代码审计)进行跟踪,顺着执行流程找到这个不安全的操作在程序处理文件、键盘输入或网络数据等外部数据时的触发点和利用点,例如将文件结构的某个字段内容设置为特别大,或者在指定长度的字段写入异常数值,使得程序在处理文件结构的时候写入了不该写入的内存地址,造成漏洞;最后搭建调试环境,将程序运行起来,观察程序运行时的内存布局和引用的模块和库的地址,根据这些信息编写漏洞利用代码和shellcode。
(编写本文期间查阅了libtiff相关的漏洞,发现这简直就是缓冲区溢出的重灾区。自2004年至今,libtiff已经累计获得了252个有CVE编号的漏洞,几乎全部都是缓冲区溢出漏洞,其中16个是评分9.0以上的极危漏洞。建议开发人员集成功能的时候谨慎使用libtiff)
我们都知道PSP是封闭生态,正常情况下只有获得索尼第一方签名的程序才能在PSP上运行。而通过漏洞可以绕过厂商的签名验证机制,在PSP上运行任意代码,后续只要通过这个漏洞执行权限提升和加载自制系统,就可以绕过签名验证机制运行记忆棒上的ISO镜像了。
这里还可以多说一句,通过这种方式实现的代码执行之后做什么。由于通过缓冲区溢出的方式注入一整个实现完整功能的shellcode不太现实,一般是先弄清楚被注入的程序会加载哪些系统API函数,从中找到合适的功能,记下其在内存空间中的地址,注入成功之后将上一个函数的返回地址覆盖为想要执行的系统函数的地址,通过它再加载存储里预先准备好的的文件或者实现提权等操作以实现需求。有点类似军事上的OSP——On-Site Procurement,即武器和装备都从任务区域获取)
(《潜龙谍影》初代的开场动画,不过尽管这是一次OSP任务,斯内克还是想办法带了一包烟在身上)
顺带一提,在TIFF漏洞之前还有一种破解方式,是通过部分正版游戏里存在的漏洞实现的。这种做法需要一张带有漏洞的游戏的UMD光盘以及一个修改过的游戏存档。其原理是借用了一个可以在PSP上合法运行的正版游戏作为掩护,让这个游戏加载一个精心构造的可以触发漏洞的存档,通过它在游戏的内存空间里执行注入的代码。后续工作一样也是降级、提权和加载自制系统等,实现破解。
(PSP的《侠盗猎车手:自由城故事》的首发版本就存在这样一个缓冲区溢出漏洞,可通过这个漏洞执行注入的代码,配合PSP 1.XX版本固件的内核漏洞实现固件版本降级)
任意代码执行是缓冲区溢出漏洞能够实现的最为成功的利用,大多数情况下缓冲区溢出只能导致程序崩溃。例如经典的死亡之Ping(Ping of death,CVE-1999-0345)。这个漏洞是由于操作系统在处理超过正常大小的ICMP PING包时发生错误导致的。具体来说是当接收到长度超过65535字节的Ping包时Windows 95和NT会蓝屏死机。因为ICMP和UDP、TCP一样同属于网络层协议,其协议栈是在操作系统内核实现的。所以一旦它发生崩溃就会导致整个操作系统崩溃。
(Windows 95的蓝屏死机界面)
缓冲区安全编译开关
计算机专业朋友可能还有印象,大学C/C++语言课程用的是微软的Visual C++的话,写代码的时候如果字符数组(定长字符串)忘了初始化,打印字符串内容会输出一堆奇怪的“烫烫烫”。
(VC++ 6.0会平等地殴打每个之前没摸过编程的计科新生)
首先说说“烫烫烫”是怎么来的。如果你在调试器里监控数组变量的内容,会发现定长数组声明之后(栈空间内存),如果没有初始化的话,数组内容都是0xcc字节(十进制值204),这是VC++在调试配置(Debug)下所有的未分配的栈内存的默认填充内容。而两个0xcc字节在GB2312或GBK编码里就是汉字的“烫”。之所以用这样的内容填充,是因为0xcc作为单字节指令时是INT3,这是一条中断指令。如果程序发生了缓冲区溢出,大概率会导致指令流程跳转到了写满了INT3指令的未分配的内存空间,执行到这里程序就会被中断,提醒开发者可能发生了缓冲区溢出。
这个功能在VC++的调试配置下默认开启(编译器开关:/GZ,启用堆栈帧运行时错误检查,从VS2005开始被/RTC替代),但在Release(发布)配置下默认是不开启的,这时申请的数组里的初始内容就是这块内存原本的内容,编译器不提供给未使用的内存填充默认值的功能。这里还能CALLBACK第一期讲的“冲击波”蠕虫:“冲击波”的传播是依靠RPC服务的缓冲区溢出漏洞,但这个病毒只能感染Windows 95/98和ME,而同样也开启这个服务的Windows Server 2003就不会感染。这是因为2003的这个服务是开启了另一个用于防止缓冲区溢出攻击编译器开关(/GS,缓冲区安全检查)编译的,当发生缓冲区溢出的时候Server 2003的这个服务只会崩溃,不会执行注入的代码。
安全版内存函数
2005年微软在自家的VC++里加入了安全版内存操作函数,在相应的原版本函数名后边加了“_s”作为区分,意为security。比如字符串拷贝函数strcpy()的安全版本就是strcpy_s()。相对于原版函数,安全版本增加了长度检查和边界检查。这样就可以防止由于写入的目的缓冲区过小导致的缓冲区溢出。但这个规范不是通用的,无法直接移植到linux环境。
(微软C++运行库(C++ Runtime)安全版内存函数的说明文档)
防火墙和IDS
缓冲区溢出漏洞最为凶恶的一种利用就是远程利用,即缓冲区溢出漏洞导致的远程任意代码执行。上面讲的TIFF图片漏洞还只是本地利用。可以想象一下如果某个网络服务存在缓冲区溢出导致任意代码执行的漏洞,那只要入侵者构造一个可触发漏洞的数据包发送过去,这个主机就被控制了。远程任意代码执行漏洞在评级里往往属于最危险的那一类。
不过幸运的是,大多数情况下可以通过远程利用的缓冲区溢出漏洞,它的载荷数据包都有很明显的特点,比如数据包的某个字段特别长、某些字段的数值明显超出正常范围之类。这个时候就可以用防火墙来过滤和阻断这些有害的数据包。防火墙一般设置在网络边界,监控所有进出流量。当根据规则发现某个流入数据包结构异常或者有明显的漏洞利用特征,防火墙就会阻断这个连接,并将这个事件记录下来。而IDS一般部署在内网,接受来自交换机的镜像流量,IDS并不会阻断可疑连接和数据,而是向运维人员发出告警。
(免费IDS软件snort用来阻拦MS08-067漏洞利用流量的规则(部分))
不过防火墙和IDS也有其局限性,它只能防御所有已经被规则定义的攻击,无法防御特征未知的0day漏洞。
地址空间布局随机化(ASLR)
由于缓冲区溢出漏洞的巨大危害以及其发生的频繁程度,让软件厂商们不得不思考有没有一种一劳永逸的解决方案,于是ASLR就诞生了。ASLR的全称是Address Space Layout Randomization,即地址空间布局随机化。
缓冲区溢出攻击能够执行注入代码的一个重要条件就是攻击者可以预测程序运行时的内存布局以及要利用的函数的地址(比如程序引用的系统API函数地址)。在以前没有ASLR的时候,相同的操作系统上同一个程序每次运行的时候它的内存基址都是固定的,使得攻击者通过调试就可以很容易弄清运行时的内存布局。而ASLR的作用就是让程序运行时的内存基址随机化,让程序每次运行的时候内存布局都是随机的,这样就使得攻击者无从预测内存布局,也就无法利用缓冲区溢出实现代码执行了。ASLR让那些原本是高危的缓冲区溢出漏洞退化成拒绝服务的中危或低危漏洞了。
(ASLR原理讲解。图中每个色块表示相应的Windows的重要应用层模块。有了ASLR,这些模块的基址在每次系统启动之后都会随机分配。而在以前它们的基址几乎是固定不变的)
ASLR技术需要在操作系统层面实现。目前包括Windows、Linux、Android、iOS和macOS等主流操作系统都已经支持ASLR技术,这项技术对缓冲区溢出漏洞可以说是釜底抽薪,直接让曾经的杀手级漏洞退环境了。
坚固的盾会催生更尖利的矛,ASLR技术也无法完美防御所有的缓冲区溢出攻击,对抗ASLR的技术叫堆喷射(Heap Spray)。基本原理也并不复杂:ASLR把内存空间全部随机化,让攻击者无从预测溢出点函数的返回地址了,那把所有可能的内存空间(这里特指堆空间)都用shellcode填满,并且在shellcode里加入大量的滑板指令(一种shellcode的编写技巧,在shellcode中填充大量的nop指令,其功能是“执行下一条指令”,然后把真正要执行的漏洞利用代码放在最后部分,这样可以大大提升shellcode被返回地址“踩中”的可能性)。当然了,这种类似“火力覆盖”的攻击方式相对于没有ASLR直接利用溢出漏洞相比成功率肯定低了很多,而且由于会产生大量的写内存操作,很容易被发现。虽然使用堆喷射技术可以绕过ASLR技术,但ASLR大幅提升了攻击的成本。
(堆喷射原理讲解示意图)
最后还可以讲一个有关缓冲区溢出的趣闻,可能有的朋友已经在别的地方读过了。
在没有网络分发、游戏还都是以实体版发售为主的时代,如果一款游戏如果发现了Bug,就只能在后续生产的碟片和卡带中进行修正,已经卖出的拷贝就没有办法了。这种情况对于纯单机游戏尚可接受,只要不是游戏无法正常运行的恶行Bug,大部分情况下官方只需要在游戏杂志上道个歉,说明一下如何避免Bug,然后在后续生产的拷贝中修正Bug就好。
不过对于包含在线游玩内容的游戏来说就有些不妙了。在线游戏会涉及平衡性和稳定性等多方面因素,在线部分如果存在Bug是必须修正的。PS2平台的《瑞奇与叮当3》当年就遇到了这样的问题。
(《瑞奇与叮当3》的美版封面,封面上“ONLINE”标志说明这款游戏包含在线部分)
在高度依赖网络的今天,我们已经难以想象以前的联网游戏是有可能运行在一张只读的光盘上的,但当时就是这样。而且很不幸地,这款游戏发售之后发现存在需要修正的Bug。可能是出于网络连通性测试目的,《瑞奇与叮当3》在联网的时候会从游戏的服务器上下载最新版的最终用户许可协议(End User License Agreement,也就是常说的EULA)文本并显示在界面上。游戏使用了原版的strcpy()函数从网络数据缓冲区中复制EULA内容到长度固定的显示缓冲区,而且没有检查文本长度是否比缓冲区长!
(C语言的strcpy()函数的定义以及用法)
这是一个非常低级的错误,在没有内存安全版本函数的年代,程序员应自行编写代码检查拷贝数据是否比缓冲区长,这也是当年大多数缓冲区溢出漏洞的成因。但这次这个错误反而成为了解决Bug的希望:游戏开发人员打算使用这个缓冲区溢出漏洞来给游戏打热补丁(Hot Fix)。
通过之前对于缓冲区溢出漏洞原理的讲解,我们知道这个方式在理论上是可行的。但实际上仍然要解决很多问题。首先,注入点的内存地址和目标内存地址之间有一定距离,二者之间的数据会被覆盖掉,而这些数据是有用的,要在之后将其恢复;其次,strcpy()的复制会在源数据里第一个零值字节(一般写做“\0”)——字符串结尾标志处停止。这就要求无论是填充部分还是shellcode部分都不能包含零值字节。填充部分还好说,shellcode由于功能要求,出现零值字节不可避免,所以还得先对shellcode进行调制和变换,使其不包含零值字节。这件事具体实施起来是这样做的:
(故事原文)
漏洞数据库
为了规范漏洞信息交换格式,增强漏洞信息通报的及时性和准确性,计算机和网络安全业界曾经成立过不少漏洞信息数据库。目前最具权威性和业界认可度的漏洞数据库叫CVE:Common Vulnerability and Explosure,即通用漏洞和披露。CVE创立于1999年,目前由美国MITRE公司运营,受到美国国土安全部和美国国家网络安全部的资助。CVE有一整套漏洞信息处理机制,包括漏洞信息的收录、验证、评估、通报和披露等。被CVE收录的漏洞都会指派一个编号,格式是“CVE-四位年份-序号”,例如“CVE-2008-4250”(著名的MS08-067漏洞)。截止本文编写期间,CVE累计收录了237725个漏洞。
(CVE的旧版LOGO)
在CVE之外,各个国家也都成立了隶属于政府机构的漏洞数据库,例如美国国家漏洞数据库NVD(National Vulnerability Database,隶属于美国国家标准与技术研究院)。我国有三个官方建立的漏洞库,分别是:
(国家信息安全漏洞库)
(国家信息安全漏洞共享平台)
(国家工业信息安全漏洞库)
此外,一些大型软件公司也有自己的漏洞披露和编号机制,比如微软、Adobe、Apache Foundation等。
POC和EXP
在和漏洞相关的内容里,POC和EXP是两个经常看到的缩写。
先说EXP,在这个语境下它代表Exploit,特指漏洞利用代码。即可以通过触发漏洞实施攻击的工具或者代码。
而POC是Proof of Concept的缩写,在这个语境下一般翻译为“漏洞验证”,指的是以尽量无害的方式证明漏洞存在的手段。POC代码可以以通过无害方式触发漏洞来证明、检查特定组件版本号等方式来证明。完全无害的POC代码一般会被用在漏洞扫描工具上。
对于大多数已知漏洞,可以在exploit-db.com上检索POC和EXP。
(网站首页)
(Ping of death漏洞的证明代码,非常简单:直接触发给你看)
下期预告
下一篇将是系列最后一篇,介绍当今计算机和网络的安全基石之一——公钥加密技术。
参考资料