卡饭网 > 其他 > 正文

心脏出血漏洞 Heartbleed 固定大小缓冲区分析

来源:本站整理 作者:梦在深巷 时间:2015-03-11 20:52:50

Heartbleed 是来自OpenSSL的紧急安全警告:OpenSSL出现“Heartbleed”安全漏洞。这一漏洞让任何人都能读取系统的运行内存,文名称叫做“心脏出血”、““击穿心脏””等。

为什么固定大小缓冲区这么流行

心脏出血漏洞是最新发现的安全问题,由长字符串导致缓冲区越界。最常见的缓冲区越界发生在如下两种条件同时满足中:

程序中一个组件A向另外一个组件B传递了一个指针,也可能同时传递长度信息
组件B忽略了,或者没有正确使用这个长度信息。此信息规定了指针所指向的内存区域能够存储多少数据。

上述条件都满足的程序结构之所以能引起缓冲区越界,一个重要原因是,调用者A分配了一块内存,但是只有当数据被真正读取的时候,才能知道程序到底需要分配多大的内存空间,因为不会被读取的数据,我们完全可以不保存它。 换句话说,一个函数负责分配空间,然后调用另外一个函数向该空间填充数据的结构,都会有点不安全。

即使这点危险能够通过正确的检查内存边界的方式成功避免,但是边界检查也会引入其自身存在的负面效果。 比如,我的一位前同事, 他创建了一个文本文件, 此文本文件压缩了数万个字符构成的单行字符串。 然后他又将这个文件作为输入,传递给了许多其他部件,比如编译器,文本处理程序等等。 几乎所有的这些程序都会出现这样那样的异常行为,例如,直接崩溃,或者悄无声息的忽略掉输入字符串的最后一截。

应对该问题的简单解决方案是:如果程序中任何部分涉及读入长度不确定的输入,那就应该负责分配足够大的内存来保存这些输入。当然,在C++语言中使用STL标准库就能轻松实现。但是在C语言中,却没有简单有效的实现代码,可以从输入读入一个单行字符串,返回包含该输入的内存指针,无视输入的长度。 任何在C语言中实现此功能的尝试,都或多或少的存在一些副作用。

我也曾静下心来在当时工作的部门,尝试在C语言库中增加一个针对上述问题的解决方案。 如果有人想要将使用了我写的函数的代码分享到别的地方,我想让他们也能将我写的函数作为其中一部分发布出去。 我所增加的函数的名称是readline,且为方便使用而设计:只需要传入一个文件指针(例如 stdin)作为输入,此函数就能读入一整行的输入,返回一个指向以NULL结尾的此字符串的第一个字符,无需考虑输入的长度。 如果读到了文件结束符(EOF),就返回一个NULL指针。

显然,任何分配内存,并返回指向该内存指针的函数都存在一个问题:该内存何时被释放? 我考虑过让readline函数的调用者负责释放,但是觉得很多调用函数可能会忘记释放内存。那么此时的缓冲区越界问题又变成了内存泄漏问题。

最后,我决定采取在别的地方看到的策略:readline将会返回一个指向内存空间的指针,并且保证其中的内容在下一次调用readline函数之前都会保持不变。这种策略不仅可以减少用户的担忧,而且也能让实现更简单:程序将存储一个静态指针(static pointer) 指向(动态分配的)缓冲区。缓冲区的大小将随读入的行的长度需要增减。 这种机制能让readline函数在最常用的场景中简单好用,并且安全。

代码如下

char *line;
while ((line = readline(stdin)) != NULL) {
/* Process a line */
}

当然,这种机制也有他自身存在的问题。比如,在同一个表达式中,两次调用readline函数将导致未定义行为(undefined behavior)。因为当程序员计划在第二次调用readline()函数之后,试图保存两次调用readline所读入的全部数据时,第一次调用所创建的内存空间,将在第二次调用时被释放掉。 此外,该代码会在读入输入的最后一行后,因为不再被调用,会一直占用内存空间。实际上,它所浪费的内存空间是整个输入中最长的那一行的长度。在实现该函数时,虽然我在缓冲区小于输入行长度时,都会重新分配更大的缓冲区,但是却没有允许缓冲区变校因为我觉得反复分配释放内存的所导致的性能下降,相比于在少数清醒下浪费一点点内存空间来说不值得。

很显然,我高估了人们所能忍受的内存分配延迟开销:当我几个月后回头看这些代码时,发现有人已经将我所写的readline版本完全修改为固定的4096-字符缓冲区。据我所了解,他的动机是完全避免运行时存储分配的开销。换句话说,为了避免只有在少数情况下才存在的多次内存分配器调用,他悄悄的让所有使用readline函数的程序,在行的长度大于4096个字符时,出现了很大的安全隐患。

之所以花了大量的篇幅讲这样一个故事,是因为它透露出我觉得非常重要的几点:

缓冲区越界通常发生在程序中某个部分A分配内存,而实际需要的存储空间大小只有另一个部分B知道。
在程序中的同一个函数内部分配内存,并将其填充。这种方式解决了缓冲区分配的问题,而付出的代价是必须要让程序的另一个函数负责内存的释放。内存的分配和释放在程序的两个不同的函数中。
这种分配和释放在两个不同的函数将会导致程序的可用性问题,除非在编程语言上有系统的支持,否则很难绕开。
即使用户为了安全和通用性,需要接收这个现实,但是他们可能也无法接受动态分配内存引入的开销。

我想,程序员不愿为了安全而引入运行时开销,是很多安全性问题之所以普遍存在的原因。 我们将在下周详细聊聊这种现象。

相关推荐