C和C++安全编码笔记:格式化输出

C标准中定义了一些可以接受可变数量参数的格式化输出参数,参数中包括一个格式字符串。printf()和sprintf()都是格式化输出函数的例子。格式化输出函数是由一个格式字符串和可变数目的参数构成的。在效果上,格式化字符串提供了一组可以由格式化输出函数解释执行的指令。因此,用户可以通过控制格式字符串的内容来控制格式化输出函数的执行。格式化输出函数是一个变参函数,也就是说它接受的参数个数是可变的。变参函数在C语言中实现的局限性导致格式化输出函数的使用中容易产生漏洞。

6.1 变参函数:头文件声明了一种类型并定义了四个宏,用于传递一组参数列表,在编译时被调用的函数对这些参数的数量和类型是不了解的。变参函数是通过使用一个部分参数列表后跟一个省略号进行声明的省略号必须出现在参数列表的最后。参数列表的终止条件是函数的实现者和使用者之间的一个契约

int average(int first, ...)
{
	va_list marker;
	// 在使用变量marker之前,首先必须调用va_start()对参数列表进行初始化
	// 定参first允许vs_start()决定第一个变参的位置
	va_start(marker, first);

	int count = 0, sum = 0, i = first;
	while (i != -1) {
		sum += i;
		count++;
		// va_arg()需要一个已初始化的va_list和下一个参数的类型.
		// 这个宏可以根据类型的大小返回下一个参数,并且相应地递增参数指针
		i = va_arg(marker, int);
	}

	// 在函数返回之前,调用va_end()来执行任何必要的清理工作
	// 若在返回前未调用va_end()宏,则行为是未定义的
	va_end(marker);
	return (sum ? (sum / count) : 0);
}

void test_format_output_variable_parameter_function()
{
	int ret = average(3, 5, 8, -1);
	fprintf(stdout, "average: %d\n", ret);
}

更多变参函数的介绍可参考:https://blog.csdn.net/fengbingchun/article/details/78483471

6.2 格式化输出函数:C标准中定义的格式化输出函数如下所示:

(1).fprintf():按照格式字符串的内容将输出写入流中。流、格式字符串和变参列表一起作为参数提供给函数。

(2).printf():等同于fprintf(),除了前者假定输出流为stdout外。

(3).sprintf():等同于fprintf(),但是输出不是写入流而是写入数组中。C标准规定在写入的字符末尾必须添加一个空字符。

(4).snprintf():等同于spirntf(),但是它指定了可写入字符的最大值n。当n非零时,输出的字符超过第n-1个的部分会被舍弃而不会写入数组中。并且,在写入数组的字符末尾会添加一个空字符。

(5).vfprintf()、vprintf()、vsprintf()、vsnprintf():分别对应于fprintf()、printf()、sprintf()、snprintf(),只是它们将后者的变参列表换成了va_list类型的参数。当参数列表是在运行时决定时,这些函数非常有用。

格式字符串:是由普通字符(ordinary character)(包括%)和转换规范(conversion specification)构成的字符序列。普通字符被原封不动地复制到输出流中。转换规范根据与实参对应的转换指示符对其进行转换,然后将结果写入输出流中。转换规范通常以”%”开始按照从左向右的顺序解释。大多数转换规范都需要单个参数,但有时也可能需要多个或者完全不需要。程序员必须根据指定的格式提供相应个数的参数。当参数多于转换规范时,多余的将被忽略,而当参数不足时,则结果是未定义的

一个转换规范是由可选域(标志、宽度、精度以及长度修饰符)和必须域(转换指示符)按照下面的格式组成的:

%[标志][宽度][.精度][{长度修饰符}] 转换指示符

例如,对转换规范%-10.8ld来说,-是标志位,10代表宽度,8代表精度,字面l是长度修饰符,d是转换指示符。这个转换规范将一个long int型的参数按照十进制格式打印,在一个最小宽度为10个字符的域中保持最少8位左对齐。每一个域都是代表特定格式选项的单个字符或数字。最简单的转换规范仅仅包含一个”%”和一个转换指示符(例如%s)。

转换指示符:用来指示所应用的转换类型。它是唯一必须的格式域,出现在任意可选格式域之后。下表中列举了C标准中的一些转换指示符:

C和C++安全编码笔记:格式化输出_第1张图片

标志:标志位用来调整输出和打印的符号、空白、小数点、八进制和十六进制前缀等。一个格式规范中可能包含一个或多个标志。

宽度:是一个用来指定输出字符的最小个数的十进制非负整数。如果输出的字符个数比指定的宽度小,就用空白字符补足。如果指定的宽度较小也不会引起输出域的截断。如果转换的结果比域宽大,则域会被扩展以容纳转换结果。如果使用星号(*)来指定宽度,则宽度将由参数列表中的一个int型的值提供。在参数列表中,宽度参数必须置于被格式化的值之前。

精度:是用来指示打印字符个数、小数位或者有效数字个数的非负十进制整数。与宽度域不同,精度域可能会引起输出的截断或浮点值的舍入。如果精度值被设为0并且被转换值也为0,则不会输出任何字符。如果精度域是一个星号(*),那么它的值就由参数列表中的一个int参数提供。在参数列表中,精度参数必须置于被格式化的值之前。

长度修饰符:指定了参数的大小。下表列举了长度修饰符及其对应的含义。如果使用了表中未出现的长度修饰符和转换指示符的组合,则会导致未定义的行为:

C和C++安全编码笔记:格式化输出_第2张图片

6.3 对格式化输出函数的漏洞利用:当使用的格式字符串(或部分字符串)是由用户或其他非信任来源提供的时候,就有可能出现格式字符串漏洞。

缓冲区溢出:向字符数组中写入数据的格式化输出函数(如sprintf())会假定存在任意长度的缓冲区,从而导致它们易于造成缓冲区溢出。

void test_format_output_buffer_overflow()
{
{
	char* user = "abcd"; // 用户提供的字符串(可能是恶意的数据)
	char buffer[512];
	// 用户提供的字符串写入一个固定长度的缓冲区
	// 任何长度大于495字节的字符串都会导致越界写(512字节-16个字符字节-1个空字节)
	sprintf(buffer, "Wrong command: %s\n", user);
	fprintf(stdout, "buffer: %s\n", buffer);
}

{
	char* user = "%497d\x3c\xd3\xff\xbf";
	fprintf(stdout, "user: %s\n", user);
	char outbuf[512], buffer[512];

	sprintf(buffer, "ERR Wrong command: %.400s", user);
	fprintf(stdout, "buffer: %s\n", buffer);
	// 格式规范%497d指示函数sprintf()从栈中读出一个假的参数并向缓冲区中写入497个字符,包括格式字符串中的普通字符
	// 在内,现在写入的字符总数已经超过了outbuf的长度4个字节
	// 用户输入可被操纵用于覆写返回地址,也就是拿恶意格式字符串参数中提供的利用代码的地址(0xbfffd33c)去覆写该
	// 地址.在当前函数退出时,控制权将以与栈溢出攻击相同的方式转移给漏洞利用代码
	sprintf(outbuf, buffer);
	fprintf(stdout, "outbuf: %s\n", outbuf);
}
}

输出流:将结果输出到流而不是输出到文件中的格式化输出函数(例如printf())也可能会导致格式字符串漏洞。

使程序崩溃:格式字符串漏洞通常是在程序崩溃的时候才被发现。在大多数UNIX系统中,存取无效的指针会引起进程收到SIGSEGV信号。除非能够捕捉并处理它,否则程序将会非正常终止并导致核心转储(dump core)。与之类似,在Windows中读取一个未映射的地址将会导致系统的一般保护错误(general protection fault)并导致程序非正常终止。

查看栈内容:攻击者还可以利用格式化输出函数来检查内存的内容。这类信息往往被用于进一步的漏洞利用。

查看内存内容:攻击者可以使用一个”显示指定地址的内存”的格式规范来查看任意地址的内存。例如,转换指示符%s显示参数指针所指定的地址的内存,将它作为一个ASCII字符串处理,直至遇到一个空字符。如果攻击者能够通过操作这个参数指针来”引用”一个特定的地址,那么转换指示符%s将会输出该位置的内存内容。

覆写内存:

void test_format_output_overwrite_memory()
{
{ // 向各种类型和大小的整数变量写入输出的字符数
	char c;
	short s;
	int i;
	long l;
	long long ll;

	// 最初转换指示符%n是用来帮助排列格式化输出字符串的. 它将字符数目成功地输出到以参数的形式
	// 提供的整数地址中
	printf("hello %hhn.", &c);
	printf("hello %hn.", &s);
	printf("hello %n.", &i);
	printf("hello %ln.", &l);
	printf("hello %lln.", &ll);
	fprintf(stdout, "c: %d, s: %d, i: %d, l: %ld, ll: %lld\n", c, s, i, l, ll); // 6
}

{
	// 格式化输出函数写入的字符个数是由格式字符串决定的.如果攻击者能够控制格式字符串,那么他就能通过使用
	// 具有具体的宽度或精度的转换规范来控制写入的字符个数
	// 每一个格式字符串都耗用两个参数,第一个参数是转换指示符%u所使用的整数值,输出的字符个数(一个整数值)
	// 则被写入由第二个参数指定的地址中
	int i;
	printf("%10u%n", 1, &i); fprintf(stdout, "i: %d\n", i); // 10
	printf("%100u%n", 1, &i); fprintf(stdout, "i: %d\n", i); // 100
}

{
	// 在对格式化输出函数的单次调用中,还可以执行多次写
	int i, j, m, n;
	// 第一个%16u%n字符序列向指定地址中写入的值是16,但第二个%16u%n则写32字节,因为计数器没有被重置
	printf("%16u%n%16u%n%32u%n%64u%n", 1, &i, 1, &j, 1, &m, 1, &n);
	fprintf(stdout, "i: %d, j: %d, m: %d, n: %d\n", i, j, m, n); // 16, 32, 64, 128
}
}

国际化:出于国际化的考虑,格式字符串和消息文本通常被移动到由程序在运行时打开的外部目录或文件中。格式字符串是必要的,因为不同的区域设置之间,参数的顺序可能会有所不同。这也意味着使用目录的程序必须传递格式字符串的变量。因为这是格式化输出函数合法和必要的使用,所以在格式字符串不是文本的情况下诊断可能会导致过度误报。攻击者可以通过修改这些文件的内容从而改动程序的格式和字符串的值。因此,应该对这些文件加以保护,以防止其内容被非法改变。同时,我们还必须防止攻击者使用自己的消息文件来替换正常的文件。这可以通过设置查找路径、环境变量或逻辑名字来限制存取。

宽字符格式字符串漏洞:宽字符格式化输出函数易招致格式字符串和缓冲区溢出漏洞,它的利用方式与窄字符格式化输出函数类似,即使在从ASCII转换为Unicode字符串的特殊情况下。

6.4 栈随机化:许多Linux变体(例如Red Hat、Debian和OpenBSD)中包含某种栈随机化机制。这种机制使得很难预测栈上信息的位置,包括返回地址和自动变量的位置,这是通过向栈中插入随机的间隙实现的。

阻碍栈随机化:尽管栈随机化加大了漏洞利用的难道,但它并不能完全阻止漏洞利用的发生。

直接参数访问:C标准不支持直接参数访问。转换规范%n$中的参数数字n必须是这样的一个整数值:其值必须介于1和提供给函数调用的参数的最大数目之间。

void test_format_output_direct_parameter_access()
{
	// 在包含%n$形式的转换规范的格式字符串中,参数列表中的数字式参数可视需要被从格式字符串中引用多次, 其中n是一个
	// 1~{NL_ARGMAX}范围内的十进制整数,它指定了参数的位置
	// 展示了%n$形式的规范转换是如果被用于格式字符串漏洞利用的
	int i, j, k;
	// 第一个转换规范%4$5u获得第四个参数(即常量5),并将输出格式为无符号的十进制整数,宽度为5.第二个转换规范%3$n,
	// 将当前输出计数器的值(5)写到第三个参数(&i)所指定的地址
	printf("%4$5u%3$n%5$5u%2$n%6$5u%1$n\n", &k, &j, &i, 5, 6, 7);
	fprintf(stdout, "i = %d, j = %d, k = %d\n", i, j, k); // i=5, j=10, k=15
}

6.5 缓解策略:

排除用户输入的格式字符串。

静态内容的动态使用:有一个消除格式字符串漏洞的常见建议是禁止动态格式字符串的使用。如果所有格式字符串都是静态的,那么格式字符串漏洞将不复存在(除非目标字符数组没有得到足够的限制而造成缓冲区溢出)。然而,这种方案不切实际,因为动态格式字符串已被现有代码广泛使用。动态格式策略的一个可选替代品是对静态内容的动态使用。

void test_format_output_dynamic_format_string()
{
	int x = 2, y = 3;
	static char format[256] = "%d * %d = ";

	strcat(format, "%d\n");
	printf(format, x, y, x * y); // 2 * 3 = 6
}

限制字节写入:当被误用的时候,格式化输出函数容易造成格式字符串漏洞和缓冲区溢出漏洞。缓冲区溢出可以通过严格控制这些函数写入的字节数来避免。写入的字节数可以通过指定一个精度域作为%s转换规范的一部分进行控制。

void test_format_output_limit_bytes_number()
{
	char buffer[512];
	char* user = "abc";
	sprintf(buffer, "Wrong command: %s\n", user); // 不推荐
	// 精度域指定了针对%s转换所要写入的最大字节数
	sprintf(buffer, "Wrong command: %.495s\n", user); // 推荐
}

另一种方式是使用更安全版本的格式化输出库函数,它们不容易产生缓冲区溢出问题(例如,采用snprintf()和vsnprintf()代替sprintf()和vsprintf())。这些函数指定了写入的最大字节数(包括末尾的空字节在内)。

C11附录K边界检查接口:C11标准添加了一个新的可选的规范性附录,它包括更安全版本的格式化输出函数。这些具有增强的安全性的函数包括fprintf_s()、printf_s()、snprintf_s()、sprintf_s()、vfprintf_s()、vprintf_s()、vsnprintf_s()、vsprintf_s()以及它们的宽字符版本。这些格式化输出函数有着不带_s后缀的原型对应物,但sprintf_s()和vsprintf_s()除外,二者对应的原型是snprintf()和vsnprintf()。这些安全性函数与不带_s后缀的对应物的区别在于,如果格式字符串为空指针,或者如果格式字符串中包含指示符%n(无论是否被标志、域、宽度或精度修改),或者如果在这些函数中对应一个%s指示符的参数是空指针的话,那么它们会将其视作运行时约束错误(runtime constraint error)。如果在格式字符串中后续出现的字符%n不被解释为一个%n指示符,例如,如果整个格式字符串是%%n时,就不构成运行时约束违例(runtime constraint violation)。尽管这些函数是对现有C标准函数的改进,即可以防止写内存,但它们无法防止格式字符串漏洞,这些漏洞使程序崩溃,或被用于查看内存内容。因此,当使用这些函数时,有必要像在使用不带_s后缀的格式化输出函数一样保持警惕。

iostream与stdio:iostream库提供了通过流来实现输入、输出的功能。格式化输出使用iostream依照中缀二元插入操作符”<<”进行实现。左操作数是待插入数据的流,右操作数则是要插入的值。格式化和标记化(tokenized)输入是通过提取操作符”>>”实现的。标准的C I/O流stdin、stdout和stderr被cin、cout和cerr所取代。除了提供类型安全和可扩展性之外,iostream库在安全性方面做得要比stdio好得多。

编译器检查:GNU C编译器(GCC)提供了对格式化输出函数调用进行附加检测的标志。Visual C++中没有这样的选项。GCC的标志包括-Wformat、-Wformat-nonliteral以及-Wformat-security。

FormatGuard:是一个编译器修改器,通过插入代码实现动态检测,并且拒绝那些参数个数与转换规范所指定个数不匹配的格式化输出函数调用。

静态二进制分析:按照如下标准,可以通过分析二进制映像来发现格式化字符串漏洞:栈修正是否比最小值还小?格式化字符串是静态的还是可变的?

为了消除格式字符串漏洞,推荐在可能的情况下使用iostream代替stdio,在没有条件的情况下则要尽量使用静态格式字符串。当需要动态字符串的时候,最关键的是不要将来自非信任源的输入合并到格式字符串中。尽量使用C11附录K”边界检查接口”中定义的格式化输出函数来代替不带_s后缀的格式化输出函数,如果你的实现支持它们的话。

以上代码段的完整code见:GitHub/Messy_Test

GitHub:https://github.com/fengbingchun/Messy_Test

你可能感兴趣的:(C/C++/C++11)