Fork me on GitHub

二进制安全之格式化字符串漏洞

相信每位程序员都写过如下代码:

1
2
3
4
5
6
#include<stdio.h>
int main()
{
printf("Hello world!");
return 0;
}

是的,这应该是每个程序员写的第一个程序,其中printf(),也是一个在C语言中的较为脆弱的函数,我们今天就来探讨一下格式化字符串漏洞。有一点要说的是由于现在的很多编译器都变得更加智能且更加注重安全性,格式化字符串等容易出现问题的函数都会由编译器自动为其添加相应的check函数从而保证函数的安全性,因此格式化字符串漏洞是由很小的可能性会出现在真实的生产环境中的,可能出现这个漏洞最多的情形就是大大小小形式各异的CTF赛题中了,但是由于此漏洞历史悠久并且较为有趣,如果产生此漏洞的话危害也不小,还是有必要学习一下的。

如果我们要实现让用户输入一段字符串并在屏幕上打印出用户输入的内容,我们可能会这样写:

1
2
3
4
5
6
#include <stdio.h>
int main(){
char buf[20];
scanf("%c",buf);
printf("%c",buf);
}

当然这样写是没有问题的,但是如果程序员偷懒写成了如下的形式:

1
2
3
4
5
6
#include <stdio.h>
int main(){
char buf[20];
scanf("%c",buf);
printf(buf);
}

程序仍然会正常运行,但是却留下了十分严重的漏洞,我们下来看一下printf()的原型:

1
int printf ( const char * format, ... );

我们可以通过一个程序来看一下此函数再处理参数时都干了些什么

1
2
3
4
5
6
#include <stdio.h>
int main(void)
{
printf("%d%d%d%d%s",0,1,2,0x16,"Hvnt3r");
return 0;
}

用gcc编译之后用gdb查看main()的汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
gdb-peda$ disass main
Dump of assembler code for function main:
0x0000000000001135 <+0>: push rbp
0x0000000000001136 <+1>: mov rbp,rsp
0x0000000000001139 <+4>: lea r9,[rip+0xec4] # 0x2004
0x0000000000001140 <+11>: mov r8d,0x16
0x0000000000001146 <+17>: mov ecx,0x2
0x000000000000114b <+22>: mov edx,0x1
0x0000000000001150 <+27>: mov esi,0x0
0x0000000000001155 <+32>: lea rdi,[rip+0xeaf] # 0x200b
0x000000000000115c <+39>: mov eax,0x0
0x0000000000001161 <+44>: call 0x1030 <printf@plt>
0x0000000000001166 <+49>: mov eax,0x0
0x000000000000116b <+54>: pop rbp
0x000000000000116c <+55>: ret
End of assembler dump.

将断点下在call 0x1030上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
[----------------------------------registers-----------------------------------]
RAX: 0x0
RBX: 0x0
RCX: 0x2
RDX: 0x1
RSI: 0x0
RDI: 0x55555555600b ("%d%d%d%d%s")
RBP: 0x7fffffffe020 --> 0x555555555170 (<__libc_csu_init>: push r15)
RSP: 0x7fffffffe020 --> 0x555555555170 (<__libc_csu_init>: push r15)
RIP: 0x555555555161 (<main+44>: call 0x555555555030 <printf@plt>)
R8 : 0x16
R9 : 0x555555556004 --> 0x25007233746e7648 ('Hvnt3r')
R10: 0x0
R11: 0x1
R12: 0x555555555050 (<_start>: xor ebp,ebp)
R13: 0x7fffffffe100 --> 0x1
R14: 0x0
R15: 0x0
EFLAGS: 0x246 (carry PARITY adjust ZERO sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0x555555555150 <main+27>: mov esi,0x0
0x555555555155 <main+32>: lea rdi,[rip+0xeaf] # 0x55555555600b
0x55555555515c <main+39>: mov eax,0x0
=> 0x555555555161 <main+44>: call 0x555555555030 <printf@plt>
0x555555555166 <main+49>: mov eax,0x0
0x55555555516b <main+54>: pop rbp
0x55555555516c <main+55>: ret
0x55555555516d: nop DWORD PTR [rax]
Guessed arguments:
arg[0]: 0x55555555600b ("%d%d%d%d%s")
arg[1]: 0x0
arg[2]: 0x1
arg[3]: 0x2
arg[4]: 0x16
arg[5]: 0x555555556004 --> 0x25007233746e7648 ('Hvnt3r')
[------------------------------------stack-------------------------------------]
0000| 0x7fffffffe020 --> 0x555555555170 (<__libc_csu_init>: push r15)
0008| 0x7fffffffe028 --> 0x7ffff7e0fb17 (<__libc_start_main+231>: mov edi,eax)
0016| 0x7fffffffe030 --> 0x0
0024| 0x7fffffffe038 --> 0x7fffffffe108 --> 0x7fffffffe421 ("/root/0101/0x06/format_x86/a.out")
0032| 0x7fffffffe040 --> 0x100040000
0040| 0x7fffffffe048 --> 0x555555555135 (<main>: push rbp)
0048| 0x7fffffffe050 --> 0x0
0056| 0x7fffffffe058 --> 0x7ed789accfb6e8e0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0x0000555555555161 in main ()
gdb-peda$

在这里可以看到参数的信息:

1
2
3
4
5
6
7
Guessed arguments:
arg[0]: 0x55555555600b ("%d%d%d%d%s")
arg[1]: 0x0
arg[2]: 0x1
arg[3]: 0x2
arg[4]: 0x16
arg[5]: 0x555555556004 --> 0x25007233746e7648 ('Hvnt3r')

printf()是C语言中少见的可以支持可变参数的库函数,当参数可变时,就没有严格的格式限制了,因此函数的调用者可以自由地向函数传递参数,但是作为被调用者的printf(),它并不知道在调用它之前有多少参数被压入了栈帧当中,也不知道这些参数的类型是什么,只会根据调用者输入的信息按部就班的执行,因此当我们输入一些非预期的参数时,就会产生漏洞。

通过格式化字符串漏洞泄露内存数据

举个例子,当我们要printf()打印出的变量的数量大于我们所给的变量的个数时,代码如下:

1
2
3
4
5
6
#include <stdio.h>
int main(void)
{
printf("%08x,%08x,%08x,%08x,%08x,%08x,%08x,%08x,%08x,%08x,%08x,%08x");
return 0;
}

此代码运行结果如下:

1
2
⚡ root@kali  ./a.out 
f58be668,f58be678,ac13f718,ac140d80,ac140d80,02a43160,abfaab17,00000000,f58be668,00040000,02a43135,00000000#

此程序打印出的这些字符串实际上是栈上的数据,这样就造成了一个内存的leak,另外,我们可以构造不同的参数来泄露指定位置的数据,如使用两个%f可以找到目标之前16个字节的位置,使用%3$s指的是第4个参数等,但是缓冲区溢出漏洞的危害不知是可以泄露内存上的数据,我们还可以利用此漏洞来修改栈上的数据

利用格式化字符串漏洞修改栈上的数据

要想利用printf()对栈上的数据进行修改,我们需要用到一个很少见的参数:%n%n 用于将当前字符串的长度打印到var中,例 printf("test %hn",&var)其中var为两个字节,printf("test %n",&var)其中var为一个字节。

举个栗子:

1
2
3
4
5
6
7
8
#include <stdio.h>
int main(void)
{
int a=0;
printf("Hvnt3r%n",&a);
printf("%d",a);
return 0;
}

运行结果为:

1
2
3
⚡ root@kali  ./a.out 
Hvnt3r
6

此时我们恶意发现我们通过printf()函数+%n参数将a的值从0改为了6,因为Hvnt3r有6个字节,发现了这一特性配合上面的内存信息泄露,我们可以精心的改造有漏洞的printf()函数来达到修改程序逻辑的目的,话不多说,我们找几个例子来看一下。

32位系统环境下的漏洞利用

64位系统环境下的漏洞利用

您的支持是我最大的动力🍉