在各种编程语言中,字符串的处理都是非常重要,也是非常频繁的需求。但是在C语言中,很多编译器为了保持C语言程序的高效率和低消耗,通常都不会提供边界检查等安全保障,所以,程序员一不小心就可能写出不安全的C语言代码,本文主要讨论一种常见的安全隐患。
不要使用固定长度数组,从没有固定边界的数据源接收数据
如果使用固定字符数组从数据源(例如 stdin 标准输入)拷贝字符串,就有可能发生程序异常。下面这段C语言代码就是一个例子,请看:
void get_y_or_n(void)
{
char response[8];
puts("Continue? [y] n: ");
gets(response);
if (response[0] == 'n')
exit(0);
return;
}
这段C语言代码调用了 gets() 函数,从标准输入缓冲里读取数据,并使用固定长度为 8 的数组 response 接收。读者应该明白,gets() 函数会一直读取字符,直到遇到换行符或者文件结束标志(EOF)才会返回。
显然,程序使用固定长度为 8 的数组接收输入是不安全的,因为只要用户输入超过 8 个字符,程序的表现就不可预知了,因为程序必定有数据会被多出的字符粗暴覆盖。
读者应该能够想到,限制C语言程序接收字符个数,就能解决上述安全隐患。但是遗憾的是,gets() 函数并不具备这样的功能,除非遇到换行符或者 EOF,否则它会一直从标准输入缓冲里读取字符。这一点可以从 gets() 函数的C语言源码看出:
char *gets(char *dest)
{
int c = getchar();
char *p = dest;
while (c != EOF && c != '\n') {
*p++ = c;
c = getchar();
}
*p = '\0';
return dest;
}
事实上,现在不少C语言编译器在处理上述C语言代码时,都会给出警告,因为 gets() 函数是不安全的,已经被逐步弃用了。
从一个没有确定边界的数据源(例如 stdin )读取数据是一件很能让C语言程序员头疼的事,因为我们不可能事先知道以后用户会究竟输入多少字符,所以也就不可能预先分配好恰当长度的字符数组接收用户输入。
解决上述困局的一个常用方法是:分配一个足够大(远超实际需要)的数组来接收输入。在上述C语言代码示例中,程序开发者希望用户只输入一个字符,所以申请了长度为 8 的数组 response,正常情况下,response 肯定足够接收 1 个字符。
如果用户遵守程序提示,只输入一个字符,上述C语言程序无疑可以正常工作。但是,如果用户带有恶意,固定长度的接收数组和 gets() 函数则很可能导致程序崩溃,甚至引发安全问题。所以,C语言程序员应该谨记:不要使用固定长度数组,从没有固定边界的数据源接收数据。
拷贝和连接字符串
在C语言程序开发中,拷贝和连接字符串也比较容易引发安全隐患,因为这两个操作大多时候都会使用诸如 strcpy(),strcat(),sprintf() 之类的标准库函数,这几个库函数都有类似于 gets() 函数的安全问题。
下面将以 strcpy() 为例做进一步分析。
从命令行读取的参数通常存储在程序内存里,C语言程序也可以从命令行接收参数,此时它的 main() 函数常如下定义,请看:
int main(int argc, char *argv[])
{
/** ... */
}
命令行输入的参数会被以字符串的形式传递给 main() 函数的参数 argv[0] 到 argv[argc-1]。如果 argc 大于 0,argv[0] 就是程序自己的名字。如果 argc 大于 1,那么 argv[1] 到 argv[argc-1] 就是命令行输入的实际参数了。例如:
#include <stdio.h>
int main(int argc, char *argv[])
{
int i;
printf("argc = %d, argv[0]: %s\n", argc, argv[0]);
for(i=1; i<argc; i++){
printf("argv[%d]: %s\n", i, argv[i]);
}
return 0;
}
编译并执行上述C语言代码,可以得到如下输出:
# gcc t.c
# ./a.out
argc = 1, argv[0]: ./a.out
# ./a.out arg1 arg2
argc = 3, argv[0]: ./a.out
argv[1]: arg1
argv[2]: arg2
一般来说,C语言程序会保证 argv[argc] 等于 NULL,也即以字符串结束符结尾。
如果用来接收参数的内存长度不够,C语言程序就会出现漏洞。即使是程序名本身 argv[0] 也有可能导致程序漏洞的产生,以下面这段C语言代码为例:
int main(int argc, char *argv[])
{
/* ... */
char prog_name[128];
strcpy(prog_name, argv[0]);
/* ... */
}
恶意用户可以控制 argv[0] 的内容,使其超过 128 字节,造成数据溢出,覆盖其他有用数据。也可以将 argv[0] 设置为 NULL,使程序崩溃。出现这样的安全问题,其实就是 strcpy() 不检查参数边界导致的。有些C语言编译器在遇到 strcpy() 时,会给出一个“缓冲可能溢出”的警告。
更安全的做法是通过 strlen() 得到 argv 的长度,然后分配一块恰当长度的内存区域用于拷贝。当然了,strlen() 也只能接收非空指针,所以需要先判断一下 argv 的值,相关C语言代码是下面这样的:
int main(int argc, char *argv[])
{
/* Do not assume that argv[0] cannot be null */
const char * const name = argv[0] ? argv[0] : "";
char *prog_name = (char *)malloc(strlen(name) + 1);
if (prog_name != NULL) {
strcpy(prog_name, name);
}
else {
/* Failed to allocate memory - recover */
}
/* ... */
}
小结
要是想写出安全的C语言程序,就应该遵守“不使用固定长度数组,从没有固定边界的数据源接收数据”,也不应该使用固定长度的数组拷贝不确定长度的数据,本文通过C语言代码示例较为详细的讨论了这两点。其实稍稍思考一下也应该明白:使用一个固定大小的桶去接不确定水量的水,是不能保证水不会漏出来的。