欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页

C/C++格式化输出(printf、fprintf、sprintf、syslog等)的不安全用法

程序员文章站 2022-07-15 09:41:02
...

遇到问题

前段时间gameserver突然挂掉,查看coredump发现挂掉的位置是调用syslog,代码如下:

syslog(LOG_INFO, pszLog);

pszLog是日志的内容,当时的那局游戏中恰好有一个玩家叫做fcc%nn,而%n在C/C++格式化输出中代表的是将输出字符串到%n为止的所有字符byte数存放到一处内存中。例如:

int a;
printf("abc%ndef\n", &a);
printf("%d\n", a);

Output:
abcdef
3

刚才的那个syslog语句在输出这次对局信息时包含fcc%nn玩家的名字,等价于下面的代码:

syslog(LOG_INFO, "xxxxx fcc%nn xxxxx");

这个syslog语句并没有在之后跟着任何参数,那么这个%n会输出到某处内存上,服务器挂掉的时候收到的信号是SIGSEGV,说明访问了非法的地址,但是并不是打这条日志服务器都会挂掉,说明很多时候这个%n被输出到了一个进程合法的地址上,导致某处合法内存被修改。这种情况比进程挂掉还要可怕,因为被篡改的内存可能是和打日志完全不想干的功能,导致运行到那个功能的时候出现宕机或者是其他逻辑错误,那时根本不知道是由于这个syslog造成的。

深入研究

我想探究一下如果format字符串中含有%n,而后面没有跟任何参数时,%n的输出究竟会被输出到哪块内存上。

printf("abc%ndef\n")
syslog(LOG_INFO, "fcc%nn");

首先得先研究可变参数的传递方式,我写了如下的示例代码:

int foo(const char* a,...)
{
    return 0;
}

int main(int argc, const char** argv)
{
    foo("abc",1,2,3,4,5,6,7,8,9,10);
    return 0;
}

foo所接受的参数和printf一样,生成相应的汇编代码:

foo:
    ...
    movq    %rsi, -168(%rbp)
    movq    %rdx, -160(%rbp)
    movq    %rcx, -152(%rbp)
    movq    %r8, -144(%rbp)
    movq    %r9, -136(%rbp)
    ...
    ret

main:
    ...
    pushq   $10
    pushq   $9
    pushq   $8
    pushq   $7
    pushq   $6
    movl    $5, %r9d
    movl    $4, %r8d
    movl    $3, %ecx
    movl    $2, %edx
    movl    $1, %esi
    movl    $.LC2, %edi     ;"abc"
    movl    $0, %eax
    call    foo
    ...

从main的汇编代码中可以知道对于可变参数的前5个在x64下是直接通过5个寄存器传递的,其中rsi中存放着第一个参数,从第6个参数起通过栈来传递参数。从foo函数的汇编代码中也能看出foo会将前5个可变参数从寄存器中保存到自己栈上的临时变量区。回到之前printf的例子:

int a;
printf("abc%ndef\n", &a);
printf("a%n\n");

第一个printf的第一个可变参数是整形a的地址,通过rsi寄存器传递,3会被写入到a的地址中。第二个printf没有显式的传递一个地址,printf函数依然会读取rsi寄存器里面的值,然后把1写入到rsi里面的值所代表的内存位置,在上面这个例子中由于printf的实现里面会改变rsi的值,所以第二个printf的1并不是写入到变量a中。我们利用上面的foo来构造一个例子:

int foo(const char* a,...)
{
    return 0;
}

int main(int argc, const char** argv)
{
    int a = 100;
    printf("%d\n", a);
    foo("abc", &a);
    printf("a%n\n");
    printf("%d\n", a);
    return 0;
}

Output:
100
a
1

给foo传递a的地址时是用rsi寄存器,foo函数里面什么也没做,所以在执行接下来的printf的时候rsi里记录的任然是变量a的地址,printf的%n前只有一个“a”字符,所以把1写入到变量a中。此时成功通过printf改变了指定位置的内存。

总结

对于C/C++中格式化输出的函数直接在format参数输出字符串是很危险的。运气好只遇到%d之类的特殊字符串只会使得输出结果不是预期想要的。如果遇到了类似%s、%n那么就有可能污染内存。

printf("abc%ndef\n");
syslog(LOG_INFO, "fcc%nn");
sprintf(buf, "abc%def");

安全的用法如下:

printf("%s", "abc%ndef\n");
syslog(LOG_INFO, "%s", "fcc%nn");
sprintf(buf, "%s", "abc%def");

此时待输出的字符串中的特殊格式字符就不会被解析。