本篇总结了《C和C++安全编码》中字符串相关的内容。
1 常见的字符串操作错误
在 C 和 C++ 中,常见的字符串操作错误有四种,分别是是无界字符串复制(unbounded string copy)、差一错误(off-by-one error)、空结尾错误(null termination error)以及字符串截断(string truncation)。
1.1 无界字符串复制(unbounded string copy)
无界字符串复制发生在从源数据复制数据到一个定长的字符数组时。从无界数据源(如cin)读入数据,由于事先无法得知用户将会输入多少个字符,因此不可能预先分配一个长度足够的数组:
1 | void test_unbounded_string_copy() |
此外,当分配的空间不足以复制一个程序的输入时,就会产生漏洞。如常用的 strcpy()
、strcat()
和 sprintf()
等函数,都会执行无界复制操作。
strcpy()
相对安全,因为目标数组已经被分配了适当的大小。snprintf()
函数同样是一个相对安全的函数,但像其它格式的输出函数一样,它也容易产生格式化字符串漏洞。需要对snprintf()
的返回值进行检查,因为函数可能会失败,这不仅是因为缓冲区空间不足,还有其它原因,如在函数执行过程中发生内存不足的状况。
1.2 差一错误(off-by-one error)
众所周知,在 C 中,字符串是一个以空字符结尾的字符数组,而 C++ 中 string 的大多数实现也是如此,而sizeof
运算返回的是不包含空字符的长度,因此在复制或申请内存时需要加1,否则将导致差一错误。
1 | void test_off_by_one_error() |
1.3 空结尾错误(null termination error)
如果一个字符串没有以空字符结尾,程序可能会被欺骗,导致在数组边界之外读取或写入数据。这就是空结尾错误。字符串必须在数组的最后一个元素的地址处或在它之前包含一个空终止字符,才可以安全地作为标准字符串处理函数如strcpy()
函数或strlen()
函数的参数被传递。空终止字符之所以是必要的,是因为前面这些函数以及其它由 C 标准定义的字符串处理函数,都依赖于它的存在来标记字符串的结尾。同样,如果程序对一个字符数组迭代循环的终止条件取决于为字符串分配的内存是否存在一个空终止字符,字符串也必须以空字符结尾。
1 | void test_null_termination_error() |
1.4 字符串截断(string truncation)
当目标字符数组的长度不足以容纳一个字符串的内容时,就会发生字符串截断。截断通常发生于读取用户输入或字符串复制时,通常是程序员试图防止缓冲区溢出的结果。尽管没有缓冲区溢出危害那么大,但字符串截断会丢失数据,有时也会导致软件漏洞。
2 字符串漏洞缓解策略
当在为一个特定数据结构分配的内存的边界之外写入数据时,就会发生缓冲区溢出。缓冲区溢出在 C 和 C++ 程序中司空见惯,因为这两种语言:
- 将字符串定义为以空字符结尾的字符数组;
- 不进行隐式的边界检查;
- 提供了不执行边界检查的标准字符串调用库。
在 C++ 中,使用 string 类是一个方便的选择,string 类已经为上面的问题提供了处理办法,因此编程时无需关注上述问题。但使用 string 类需要格外注意迭代器失效的问题:
1 | void test_string_reference_invalid() |
3 字符串处理函数
gets():永远不要使用,因为它不对缓冲区溢出进行任何检测。可以用 fgets() 或 getchar() 取代 gets()。
fgets():接受两个额外的参数:期望读入的字符数和输入流。与 gets() 不同,fgets() 函数保留换行符。当使用 fgets() 时,可能只读取了一行的部分数据,然而,可以确定用户的输入是否被截断了,因为那样的话,输入缓冲区内将不会包含一个换行符。fget() 函数从流中最多读入比指定数量少 1 个的字符到一个数组中。如果遇到换行符或者 EOF 标志,则不会继续读取。在最后一个字符读入数组中后,一个空字符随即被写入缓冲区的结尾处。
getchar():返回 stdin 指向的输入流中的下一个字符。如果流在 EOF 处,则该流的 EOF 标记就会被设置,且 getchar() 返回 EOF。如果发生读取错误,则该流的错误标记就会被设置,且 getchar() 返回 EOF。
gets_s():gets_s() 函数是 gets() 的一个兼容且更安全的版本。它只从 stdin 指向的流中读取,且不保留换行符。gets_s() 函数接受一个额外的参数 rsize_t,用于指定输入的最大字符数。如果这个参数等于 0 或者比 RSIZE_MAX 更大,或者目标字符数组指针为 NULL,将产生一个错误条件。如果产生了错误条件,那么将不会有任何的输入动作,并且目标字符数组将不会被更改。否则,该函数最多读入比指定数量少 1 的字符,并且在最后一个字符读入数组后立即在其后加上空字符。如果 gets_s() 函数执行成功,则返回一个指向字符数组的指针,否则返回一个空指针。如果指定的输入字符数超过目标缓冲区的长度,那么 gets_s() 函数仍然可能导致缓冲区溢出。
strcpy() 和 strcat():是缓冲区溢出的频繁来源,因为它们不允许调用者指定目标数组的大小,许多预防策略都建议使用这些函数的更安全的变种。strcpy_s() 和 strcat_s() 函数被定义为与 strcpy() 和 strcat() 函数非常接近的替代函数。strcpy_s() 函数有一个额外的参数,用于给定目标数组的大小,来防止缓冲区溢出。strcpy_s() 仅在源字符串可被完全复制到目标缓冲区且不引起目标缓冲区溢出的情况下才会调用成功。strcpy_s() 函数执行各种运行时约束。strcat_s() 函数将源字符串中的字符追加到目标字符串的末尾,直至遇到空结束符为止,并且追加的字符包含结尾的空字符。在没有正确指定目标缓冲区的最大长度的情况下,strcpy_s()和strcat_s()仍然可能会引起缓冲区溢出的问题。
strncpy() 和 strncat():与 strcpy() 和 strcat() 函数类似,但每个函数都有一个额外的 size_t 类型的参数 n 用于限制要被复制的字符数量。这些函数可以被认为是截断型的复制和拼接函数。strncpy_s() 函数有一个额外的参数用于给出目标数组的大小,以防止缓冲区溢出。如果发生运行时约束违反,则目标数组被设置为空字符串,以增加问题的能见度。strncpy_s() 函数返回 0 表示成功。因为 strncpy() 函数不能保证用空字符终止目标字符串,所有程序员必须小心,以确保目标字符串是正确地以空字符终止的,并且没有覆盖最后一个字符。C 标准的 strncpy() 函数经常被推荐为 strcpy() 函数”更安全”的替代品,然而,strncpy() 容易发生串终止错误。
strncat(char* s1, const char* s2, size_t n)函数:从 s2 指向的数组追加不超过 n 个字符(空字符和它后面的字符不追加)到 s1 指向的字符串结尾。s2 最初的字符覆盖了 s1 末尾的空字符。终止空字符总是被附加到结果字符串。因此,在 s1 指向的数组中的最大字符数量是 strlen(s1)+n+1。必须谨慎使用 strncpy() 和 strncat() 函数,或根本不使用它们,尤其是在有更不易出错的替代品的时候。这两个函数都要求指定剩余的长度而不是缓冲区的总长度。由于剩余的长度在每次添加或删除数据时都会改变,因此程序员必须跟踪这些改变或重新计算剩余长度。这个过程很容易出错,并且可能会导致漏洞。
memcpy() 和 memmove():memcpy_s() 和 memmove_s() 函数与相应的安全性较低的 memcpy() 和 memmove() 函数类似,但提供了一些额外的保障。为了防止缓冲区溢出,memcpy_s() 和 memmove_s() 函数具有额外的参数来指定目标数组的大小。
strlen():没有特别的缺陷,但由于底层字符串表示的弱点,它的操作可能被破坏。strlen() 函数接受一个指向一个字符数组的指针,并返回终止空字符之前的字符数量。如果字符数组不是正确地以空字符结尾的,strlen() 函数可能会返回一个错误的超大的数值,使用它时,就可能会导致漏洞。在将字符串传递给 strlen() 函数之前,有必要确保它们是正确地以空值结尾的,从而使函数的结果在预期范围内。