C/C++ 中一个部分解决方案是使用没有缓冲区溢出问题的库函数。第一个小节描述了“标准 C 库”解决方案,该方案可能有效,但也有其缺点。下一个小节描述了固定长度和动态重新分配缓冲区方法的一般安全问题。以下小节描述了各种替代库,例如 strlcpy 和 libmib。请注意,这些库并不能解决所有问题;您仍然必须在 C/C++ 中极其小心地编写代码,以避免所有缓冲区溢出情况。
防止 C 语言(以及某些 C++ 程序中使用)缓冲区溢出的“标准”解决方案是使用标准 C 库调用,以防御这些问题。这种方法很大程度上依赖于标准库函数 strncpy(3) 和 strncat(3)。如果您选择这种方法,请注意:这些调用具有有些令人惊讶的语义,并且很难正确使用。如果源字符串长度至少等于目标字符串的长度,则函数 strncpy(3) 不会以 NIL 结尾目标字符串,因此请务必在调用 strncpy(3) 后将目标字符串的最后一个字符设置为 NIL。如果您要多次重复使用同一个缓冲区,一种有效的方法是告诉 strncpy() 缓冲区实际上比它小一个字符,并在使用前将最后一个字符设置为 NIL 一次。strncpy(3) 和 strncat(3) 都要求您传递剩余可用空间的大小,这个计算很容易出错(并且出错可能会导致缓冲区溢出攻击)。两者都没有提供简单的机制来确定是否发生了溢出。最后,与它本应替换的 strcpy(3) 相比,strncpy(3) 具有显着的性能损失,因为strncpy(3) 会用 NIL 填充目标字符串的其余部分。我收到过电子邮件,对最后一点表示惊讶,但这在 Kernighan 和 Ritchie 第二版 [Kernighan 1988, 第 249 页] 中有明确说明,并且这种行为在 Linux、FreeBSD 和 Solaris 的手册页中都有明确记录。这意味着仅仅从 strcpy 更改为 strncpy 可能会导致性能严重下降,而在大多数情况下没有充分的理由。
警告!!函数 strncpy(s1, s2, n) 也可以用作仅复制 s2 的一部分的方法,其中 n 小于 strlen(s2)。当以这种方式使用时,strncpy() 本身基本上不提供任何针对缓冲区溢出的保护 - 您必须采取单独的措施来确保 n 小于 s1 的缓冲区。此外,当以这种方式使用时,strncpy() 通常不会在复制 n 个字符后添加尾随 NIL。这使得更难以确定使用 strncpy() 的程序是否安全。
您还可以使用 sprintf(),同时防止缓冲区溢出,但这样做时需要小心;它太容易被误用,因此很难推荐。sprintf 控制字符串可以包含各种转换说明符(例如,“%s”),并且控制说明符可以具有可选的字段宽度(例如,“%10s”)和精度(例如,“%.10s”)规范。这些看起来非常相似(唯一的区别是一个句点),但它们非常不同。字段宽度仅指定最小长度,并且对于防止缓冲区溢出完全没有价值。相反,精度规范指定了当用作字符串转换说明符时,该特定字符串在其输出中可能具有的最大长度 - 因此它可以用于防止缓冲区溢出。请注意,精度规范仅在处理字符串时指定总最大长度;它对于其他转换操作具有不同的含义。如果大小以“*”的精度给出,那么您可以将最大大小作为参数传递(例如,sizeof() 操作的结果)。这最好通过一个示例来展示 - 这是使用 sprintf() 防止缓冲区溢出的错误和正确方法
char buf[BUFFER_SIZE]; sprintf(buf, "%*s", sizeof(buf)-1, "long-string"); /* WRONG */ sprintf(buf, "%.*s", sizeof(buf)-1, "long-string"); /* RIGHT */ |
此外,关于上面的代码的快速说明 - 请注意,sizeof() 操作使用了数组的大小。如果代码被更改为使 “buf” 是指向某些已分配内存的指针,那么所有 “sizeof()” 操作都必须更改(否则 sizeof 只会测量指针的大小,这对于大多数值来说空间不足)。
scanf() 系列也令人遗憾地有些模糊。一个明显的问题是,是否可以在 %s 中使用最大宽度值来防止这些攻击。关于 scanf() 有多个官方规范;有些明确声明宽度参数是绝对最大的字符数,而另一些则不太清楚。最大的问题是实现;我所知道的现代实现都支持最大宽度,但我不能肯定地说所有库都正确地实现了最大宽度。最安全的方法是在这种情况下自己动手。但是,如果您只是使用 scanf 并在格式字符串中包含宽度,很少有人会责怪您(但不要忘记计算 \0,否则您会得到错误的长度)。如果您确实使用 scanf,最好在安装脚本中包含一个测试,以确保库正确限制了长度。
诸如 strncpy 之类的函数对于处理静态分配的缓冲区很有用。这是一种编程方法,其中为“最长有用大小”分配缓冲区,然后从那时起它保持固定大小。另一种方法是根据需要动态重新分配缓冲区大小。事实证明,这两种方法都存在安全隐患。
使用固定长度缓冲区时,存在一般的安全问题:缓冲区是固定长度这一事实可能是可利用的。这是 strncpy(3) 和 strncat(3)、snprintf(3)、strlcpy(3)、strlcat(3) 以及其他此类函数的问题。基本思想是攻击者设置一个非常长的字符串,以便当字符串被截断时,最终结果将是攻击者想要的(而不是开发人员预期的)。也许字符串是由几个较小的部分连接而成的;攻击者可能会使第一部分与整个缓冲区一样长,因此以后所有连接字符串的尝试都将无效。以下是一些具体示例
想象一下调用 gethostbyname(3) 的代码,如果成功,则立即使用 strncpy 或 snprintf 将 hostent->h_name 复制到固定大小的缓冲区。使用 strncpy 或 snprintf 可以防止过长的完全限定域名 (FQDN) 溢出,因此您可能会认为您已经完成了。但是,这可能会导致截断 FQDN 的末尾。根据接下来发生的事情,这可能非常不可取。
想象一下使用 strncpy、strncat、snprintf 等的代码,将文件系统对象的完整路径复制到某个缓冲区。进一步想象原始值是由不受信任的用户提供的,并且复制是将结果计算传递给函数的过程的一部分。听起来安全,对吧?现在想象一下,攻击者在路径的开头填充了大量的“/”。这可能导致将来对文件“/”执行操作。如果程序在相信结果将是安全的情况下附加值,则程序可能是可利用的。或者,攻击者可以设计一个接近缓冲区长度的长文件名,以便附加到文件名的尝试将静默地失败(或仅部分发生,其方式可能是可利用的)。
当使用静态分配的缓冲区时,您真的需要考虑源参数和目标参数的长度。对输入和生成的中间计算进行健全性检查也可能解决这个问题。
另一种选择是动态重新分配所有字符串,而不是使用固定大小的缓冲区。GNU 编程指南推荐这种通用方法,因为它允许程序处理任意大小的输入(直到内存耗尽)。当然,动态分配字符串的主要问题是您可能会耗尽内存。内存甚至可能在程序中的其他位置耗尽,而不是在您担心缓冲区溢出的部分;任何内存分配都可能失败。此外,由于动态重新分配可能会导致内存分配效率低下,因此即使从技术上讲有足够的虚拟内存可供程序继续运行,也完全有可能耗尽内存。此外,在耗尽内存之前,程序可能会使用大量的虚拟内存;这很容易导致“颠簸”,在这种情况下,计算机的所有时间都花在磁盘和内存之间来回传输信息(而不是做有用的工作)。这可能会产生拒绝服务攻击的效果。对输入大小进行一些合理的限制可以解决这个问题。一般来说,如果您使用动态分配的字符串,则程序必须被设计为在内存耗尽时安全地失败。
OpenBSD 正在采用的一种替代方案是由 Miller 和 de Raadt [Miller 1999] 提供的 strlcpy(3) 和 strlcat(3) 函数。这是一种极简主义的、静态大小的缓冲区方法,它提供了 C 字符串复制和连接,并具有不同的(且不易出错的)接口。这些函数的源代码和文档根据较新的 BSD 风格的开源许可证在 ftp://ftp.openbsd.org/pub/OpenBSD/src/lib/libc/string/strlcpy.3 上提供。
首先,这是它们的原型
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 复制最多 size-1 个字符到 dst,并以 NIL 结尾结果。strlcat 函数将以 NIL 结尾的字符串 src 附加到 dst 的末尾。它将最多附加 size - strlen(dst) - 1 个字节,并以 NIL 结尾结果。
strlcpy(3) 和 strlcat(3) 的一个小的缺点是它们默认情况下未安装在大多数类 Unix 系统中。在 OpenBSD 中,它们是 <string.h> 的一部分。这不是一个难题;由于它们是小型函数,您甚至可以将它们包含在您自己的程序源代码中(至少作为一种选择),并创建一个单独的小包来加载它们。您甚至可以使用 autoconf 来自动处理这种情况。如果更多程序使用这些函数,那么它们很快就会成为 Linux 发行版和其他类 Unix 系统的标准部分。此外,这些函数最近已添加到 “glib” 库中(我提交了补丁来完成此操作),因此使用 glib 的最新版本可以使它们可用。在 glib 中,这些函数被命名为 g_strlcpy 和 g_strlcat(而不是 strlcpy 或 strlcat),以与 glib 库命名约定保持一致。
此外,当提供的 size 为 0 或目标字符串 dst 中没有 NIL 字符(在给定的字符数内)时,strlcat(3) 具有略微不同的语义。在 OpenBSD 中,如果 size 为 0,则目标字符串的长度被视为 0。此外,如果 size 非零,但目标字符串中没有 NIL 字符(在 size 个字符中),则目标字符串的长度被视为等于 size。这些规则使处理没有嵌入 NIL 的字符串保持一致。不幸的是,至少 Solaris(目前)不遵守这些规则,因为这些规则没有在原始文档中指定。我已经与 Todd Miller 交谈过,他和我都同意 OpenBSD 语义是正确的(并且 Solaris 是不正确的)。理由很简单:在任何情况下,strlcat 或 strlcpy 都不应检查目标中 size 范围之外的字符;这种访问可能会导致核心转储(来自访问超出范围的内存),甚至硬件交互(通过内存映射 I/O)。因此,给定
a = strlcat ("Y", "123", 0); |
C 语言的一个工具集,可以自动动态重新分配字符串,是 Forrest J. Cavalier III 的 “libmib 分配字符串函数”,可在 http://www.mibsoftware.com/libmib/astring 上获得。libmib 有两个变体;“libmib-open” 显然是在其自己的类似 X11 的许可证下开源的,该许可证允许修改和重新分发,但重新分发必须选择不同的名称,但是,开发人员声明它“可能尚未经过充分测试”。要持续获得 libmib-mature,您必须付费订阅。文档不是开源的,但可以免费获得。
C++ 开发人员可以使用内置于语言中的 std::string 类。这是一种动态方法,因为存储空间会根据需要增长。但是,重要的是要注意,如果该类的数据被转换为 “char *”(例如,通过使用 data() 或 c_str()),缓冲区溢出的可能性会再次出现,因此您在使用此类方法时需要小心。请注意,c_str() 始终返回以 NIL 结尾的字符串,但 data() 可能不会(它依赖于实现,并且大多数实现不包括 NIL 终止符)。避免使用 data(),如果您必须使用它,请不要依赖其格式。
许多 C++ 开发人员也使用其他字符串库,例如那些随附其他大型库甚至自制字符串库的库。对于这些库,请格外小心 - 许多替代 C++ 字符串类都包含将类自动转换为 “char *” 类型的例程。因此,它们可能会静默地引入缓冲区溢出漏洞。
Arash Baratloo、Timothy Tsai 和 Navjot Singh(来自 Lucent Technologies)开发了 Libsafe,它是几个已知容易受到堆栈粉碎攻击的库函数的包装器。这个包装器(他们称之为一种“中间件”)是一个简单的动态加载库,其中包含 C 库函数的修改版本,例如 strcpy(3)。这些修改后的版本实现了原始功能,但以确保任何缓冲区溢出都包含在当前堆栈帧内的方式。他们的初步性能分析表明,该库的开销非常小。Libsafe 论文和源代码可在 http://www.research.avayalabs.com/project/libsafe 上获得。Libsafe 源代码在完全开源的 LGPL 许可证下可用。
Libsafe 的方法看起来有些用处。Linux 发行版肯定应该考虑包含 Libsafe,并且其他人也值得考虑其方法。例如,我知道 Linux 的 Mandrake 发行版(7.1 版)包含它。但是,作为软件开发人员,Libsafe 是一种有用的机制,可以支持深度防御,但它并没有真正防止缓冲区溢出。以下是您不应仅在代码开发期间依赖 Libsafe 的几个原因
Libsafe 仅保护一小部分已知函数,这些函数具有明显的缓冲区溢出问题。在撰写本文时,此列表明显短于本书中已知存在此问题的函数列表。它也不会防止您自己编写的(例如,在 while 循环中)导致缓冲区溢出的代码。
即使 libsafe 安装在发行版中,其安装方式也会影响其使用。文档建议设置 LD_PRELOAD 以启用 libsafe 的保护,但问题是用户可以取消设置此环境变量... 导致他们执行的程序的保护被禁用!
Libsafe 仅防止堆栈溢出到返回地址;您仍然可以覆盖该过程帧中的堆或其他变量。
除非您可以确信所有部署的平台都将使用 libsafe(或类似的东西),否则您必须像它不存在一样保护您的程序。
LibSafe 似乎假设保存的帧指针位于每个堆栈帧的开头。情况并非总是如此。编译器(例如 gcc)可以优化掉一些东西,特别是选项 “-fomit-frame-pointer” 会删除 libsafe 似乎需要的信息。因此,libsafe 可能无法在某些程序中工作。
libsafe 开发人员自己也承认软件开发人员不应仅仅依赖 libsafe。用他们的话说:
人们普遍认为,解决缓冲区溢出攻击的最佳方法是修复有缺陷的程序。但是,修复有缺陷的程序需要知道特定程序是有缺陷的。使用 libsafe 和其他替代安全措施的真正好处是防止未来对尚未知存在漏洞的程序进行攻击。
glib(不是 glibc)库是一个广泛可用的开源库,为 C 程序员提供了许多有用的函数。例如,GTK+ 和 GNOME 都使用 glib。正如我之前指出的,在 glib 版本 1.3.2 中,通过我提交的补丁添加了 g_strlcpy() 和 g_strlcat()。一旦这些更高版本的 glib 变得广泛可用,这应该使可移植地使用这些函数变得更容易。目前,我没有分析明确表明 glib 库函数可以防止缓冲区溢出。但是,许多 glib 函数会自动分配内存,并且这些函数会自动失败,并且没有合理的方法来拦截失败(例如,尝试其他方法)。因此,在许多情况下,大多数 glib 函数都不能在大多数安全程序中使用。GNOME 指南建议使用诸如 g_strdup_printf() 之类的函数,只要在内存不足的情况下程序立即崩溃是可以接受的,那么这很好。但是,如果您不能接受这一点,那么使用此类例程是不合适的。