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

作者:袖梨 2022-06-30

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

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

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

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

相关文章

精彩推荐