C和C++安全编码(原书第2版)
上QQ阅读APP看书,第一时间看更新

2.2.1 无界字符串复制

无界字符串复制发生于从源数据复制数据到一个定长的字符数组时(例如,从标准输入读取数据到一个定长的缓冲区中)。如例2.1所示,来自ISO/IEC TR 24731-2的附录A的一个程序利用gets()函数把字符从标准输入读入一个定长的字符数组,直到读到一个换行符或者遇到文件结束标志(EOF)为止。

例2.1 从标准输入读入


01  #include <stdio.h>
02  #include <stdlib.h>
03  
04  void get_y_or_n(void) {
05    char response[8];
06    puts("Continue? [y] n: ");
07    gets(response);
08    if (response[0] == 'n')
09      exit(0);
10    return;
11  }

本例只使用C99中的接口,尽管gets()函数在C99中已废弃并在C11中淘汰。《C安全编码标准》[Seacord 2008],“MSC34-C.不要使用废弃或过时的函数”规定,不允许使用此函数。

这个程序在MiCrosoft Visual C++2010中可以编译和运行,但在警告级别/W3下会对使用gets()发出警告。当用G++4.6.1编译时,编译器对使用gets()发出警告,但可以无错地编译。

如果在提示符下输入超过8个字符(包括空终结符),这个程序就会有不确定的行为。gets()函数的主要问题是,它没有提供方法指定读入的字符数的限制。这种限制在此函数的如下一致实现中是显而易见的:


01  char *gets(char *dest) {
02    int c = getchar();
03    char *p = dest;
04    while (c != EOF && c != '\n') {
05      *p++ = c;
06      c = getchar();
07    }
08    *p = '\0';
09    return dest;
10  }

对于程序员而言,从无界数据源(例如stdin)读入数据是一个有趣的问题。由于事先无法得知用户将会输入多少个字符,因此不可能预先分配一个长度足够的数组。常见的解决方案是静态分配一个认为长度远远大于所需的数组。在这个例子中,程序员仅仅期望用户输入1个字符,因此假设不会超过8个字节的数组长度。对于友好的用户而言,这种方式可以很好地工作。但对于那些恶意用户来说,很容易就超过一个定长字符数组的长度,从而导致发生未定义行为。在《C安全编码标准》[Seacord 2008]的“STR35-C.不要从一个无界源复制数据到定长数组”中,禁止这种方法。

复制和连接字符串。复制和连接字符串时也容易出现错误,因为执行这个功能的许多标准库调用,如strcpy()、strcat()和sprintf()函数,执行无界复制操作。

从命令行读入的参数保存在进程内存中。当程序执行时调用的main()函数,当程序接受命令行参数时,通常声明为如下格式:


1  int main(int argc, char *argv[]) {
2      /* ...*/
3  }

命令行参数作为指向从argv[0]到argv[argc-1]的数组成员并以空字符结尾的字符串指针传入main()函数。若argc大于0 [1],按照惯例,argv[0]指向的字符串是程序名。若argc大于1,从argv[0]到argv[argc-1]引用的字符串是实际程序参数。在任何条件下,argv[argc]始终保证是NULL [2]

当分配的空间不足以复制一个程序的输入(比如一个命令行参数)时,就会产生漏洞。虽然按照惯例,argv[0]包含程序名,但攻击者可以控制argv[0]的内容,在如下的程序中,提供一个超过128个字节的字符串就会造成一个漏洞。而且,攻击者还可以把argv[0]设置为NULL来调用这个程序。


1  int main(int argc, char *argv[]) {
2    /* ... */
3    char prog_name[128];
4    strcpy(prog_name, argv[0]);
5    /* ... */
6  }

这个程序可以在MiCrosoft Visual C++2012下编译并运行,但在警告级别/W3下会对使用strcpy()发出警告。这个程序也能在G++4.7.2下编译并运行,如果定义了_FORTIFY_SOURCE,那么在运行时,如果对strcpy()的调用导致了缓冲区溢出,由于对对象大小检查的结果失败,此程序会中止。

strlen()函数可用于确定由argv[0]到argv[argc-1]引用的字符串的长度,以便可动态分配足够的内存。记得要加一个字节,以容纳用于终止字符串的空字符。请注意,必须注意避免假设argv数组中的任何元素(包括argv[0])是非空的。


01  int main(int argc, char *argv[]) {
02    /* 
不要假设argv[0] 
不许为空 */
03    const char * const name = argv[0] ? argv[0] : "";
04    char *prog_name = (char *)malloc(strlen(name) + 1);
05    if (prog_name != NULL) {
06      strcpy(prog_name, name);
07    }
08    else {
09        /* 
动态分配内存失败,复原 */
10    }
11    /* ... */
12  }

strcpy()函数的使用是绝对安全的,因为目标数组已经被分配了适当的大小。但调用“更安全”的函数来取代strcpy()函数,以消除由编译器或分析工具生成的诊断消息,这么做可能仍然是可取的。

POSIX的strdup()函数也可以用于复制字符串。strdup()函数接受一个指向字符串的指针,并返回一个指向新分配的复制字符串的指针。将返回的指针传递给free(),可以回收这些内存。strdup()函数定义在ISO/IEC TR 24731-2[ISO/ IEC TR 24731-2:2010]中,但没有被包括在C99或C11标准中。

sprintf()函数。另一个经常被用来复制字符串的标准库函数是sprintf()函数。sprintf()函数在一个格式字符串的控制之下,将输出写入一个数组。被写入的字符结尾处会写入一个空字符。因为sprintf()的后续参数指定字符串转换格式,所以往往难以确定目标数组所需的最大尺寸。例如,在常见的ILP 32和LP 64平台,INT_MAX =2147483647,用一个字符串来表示int类型参数的值,它会占用11个字符(逗号不能输出,而且可能有一个减号)。浮点值的大小更是难以预料。

snprintf()函数增加了一个额外的size_t参数n。如果n为0,那么不写任何内容,目标数组可能是一个空指针。否则,超过第n-1位的输出字符将被丢弃,而不是写入数组,并在真正写入数组的字符的末尾处把一个空字符写入字符数组。如果n足够大,snprintf()函数将返回会被写入的字符数量,不计终止空字符,如果发生编码错误,则返回负值。因此,当且仅当返回值是小于n的非负整数时,空字符结尾的输出是完全写入的。snprintf()函数是一个相对安全的函数,但像其他格式的输出函数一样,它也容易产生格式化字符串漏洞。需要对snprintf()的返回值进行检查,因为函数可能会失败,这不仅是因为缓冲区空间不足,还有其他原因,如在函数执行过程中发生内存不足的状况。详情见《C安全编码标准》[Seacord 2008],“FIO04-C.检测和处理输入和输出错误”和“FIO33-C.检测和处理导致未定义行为的输入输出错误”。

无界字符串复制问题不仅存在于C语言中。举个例子,对于以下的C++程序,如果用户输入多于11个字符,也会导致写越界。


1  #include <iostream>
2
3  int main(void) {
4    char buf[12];
5
6    std::cin >> buf;
7    std::cout << "echo: " << buf << '\n';
8  }

在微软Visual C++2012中,当警告级别是/W4时,这个程序可以正确编译。在G++4.7.2中,当选项是-Wall -Wextra -pedantic时,它也可以正确编译。

标准的std::cin对象类型是std::istream类。istream类其实是std::basic_istream类模板在字符类型char上的特化。它提供了一些成员函数,以帮助从数据流缓冲区中读取和解释输入。所有格式化的输入都通过提取操作符operator>>进行。C++同时定义了成员与非成员operator>>重载操作符,包括:


istream& operator>> (istream& is, char* str);

这个操作符提取字符并将其存入str指向的数组的连续元素。当下一个元素是有效的空白或空字符,或遇到EOF标志时,提取操作结束。如果其域宽(可以用ios_base::width或setw()设置)设置为大于0的值,提取操作可以限制为只提取指定数量的字符(因而避免了可能的缓冲区溢出)。在这种情况下,提取操作在提取了比由域宽指定的数量少一个字符的时候就会终止,以便为结尾的空字符留出空间。一次提取操作调用结束后域宽自动被重置为0。并且自动在提取出来的字符串末尾附加一个空字符。

例2.2的程序通过将域宽成员设置为字符数组的长度消除了上一个例子的溢出,这个例子展示了C++提取操作不存在与C的gets()函数同样的固有缺陷。

例2.2 域宽成员


1  #include <iostream>
2
3  int main(void) {
4    char buf[12];
5
6    std::cin.width(12);
7    std::cin >> buf;
8    std::cout << "echo: " << buf << '\n';
9  }

[1] 在某些特殊情况下,如被execlp函数调用的程序,其argc 会等于0。—译者注
[2] 这里的NULL 是表示空指针。—译者注