C/C++ 解决方案针对缓冲区溢出的一种简单解决办法就是转为使用能够防止缓冲区溢出的语言。毕竟,除了 C 和 C++ 外,几乎每种高级语言都具有有效防止缓冲区溢出的内置机制。但是许多开发人员因为种种原因还是选择使用 C 和 C++。那么您能做什么呢?
事实证明存在许多防止缓冲区溢出的不同技术,但它们都可划分为以下两种方法:静态分配的缓冲区和动态分配的缓冲区。首先,我们将讲述这两种方法分别是什么。然后,我们将讨论静态方法的两个例子(标准 C strncpy/strncat 和 OpenBSD 的 strlcpy/strlcat ),接着讨论动态方法的两个例子(SafeStr 和 C++ 的 std::string )。
重要选择:静态和动态分配的缓冲区缓冲区具有有限的空间。因此实际上存在处理缓冲区空间不足的两种可能方式。
- “静态分配的缓冲区”方法:也就是当缓冲区用完时,您抱怨并拒绝为缓冲区增加任何空间。
- “动态分配的缓冲区”方法:也就是当缓冲区用完时,动态地将缓冲区大小调整到更大的尺寸,直至用完所有内存。
静态方法具有一些缺点。事实上,静态方法有时可能会带来不同的缺陷。静态方法基本上就是丢弃“过多的”数据。如果程序无论如何还是使用了结果数据,那么攻击者会尝试填满缓冲区,以便在数据被截断时使用他希望的任何内容来填充缓冲区。如果使用静态方法,应该确保攻击者能够做的最糟糕的事情不会使得预先的假设无效,而且检查最终结果也是一个好主意。
动态方法具有许多优点:它们能够向上适用于更大的问题(而不是带来任意的限制),而且它们没有导致安全问题的字符数组截断问题。但它们也具有自身的问题:在接受任意大小的数据时,可能会遇到内存不足的情况 ―― 而这在输入时也许不会发生。任何内存分配都可能会失败,而编写真正很好地处理该问题的 C 或 C++ 程序是很困难的。甚至在内存真正用完之前,也可能导致计算机变得太忙而不可用。简而言之,动态方法通常使得攻击者发起拒绝服务攻击变得更加容易。因此仍然需要限制输入。此外,必须小心设计程序来处理任意位置的内存耗尽问题,而这不是一件容易的事情。
标准 C 库方法最简单的方法之一是简单地使用那些设计用于防止缓冲区溢出的标准 C 库函数(即使在使用 C ++,这也是可行的),特别是 strncpy(3) 和 strncat(3) 。这些标准 C 库函数一般支持静态分配方法,也就是在数据无法装入缓冲区时丢弃它。这种方法的最大优点在于,您可以肯定这些函数在任何机器上都可用,并且任何 C/C++ 开发人员都会了解它们。许许多多的程序都是以这种方式编写的,并且确实可行。
遗憾的是,要正确地做到这点却是令人吃惊的困难。下面是其中的一些问题:
- strncpy(3) 和 strncat(3) 都要求您给出 剩余的空间,而不是给出缓冲区的总大小。这之所以会成为问题是因为,虽然缓冲区的大小一经分配就不会变化,但是缓冲区中剩余的空间量会在每次添加或删除数据时发生变化。这意味着程序员必须始终跟踪或重新计算剩余的空间。这种跟踪或重新计算很容易出错,而任何错误都可能给缓冲区攻击打开方便之门。
- 在发生了溢出(和数据丢失)时,两个函数都不会给出简单的报告,因此如果要检测缓冲区溢出,程序员就必须做更多的工作。
- 如果源字符串至少和目标一样长,那么函数 strncpy(3) 还不会使用 NUL 来结束字符串;这可能会在以后导致严重破坏。因而,在运行 strncpy(3) 之后,您通常需要重新结束目标字符串。
- 函数 strncpy(3) 还可以用来仅把源字符串的 一部分复制到目标中。 在执行这个操作时,要复制的字符的数目通常是基于源字符串的相关信息来计算的。 这样的危险之处在于,如果忘了考虑可用的缓冲区空间,那么 即使在使用strncpy(3) 时也可能会留下缓冲区攻击隐患。这个函数也不会复制 NUL 字符,这可能也是一个问题。
- 可以通过一种防止缓冲区溢出的方式使用 sprintf() ,但是意外地留下缓冲区溢出攻击隐患是非常容易的。 sprintf() 函数使用一个控制字符串来指定输出格式,该控制字符串通常包括“ %s ”(字符串输出)。如果指定字符串输出的精确指定符(比如 %.10s ),那么您就能够通过指定输出的最大长度来防止缓冲区溢出。甚至可以使用“ * ”作为精确指定符(比如“ %.*s ”),这样您就可以传入一个最大长度值,而不是在控制字符串中嵌入最大长度值。这样的问题在于,很容易就会不正确地使用 sprintf() 。一个“字段宽度”(比如“ %10s ”)仅指定了最小长度 ―― 而不是最大长度。“字段宽度”指定符会留下缓冲区溢出隐患,而字段宽度和精确宽度指定符看起来几乎完全相同 ―― 唯一的区别在于安全的版本具有一个点号。另一个问题在于,精确字段仅指定一个参数的最大长度,但是缓冲区需要针对组合起来的数据的最大尺寸调整大小。
- scanf() 系列函数具有一个最大宽度值,至少 IEEE Standard 1003-2001 清楚地规定这些函数一定不能读取超过最大宽度的数据。遗憾的是,并非所有规范都清楚地规定了这一点,我们不清楚是否所有实现都正确地实现了这些限制(这在如今的 GNU/Linux 系统上就 不能正确地工作)。如果您依赖它,那么在安装或初始化期间运行小测试来确保它能正确工作,这样做将是明智的。
strncpy(3) 还存在一个恼人的性能问题。从理论上讲, strncpy(3) 是 strcpy(3) 的安全替代者,但是 strncpy(3) 还会在源字符串结束时使用 NUL 来填充整个目标空间。 这是很奇怪的,因为实际上并不存在这样做的很好理由,但是它从一开始就是这样,并且有些程序还依赖这个特性。这意味着从 strcpy(3) 切换到 strncpy(3) 会降低性能 ―― 这在如今的计算机上通常不是一个严重的问题,但它仍然是有害的。
那么可以使用标准 C 库的例程来防止缓冲区溢出吗?是的,不过并不容易。如果计划沿着这条路线走,您需要理解上述的所有要点。或者,您可以使用下面几节将要讲述的一种替代方法。
OpenBSD 的 strlcpy/strlcatOpenBSD 开发人员开发了一种不同的静态方法,这种方法基于他们开发的新函数 strlcpy(3) 和 strlcat(3) 。这些函数执行字符串复制和拼接,不过更不容易出错。这些函数的原型如下:
1
2
| size_t strlcpy (char *dst, const char *src, size_t size);
size_t strlcat (char *dst, const char *src, size_t size);
|
strlcpy() 函数把以 NUL 结尾的字符串从“ src ”复制到“ dst ”(最多 size-1 个字符)。 strlcat() 函数把以 NUL 结尾的字符串 src 附加到 dst 的结尾(但是目标中的字符数目将不超过 size-1)。
初看起来,它们的原型和标准 C 库函数并没有多大区别。但是事实上,它们之间存在一些显著区别。这些函数都接受目标的总大小(而不是剩余空间)作为参数。这意味着您不必连续地重新计算空间大小,而这是一项易于出错的任务。此外,只要目标的大小至少为 1,两个函数都保证目标将以 NUL 结尾(您不能将任何内容放入零长度的缓冲区)。如果没有发生缓冲区溢出,返回值始终是组合字符串的长度;这使得检测缓冲区溢出真正变得容易了。
遗憾的是, strlcpy(3) 和 strlcat(3) 并不是在类 UNIX 系统的标准库中普遍可用。OpenBSD 和 Solaris 将它们内置在 <string.h> 中,但是 GNU/Linux 系统却不是这样。这并不是一件那么困难的事情;因为当底层系统没有提供它们时,您甚至可以将一些小函数直接包括在自己的程序源代码中。
SafeStrMessier 和 Viega 开发了“SafeStr”库,这是一种用于 C 的动态方法,它自动根据需要调整字符串的大小。使用 malloc() 实现所使用的相同技巧,Safestr 字符串很容易转换为常规的 C“ char * ”字符串:safestr 在传递指针“之前”的地址处存储重要信息。这种技术的优点在于,在现有程序中使用 SafeStr 将会很容易。SafeStr 还支持“只读”和“受信任”的字符串,这也可能是有用的。这种方法的一个问题在于它需要 XXL(这是一个给 C 添加异常处理和资源管理支持的库),因此您实际上要仅为了处理字符串而引入一个重要的库。Safestr 是在开放源代码的 BSD 风格的许可证下发布的。
C++ std::string针对 C++ 用户的另一种解决方案是标准的 std::string 类,这是一种动态的方法(缓冲区根据需要而增长)。它几乎是不需要伤脑筋的,因为 C++ 语言直接支持该类,因此不需要做特殊的工作就可使用它,并且其他库也可能会使用它。就其本身而言, std::string 通常会防止缓冲区溢出,但是如果通过它提取一个普通 C 字符串(比如使用 data() 或 c_str() ),那么上面讨论的所有问题都会重新出现。还要记住 data() 并不总是返回以 NUL 结尾的字符串。
由于种种历史原因,许多 C++ 库和预先存在的程序都创建了它们自己的字符串类。这可能使得 std::string 更难于使用,并且在使用那些库或修改那些程序时效率很低,因为不同的字符串类型将不得不连续地来回转换。并非其他所有那些字符串类都会防止缓冲区溢出,并且如果它们对 C 不受保护的 char* 类型执行自动转换,那么缓冲区溢出缺陷很容易引入那些类中。
工具有许多工具可以在缓冲区溢出缺陷导致问题之前帮助检测它们。 例如,像我的 Flawfinder 和 Viega 的 RATS 这样的工具能够搜索源代码,识别出可能被不正确地使用的函数(基于它们的参数来归类)。这些工具的一个缺点在于,它们不是完美的 ―― 它们会遗漏一些缓冲区溢出缺陷,并且它们会识别出一些实际上不是问题的“问题”。但是使用它们仍然是值得的,因为与手工查找相比,它们将帮助您在短得多的时间内识别出代码中的潜在问题。 |