总述
本篇文章主要讲解格式化字符串漏洞的原理和利用。
讲解了如何利用格式化字符串去泄露内存,去读取栈上面的信息,读取任意地址的信息。
讲解了如何利用格式化字符去覆盖内存,去覆盖任意地址内存,去覆盖任意值。
讲解了pwntools工具:fmtstr_payload。
格式化字符串漏洞原理
简单谈谈我个人的理解:
格式化字符串函数:格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式,像这样的输出函数有下面这些:
函数 | 基本介绍 |
---|---|
printf | 输出到 stdout |
fprintf | 输出到指定 FILE 流 |
vprintf | 根据参数列表格式化输出到 stdout |
vfprintf | 根据参数列表格式化输出到指定 FILE 流 |
sprintf | 输出到字符串 |
snprintf | 输出指定字节数到字符串 |
vsprintf | 根据参数列表格式化输出到字符串 |
vsnprintf | 根据参数列表格式化输出指定字节到字符串 |
setproctitle | 设置 argv |
syslog | 输出日志 |
err, verr, warn, vwarn 等 | 。。。 |
格式化字符串基本格式如下 :
1 | %[parameter] [flags] [field width] [.precision] [length] type |
parameter可以忽略或者指定参数在参数列表中的位置,使用 n$ 形式,其中 n 是一个正整数,表示第 n 个参数。这在需要多次使用同一个参数或调整参数顺序时很有用。
flags 用于控制输出的格式,常见的标志有以下几种。
-:左对齐输出。
+:在正数前显示 + 号。
(空格):在正数前显示一个空格。
0:用零填充字段宽度。
#:对于某些转换类型,改变输出的格式,如八进制前加 0,十六进制前加 0x 或 0X1
2
3
4
5
6
7
8
9
10
11
12
13
14num=123;
// 左对齐,宽度为10
printf("%-10d\n", num);
// 正数前显示 + 号
printf("%+d\n", num);
// 用零填充,宽度为10
printf("%010d\n", num);
// 十六进制输出,加 0x 前缀
printf("%#x\n", num);
#结果:
123
+123
0000000123
0x7bfield width 输出的最小宽度
.precision 输出的精度
1
2
3
4
5
6// 浮点数保留2位小数
printf("%.2f\n", d);
// 字符串最多输出5个字符
printf("%.5s\n", str);
// 整数至少输出5位,不足用零填充
printf("%05d\n", num);length 输出的长度 hh,输出一个字节 h,输出一个双字节
type 指定要输出或输入的数据类型,常见的转换类型有:
d
或i
:十进制整数。u
:无符号十进制整数。x
或X
:十六进制整数。o
:八进制整数。f
:浮点数。s
:字符串。c
:字符。p
: 以16进制来输出指针所指向的值。 它有助于在调试过程中查看变量或对象在内存中的存储位置。
上面的格式化字符串其实就是用来把我们的输入和输出转换成合适的样子,参数用来调整我们的字符串,帮助我们更好的阅读。接下来我们用printf这个函数来具体讲解一些原理。
1 |
|
在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
- 当前字符不是 %,直接输出到相应标准输出。
- 当前字符是 %, 继续读取下一个字符
- 如果没有字符,报错
- 如果下一个字符是 %, 输出 %
- 否则根据相应的字符,获取相应的参数,对其进行解析并输出
聪明如你很快就发现一个问题,那就是如果格式化字符串和参数没有一一对应会发生什么呢?
当参数比格式化字符串多的时候,多余的参数会被函数省略,不进行输出。
当格式化字符串比参数多的时候, 程序会出现未定义行为。这意味着程序可能会从栈上读取额外的数据当作缺失的参数,进而导致输出结果不可预测,甚至可能使程序崩溃。
这个就是格式化字符串的基本原理了,利用格式化字符串比给定的参数多来实现一些未定义行为,作为我们的利用条件。
利用漏洞
对于格式化字符串漏洞,我们常见的用法就是利用它来泄露内存或覆盖内存。想象一下,一个程序的内存你可以来读或者写的时候,那不就是被你给掌控了吗。
泄露内存
这里要用到最主要的格式化字符串原理是:
1 | #type: |
读取栈上的内容
我们先来编写一个例子:
1 | #include <stdio.h> |
我们可以直接利用%p作为泄露的type,这样可以直接泄露出栈上的内存地址。
1 | payload=b'aaaa'+b'-%p'*20 |
这题刚刚好是我编写的,然后就在第14个(地址)偏移的这边我们可以看见61616161==’aaaa’。说明我们的泄露在14个偏移量之后刚刚好到达了输入点,也就是aaaa输入到栈上面的位置就是偏移14.(初学者不要纠结为什么会偏移14,这个值是格式化字符串漏洞产生的未定义行为导致的泄露栈上面的其他内容所产生的)
然后我们来看看里面的栈帧关系,这里我们直接打开ida来看看:
这边buf2是真flag,buf1是假flag,而我们输入点是buf,buf距离格式化字符串的偏移量是14。在64位里面一个(地址)偏移就是8位,32位里面一个偏移(地址)是4位。buf2到buf的距离是0xb0-0x70==0x40 也就是8个偏移量。14减8就是6,说明距离输入格式化字符串的偏移是6。那么我们可以把6-14的值都给打印出来。
编写如下脚本在虚拟机中运行得:
1 | from pwn import * |
就可以得到下面的结果:
1 | Offset 6: Failed to decode - invalid literal for int() with base 16: '答案就在栈上面,你找找看吧,5203\n0x616c665f6c616572' |
两个要点:
- 为什么第一个偏移量没有读出来呢,这个就要设计新的知识了,应为我们获得的字符都是由ASCII码转换过来的,如果前面加上了一段中文,中文不是正常的符号转换过来的,自然就没有办法显示了,只能靠别的方法,去把第一段的字节码给转换成字符串了。
- 为什么需要反转呢,这是因为这个字节码是按照小端序,也就是倒序输出的,小端序是机器看的,大端序才是我们看的,
0x12345678
变成小端序后是0x78563412
欸这个时候有聪明的同学要问了,upup,为什么不用%s直接打印字符串呢,为什么要这样?
实际上:
- 栈上的值并不是指向字符串的指针
- 而是字符串的实际内容
- 所以用 %s 会导致程序崩溃,因为它会把这些值当作指针去解引用
使用 %p方法,因为:
- 不会尝试解引用
- 我们可以手动将这些值转换为字符串
%s选择读取的值作为指针这一点还有妙用哦。
补充说明:如果是数值的话,可以直接利用%d或%x,这两个一个泄露十进制一个泄露16进制。
欧克,通过这案例你已经学会了如何利用格式化字符串去读取栈上的内容了。
接下来我们需要升级挑战,读取任意地址内的信息。
读取任意地址
可以看出,在上面无论是泄露栈上连续的变量,还是说泄露指定的变量值,我们都没能完全控制我们所要泄露的变量的地址。这样的泄露固然有用,可是却不够强力有效。有时候,我们可能会想要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了。那么我们究竟能不能这样做呢?自然也是可以的啦。
如上面所写的例题一样,我们格式化字符串的调用其实是函数的局部变量,他的第一个参数就是格式化字符串的地址。这个时候就可以联想到我们上面说的%s的特性:会把读取的值作为指针去找寻地址。利用这一点,如果我们可以把栈上面的值填写成一个地址,那么%s就会去把这个地址上面的值打印出来。
那么就只要把输入的位置的偏移量,放在你输入的地址后面,就可以先把输入位置变成你寻找的地址,然后取执行格式化字符串,取读取这个地址。如:p64(target)+%offset$s
==payload
1 | #基本 |
我们任然可以利用上面的那个例子,继续任意地址读我们只要可以把offset14的位置填写我们想要读取的地址就可以了,写一个exp如下 :
1 | from pwn import * |
温馨提示这里要用pwngdb来查看内存情况,所以可能出现一些问题,比如权限问题。
1 | #权限问题,在终端中填写,修改yama/ptrace_scop,/proc/sys/kernel/yama/ptrace_scope 这个文件对 ptrace 操作进行了限制,默认情况下可能会阻止 GDB 附加到其他进程。 |
这个时候可以发现一个很严重的问题,那就是printf函数的特性。
printf在遇到’\x00’这个字节的时候会截断导致,你无法下一步去解析%s,print就只能单纯的把got表的值打印出来,而打印不出真实地址。(牢黎我啊,真的是什么坑都踩到了 (╥╯^╰╥) )。
1 | payload = b'aaa%15$s'+p64(printf_got) #got表放在后面,前面进行栈对齐就可以了。 |
这边可能要调整一下保护,但是总体来说,就是这样的。
覆盖内存
上面,我们已经展示了如何利用格式化字符串来泄露栈内存以及任意地址内存,不卖关子现在立刻学习覆盖内存。
这里主要用到的原理是:
1 | #type: |
还是用一个经典的程序:(费雾黎,只能编写到这个了,和wiki差不多)
1 |
|
记得看ida,这个好用着呢:
然后可以发现buf在a下面,也就是偏移量要减1.
对于地址覆盖,我们常用的是:
1 | ...[overwrite addr]....%[overwrite offset]$n |
其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。所以一般来说,也是如下步骤
- 确定覆盖地址
- 确定相对偏移
- 进行覆盖
由于目前几乎所有的程序都开启了 aslr 保护,所以栈的地址一直在变,所以我们这里故意输出了 a 变量的地址。
现在按照前面学习的方法获得偏移量(去找61616161)。
1 | from pwn import * |
正常来说是按照上面的,但是我这题出的有心机,我们知道%n
要在指定位置输入的是已经输入的字符个数,但是我们一个p64()就是8个字节,想要变成1我们还要学习一个小知识点:
覆盖小数字
聪明如你肯定想到了上一节我们遇到的问题,这里我们也是一样,把这个payload反过来:
1 | payload=b'a%9$naaa'+p64(a_addr) #这个时候这个地址的位置放在后面但是可以正确索引到这个地址。 |
然后我们就可以覆盖成功啦,可以得到:
其实,这里我们需要掌握的小技巧就是,我们没有必要把地址放在最前面,放在哪里都可以,只要我们可以找到其对应的偏移即可。
覆盖大数字
上面我们也说了,我们可以选择直接一次性输出大数字个字节来进行覆盖,但是这样基本也不会成功,因为太长了。而且即使成功,我们一次性等待的时间也太长了。
先回忆一下变量在内存中的存储格式。首先,所有的变量在内存中都是以字节进行存储的。此外,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12。
这里利用的原理是:
1 | hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。 |
接下来我们继续上面的例子去覆盖c:
1 | 我们目标是把下面的地址一个一个覆盖掉。 |
1 | from pwn import * |
欸有同学就问了,这个fmtstr_payload是什么,这个是一个非常好用的工具用来辅助我们的格式化字符串书写。
接下来介绍今天的究极武器也就是今天的文章完结的总章:
fmtstr_payload
fmtstr_payload是pwntools里面的一个工具,用来简化对格式化字符串漏洞的构造工作。
可以实现修改任意内存
1 | #一: |
我们常用的形式是fmtstr_payload(offset,{address1:value1})
欧克,今天关于格式化字符串的学习就到这里了,白。(这个文章搞了我一整天,还是要努力学习啊,学pwn的都坚强(笑))