CTF-All-In-One/doc/3.3.1_format_string.md
2017-08-20 17:23:48 +08:00

12 KiB
Raw Blame History

格式化字符串漏洞

格式化输出函数和格式字符串

在 C 语言基础章节中,我们详细介绍了格式化输出函数和格式化字符串的内容。在开始探索格式化字符串漏洞之前,强烈建议回顾该章节。这里我们简单回顾几个常用的。

函数

#include <stdio.h>

int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

转换指示符

字符 类型 使用
d 4-byte Integer
u 4-byte Unsigned Integer
x 4-byte Hex
s 4-byte ptr String
c 1-byte Character

长度

字符 类型 使用
hh 1-byte char
h 2-byte short int
l 4-byte long int
ll 8-byte long long int

示例

#include<stdio.h>
#include<stdlib.h>
void main() {
    char *format = "%s";
    char *arg1 = "Hello World!\n";
    printf(format, arg1);
}
printf("%03d.%03d.%03d.%03d", 127, 0, 0, 1);    // "127.000.000.001"
printf("%.2f", 1.2345);   // 1.23
printf("%#010x", 3735928559);   // 0xdeadbeef

printf("%s%n", "01234", &n);  // n = 5

格式化字符串漏洞基本原理

在 x86 结构下,格式字符串的参数是通过栈传递的,看一个例子:

#include<stdio.h>
void main() {
	printf("%s %d %s", "Hello World!", 233, "\n");
}
gdb-peda$ disassemble main
Dump of assembler code for function main:
   0x0000053d <+0>:	lea    ecx,[esp+0x4]
   0x00000541 <+4>:	and    esp,0xfffffff0
   0x00000544 <+7>:	push   DWORD PTR [ecx-0x4]
   0x00000547 <+10>:	push   ebp
   0x00000548 <+11>:	mov    ebp,esp
   0x0000054a <+13>:	push   ebx
   0x0000054b <+14>:	push   ecx
   0x0000054c <+15>:	call   0x585 <__x86.get_pc_thunk.ax>
   0x00000551 <+20>:	add    eax,0x1aaf
   0x00000556 <+25>:	lea    edx,[eax-0x19f0]
   0x0000055c <+31>:	push   edx
   0x0000055d <+32>:	push   0xe9
   0x00000562 <+37>:	lea    edx,[eax-0x19ee]
   0x00000568 <+43>:	push   edx
   0x00000569 <+44>:	lea    edx,[eax-0x19e1]
   0x0000056f <+50>:	push   edx
   0x00000570 <+51>:	mov    ebx,eax
   0x00000572 <+53>:	call   0x3d0 <printf@plt>
   0x00000577 <+58>:	add    esp,0x10
   0x0000057a <+61>:	nop
   0x0000057b <+62>:	lea    esp,[ebp-0x8]
   0x0000057e <+65>:	pop    ecx
   0x0000057f <+66>:	pop    ebx
   0x00000580 <+67>:	pop    ebp
   0x00000581 <+68>:	lea    esp,[ecx-0x4]
   0x00000584 <+71>:	ret    
End of assembler dump.
gdb-peda$ s
[----------------------------------registers-----------------------------------]
EAX: 0x56557000 --> 0x1efc
EBX: 0x56557000 --> 0x1efc
ECX: 0xffffd250 --> 0x1
EDX: 0x5655561f ("%s %d %s")
ESI: 0xf7f95000 --> 0x1bbd90
EDI: 0x0
EBP: 0xffffd238 --> 0x0
ESP: 0xffffd220 --> 0x5655561f ("%s %d %s")
EIP: 0x56555572 (<main+53>:	call   0x565553d0 <printf@plt>)
EFLAGS: 0x216 (carry PARITY ADJUST zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x56555569 <main+44>:	lea    edx,[eax-0x19e1]
   0x5655556f <main+50>:	push   edx
   0x56555570 <main+51>:	mov    ebx,eax
=> 0x56555572 <main+53>:	call   0x565553d0 <printf@plt>
   0x56555577 <main+58>:	add    esp,0x10
   0x5655557a <main+61>:	nop
   0x5655557b <main+62>:	lea    esp,[ebp-0x8]
   0x5655557e <main+65>:	pop    ecx
Guessed arguments:
arg[0]: 0x5655561f ("%s %d %s")
arg[1]: 0x56555612 ("Hello World!")
arg[2]: 0xe9
arg[3]: 0x56555610 --> 0x6548000a ('\n')
[------------------------------------stack-------------------------------------]
0000| 0xffffd220 --> 0x5655561f ("%s %d %s")
0004| 0xffffd224 --> 0x56555612 ("Hello World!")
0008| 0xffffd228 --> 0xe9
0012| 0xffffd22c --> 0x56555610 --> 0x6548000a ('\n')
0016| 0xffffd230 --> 0xffffd250 --> 0x1
0020| 0xffffd234 --> 0x0
0024| 0xffffd238 --> 0x0
0028| 0xffffd23c --> 0xf7df1253 (<__libc_start_main+243>:	add    esp,0x10)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x56555572 in main ()
gdb-peda$ r
Continuing
Hello World! 233
[Inferior 1 (process 27416) exited with code 022]

根据 cdecl 的调用约定,在进入 printf() 函数之前,将参数从右到左依次压栈。进入 printf() 之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是 %,字符直接复制到输出中。否则,读取下一个非空字符,获取相应的参数并解析输出。(注意:% d%d 是一样的)

接下来我们修改一下上面的程序,给格式字符串加上 %x %x %x %3$s,使它出现格式化字符串漏洞:

#include<stdio.h>
void main() {
	printf("%s %d %s %x %x %x %3$s", "Hello World!", 233, "\n");
}

反汇编后的代码同上,没有任何区别。我们主要看一下参数传递:

gdb-peda$
[----------------------------------registers-----------------------------------]
EAX: 0x56557000 --> 0x1efc
EBX: 0x56557000 --> 0x1efc
ECX: 0xffffd250 --> 0x1
EDX: 0x5655561f ("%s %d %s %x %x %x %3$s")
ESI: 0xf7f95000 --> 0x1bbd90
EDI: 0x0
EBP: 0xffffd238 --> 0x0
ESP: 0xffffd220 --> 0x5655561f ("%s %d %s %x %x %x %3$s")
EIP: 0x56555572 (<main+53>:	call   0x565553d0 <printf@plt>)
EFLAGS: 0x216 (carry PARITY ADJUST zero sign trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x56555569 <main+44>:	lea    edx,[eax-0x19e1]
   0x5655556f <main+50>:	push   edx
   0x56555570 <main+51>:	mov    ebx,eax
=> 0x56555572 <main+53>:	call   0x565553d0 <printf@plt>
   0x56555577 <main+58>:	add    esp,0x10
   0x5655557a <main+61>:	nop
   0x5655557b <main+62>:	lea    esp,[ebp-0x8]
   0x5655557e <main+65>:	pop    ecx
Guessed arguments:
arg[0]: 0x5655561f ("%s %d %s %x %x %x %3$s")
arg[1]: 0x56555612 ("Hello World!")
arg[2]: 0xe9
arg[3]: 0x56555610 --> 0x6548000a ('\n')
[------------------------------------stack-------------------------------------]
0000| 0xffffd220 --> 0x5655561f ("%s %d %s %x %x %x %3$s")
0004| 0xffffd224 --> 0x56555612 ("Hello World!")
0008| 0xffffd228 --> 0xe9
0012| 0xffffd22c --> 0x56555610 --> 0x6548000a ('\n')
0016| 0xffffd230 --> 0xffffd250 --> 0x1
0020| 0xffffd234 --> 0x0
0024| 0xffffd238 --> 0x0
0028| 0xffffd23c --> 0xf7df1253 (<__libc_start_main+243>:	add    esp,0x10)
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x56555572 in main ()
gdb-peda$ c
Continuing.
Hello World! 233
 ffffd250 0 0
[Inferior 1 (process 27480) exited with code 041]

这一次栈的结构和上一次相同,只是格式字符串有变化。程序打印出了七个值(包括换行),而我们其实只给出了前三个值的内容,后面的三个 %x 打印出了 0xffffd230~0xffffd238 栈内的数据,这些都不是我们输入的。而最后一个参数 %3$s 是对 0xffffd22c\n 的重用。

上一个例子中,格式字符串中要求的参数个数大于我们提供的参数个数。在下面的例子中,我们省去了格式字符串,同样存在漏洞:

#include<stdio.h>
void main() {
    char buf[50];
    if (fgets(buf, sizeof buf, stdin) == NULL)
        return;
    printf(buf);
}
gdb-peda$
[----------------------------------registers-----------------------------------]
EAX: 0xffffd1fa ("Hello %x %x %x !\n")
EBX: 0x56557000 --> 0x1ef8
ECX: 0xffffd1fa ("Hello %x %x %x !\n")
EDX: 0xf7f9685c --> 0x0
ESI: 0xf7f95000 --> 0x1bbd90
EDI: 0x0
EBP: 0xffffd238 --> 0x0
ESP: 0xffffd1e0 --> 0xffffd1fa ("Hello %x %x %x !\n")
EIP: 0x5655562a (<main+77>:	call   0x56555450 <printf@plt>)
EFLAGS: 0x296 (carry PARITY ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
   0x56555623 <main+70>:	sub    esp,0xc
   0x56555626 <main+73>:	lea    eax,[ebp-0x3e]
   0x56555629 <main+76>:	push   eax
=> 0x5655562a <main+77>:	call   0x56555450 <printf@plt>
   0x5655562f <main+82>:	add    esp,0x10
   0x56555632 <main+85>:	jmp    0x56555635 <main+88>
   0x56555634 <main+87>:	nop
   0x56555635 <main+88>:	mov    eax,DWORD PTR [ebp-0xc]
Guessed arguments:
arg[0]: 0xffffd1fa ("Hello %x %x %x !\n")
[------------------------------------stack-------------------------------------]
0000| 0xffffd1e0 --> 0xffffd1fa ("Hello %x %x %x !\n")
0004| 0xffffd1e4 --> 0x32 ('2')
0008| 0xffffd1e8 --> 0xf7f95580 --> 0xfbad2288
0012| 0xffffd1ec --> 0x565555f4 (<main+23>:	add    ebx,0x1a0c)
0016| 0xffffd1f0 --> 0xffffffff
0020| 0xffffd1f4 --> 0xffffd47a ("/home/firmy/Desktop/RE4B/c.out")
0024| 0xffffd1f8 --> 0x65485ea0
0028| 0xffffd1fc ("llo %x %x %x !\n")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
0x5655562a in main ()
gdb-peda$ c
Continuing.
Hello 32 f7f95580 565555f4 !
[Inferior 1 (process 28253) exited normally]

如果大家都是好孩子,输入正常的字符,程序就不会有问题。由于没有格式字符串,如果我们在 buf 中输入一些转换指示符,则 printf() 会把它当做格式字符串并解析,漏洞发生。例如上面演示的我们输入了 Hello %x %x %x !\n(其中 \nfgets() 函数给我们自动加上的),这时,程序就会输出栈内的数据。

我们可以总结出,其实格式字符串漏洞发生的条件就是格式字符串要求的参数和实际提供的参数不匹配。下面我们讨论两个问题:

  • 为什么可以通过编译?
    • 因为 printf() 函数的参数被定义为可变的。
    • 为了发现不匹配的情况,编译器需要理解 printf() 是怎么工作的和格式字符串是什么。然而,编译器并不知道这些。
    • 有时格式字符串并不是固定的,它可能在程序执行中动态生成。
  • printf() 函数自己可以发现不匹配吗?
    • printf() 函数从栈中取出参数,如果它需要 3 个,那它就取出 3 个。除非栈的边界被标记了,否则 printf() 是不会知道它取出的参数比提供给它的参数多了。然而并没有这样的标记。

格式化字符串漏洞

通过提供和格式字符串,我们就能够控制格式化函数的行为。漏洞的利用主要有下面几种。

使程序崩溃

格式话字符串漏洞通常要在程序崩溃时才会被发现,所以利用格式化字符串漏洞最简单的方式就是使进程崩溃。在 Linux 中,存取无效的指针会引起进程收到 SIGSEGV 信号,从而使程序非正常终止并产生核心转储(在 Linux 基础的章节中详细介绍了核心转储)。我们知道核心转储中存储了程序崩溃时的许多重要信息,这些信息正是攻击者所需要的。

利用类似下面的格式字符串即可触发漏洞:

printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s")
  • 对于每一个 %sprintf() 都要从栈中获取一个数字,把该数字视为一个地址,然后打印出地址指向的内存内容,直到出现一个 NULL 字符。
  • 因为不可能获取的每一个数字都是地址,数字所对应的内存可能并不存在。
  • 还有可能获得的数字确实是一个地址,但是该地址是被保护的。

查看栈内容

使程序崩溃只是验证漏洞的第一步,攻击者还可以利用格式化输出函数来获得内存的内容,为下一步漏洞利用做准备。

查看任意地址的内存

覆盖栈内容

覆盖任意地址内存

CTF 中的格式化字符串漏洞