VM escape - QEMU의 메모리를 읽는 두 가지 방법

악성코드가 VM을 탈출하여 호스트 머신에서 코드를 실행할 수 있을까?

KVM(Kernel-based Virtual Machine) 은 사용자 공간 프로그램에 완전 가상화 인프라를 제공하는 커널 모듈이다. 이를 통해 수정되지 않은 Linux 또는 Windows 이미지를 실행하는 여러 가상 머신을 구동할 수 있다.

KVM 의 사용자 공간 구성요소는 메인라인 QEMU(Quick Emulator)에 포함되어있으며 특히 device emulation을 담당한다.


개발 환경

여기서 타겟으로 잡은 취약점은 이미 오래전 패치되었기 때문에 QEMU 소스에서 해당 취약점 수정 직전의 커밋으로 checkout 해야한다.


QEMU 메모리 배치

게스트에 할당된 물리 메모리는 실제로 QEMU의 가상 주소 공간 내에서 mmap 된 private 영역이다.

게스트의 물리 메모리를 할당할 때 PROT_EXEC 플래그가 활성화되지 않는다는 점에 주목하는 것이 중요하다.

                        게스트 프로세스
                     +--------------------+
가상 주소 공간       |                    |
                     +--------------------+
                     |                    |
                     \__   페이지 테이블   \__
                        \                    \
                         |                    |  게스트 커널
                    +----+--------------------+----------------+
게스트 물리 메모리   |    |                    |                |
                    +----+--------------------+----------------+
                    |                                          |
                    \__                                        \__
                       \                                          \
                        |             QEMU 프로세스                |
                   +----+------------------------------------------+
가상 주소 공간     |    |                                          |
                   +----+------------------------------------------+
                   |                                               |
                    \__                페이지 테이블                \__
                       \                                               \
                        |                                               |
                   +----+-----------------------------------------------++
물리 메모리        |    |                                               ||
                   +----+-----------------------------------------------++

추가적으로 QEMU는 BIOS와 ROM을 위한 메모리 영역을 예약한다. 이러한 매핑은 QEMU의 maps 파일에서 확인할 수 있다.


주소 변환

QEMU 내에는 두가지 변환 계층이 존재한다.

  • 게스트 가상 주소에서 게스트 물리 주소로의 변환.
    • 우리의 익스플로잇에서는 DMA 접근이 필요한 네트워크 카드 장치를 설정해야한다.
    • 예를 들어 네트워크 카드 장치를 올바르게 설정하기 위해 tx/rx 버퍼의 물리 주소를 제공해야한다.
  • 게스트 물리 주소에서 QEMU 의 가상주소 공간으로의 변환.
    • 우리의 익스플로잇에서는 가짜 구조체를 주입하고 QEMU의 가상 주소 공간에서 그 정확한 주소를 알아내야한다.
x86_64 시스템에서 가상 주소는 페이지 오프셋(비트 0-11)과 페이지 번호로 구성된다.
리눅스 시스템에서 pagemap 파일은 CAP_SYS_ADMIN 권한을 가진 사용자 공간 프로세스가 각 가상 페이지가 어떤 물리 프레임에 매핑되어 있는지 알 수 있게 해준다.
pagemap 파일은 각 가상 페이지에 대해 kernel.org[5]에 잘 문서화된 64비트 값을 포함한다.
  • 비트 0-54 : 존재할 경우 물리 프레임 번호.
  • 비트 55 : 페이지 테이블 엔트리가 soft-dirty 상태
  • 비트 56 : 페이지가 배타적으로 매핑됨
  • 비트 57-60 : 0
  • 비트 61 : 페이지가 파일 페이지 또는 shared-anon
  • 비트 62 : 페이지가 스왑됨.
  • 비트 63 : 페이지가 존재함.
가상 주소를 물리 주소로 변환하기 위해 다음 코드를 활용한다. (출처: Nelson Elhage의 코드)
다음 프로그램은 버퍼를 할당하고 "Where am I?" 문자열로 채운 뒤, 해당 물리 주소를 출력한다.
---[ mmu.c ]---
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <stdlib.h>
#include <fcntl.h>
#include <assert.h>
#include <inttypes.h>

#define PAGE_SHIFT  12
#define PAGE_SIZE   (1 << PAGE_SHIFT)
#define PFN_PRESENT (1ull << 63)
#define PFN_PFN     ((1ull << 55) - 1)

int fd;

uint32_t page_offset(uint32_t addr)
{
    return addr & ((1 << PAGE_SHIFT) - 1);
}

uint64_t gva_to_gfn(void *addr)
{
    uint64_t pme, gfn;
    size_t offset;

    offset = ((uintptr_t)addr >> 9) & ~7;

    lseek(fd, offset, SEEK_SET);
    read(fd, &pme, 8);

    if (!(pme & PFN_PRESENT))
        return -1;

    gfn = pme & PFN_PFN;
    return gfn;
}

uint64_t gva_to_gpa(void *addr)
{
    uint64_t gfn = gva_to_gfn(addr);

    assert(gfn != -1);

    return (gfn << PAGE_SHIFT)
         | page_offset((uint64_t)addr);
}

int main()
{
    uint8_t *ptr;
    uint64_t ptr_mem;

    fd = open("/proc/self/pagemap", O_RDONLY);

    if (fd < 0) {
        perror("open");
        exit(1);
    }

    ptr = malloc(256);
    strcpy(ptr, "Where am I?");

    printf("%s\n", ptr);

    ptr_mem = gva_to_gpa(ptr);

    printf("Your physical address is at 0x%"PRIx64"\n",
           ptr_mem);

    getchar();

    return 0;
}
위 코드를 게스트 내부에서 실행하고 QEMU 프로세스에 gdb를 연결하면 우리의 버퍼가 게스트에 할당된 물리 주소 공간 내에 위치해 있음을 확인할 수 있다.
더 정확히 말하면 출력된 주소가 실제로는 게스트 물리 메모리의 베이스 주소로부터의 오프셋임을 알 수 있다.

Memory leak Exploit

RTL8139 네트워크 카드 장치 에뮬레이터에 영향을 미치는 메모리 누수 취약점인 CVE-2015-5165를 exploit하여 QEMU의 메모리 레이아웃을 재구성할 것이다.

exploit의 최종 목표는 VM을 탈출하여 호스트에서 코드를 실행하는 것이다. 그러려면 두가지 정보가 필요하다.

(1) .text 세그먼트의 베이스 주소 (어떤 코드를 실행할지)

호스트에서 코드를 실행하려면 ROP 체인이나 셸코드를 만들어야하는데 그려려면 QEMU 바이너리 안에 있는 gadget이나 함수의 주소를 알아야한다.

그런데 ASLR 때문에 QEMU 가 실행될 때마다 .text 세그먼트가 메모리에 로드되는 위치가 달라진다.

그래서 베이스 주소를 유출하면 거기에 offset 을 더해서 원하는 함수/가젯의 실제 주소를 계산할 수 있다.

예: system() 실제 주소 = 유출된 베이스 주소 + system() 의 offset

(2) guest 물리 메모리의 베이스 주소 (주입한 데이터가 어디에 있는지)

게스트의 물리 메모리는 QEMU 프로세스의 가상 주소 공간 안에 mmap 되어 있다.

공격자는 게스트 VM 안에서 가짜 구조체(예: 함수 포인터가 조작된 구조체)를 메모리에 배치할 수 있고 게스트 내부에서의 물리 주소도 계산할 수 있다.

하지만 QEMU 입장에서 그 구조체가 어디에 있는지 알려면 게스트 RAM이 QEMU 주소 공간에서 어디서 시작하는지 (베이스 주소)를 알아야한다.

예: QEMU에서의 실제 주소 = 게스트 RAM 베이스 (0x7fc31c000000) + guest 물리 주소 (0x78b0d010)


REALTEK 네트워크 카드는 두가지 송수신 동작 모드를 지원한다.

C 모드와 C+모드.

카드가 C+모드를 사용하도록 설정되면 NIC Device emulator가 IP packet 데이터의 길이를 잘못 계산하여 패킷에 실제로 존재하는 것보다 더 많은 데이터를 전송하게 된다.

이 취약점은 hw/net/rtl8139.c의 rtl8139_cplus_transmit_one 함수에 존재한다.

/* IP 패킷 헤더 */
ip_header *ip = NULL;
int hlen = 0;
uint8_t  ip_protocol = 0;
uint16_t ip_data_len = 0;

uint8_t *eth_payload_data = NULL;
size_t   eth_payload_len  = 0;

int proto = be16_to_cpu(*(uint16_t *)(saved_buffer + 12));
if (proto == ETH_P_IP)
{
    DPRINTF("+++ C+ mode has IP packet\n");

    /* not aligned */
    eth_payload_data = saved_buffer + ETH_HLEN;
    eth_payload_len  = saved_size   - ETH_HLEN;

    ip = (ip_header*)eth_payload_data;

    if (IP_HEADER_VERSION(ip) != IP_HEADER_VERSION_4) {
        DPRINTF("+++ C+ mode packet has bad IP version %d "
            "expected %d\n", IP_HEADER_VERSION(ip),
            IP_HEADER_VERSION_4);
        ip = NULL;
    } else {
        hlen = IP_HEADER_LENGTH(ip);
        ip_protocol = ip->ip_p;
        ip_data_len = be16_to_cpu(ip->ip_len) - hlen;
    }
}

IP 헤더에는 hlen과 ip->ip_len이라는 두 필드가 있으며 이들은 각각 IP 헤더의 길이(옵션이 없는 패킷 기준 20바이트)와 IP 헤더를 포함한 패킷의 전체 길이를 나타낸다.

위 코드 스니펫의 끝부분에서 볼 수 있듯이 IP 데이터 길이(ip_data_len)를 계산할 때 ip->ip_len >= hlen 인지 확인하는 검사가 없다.

ip_data_len 필드가 unsigned short로 인코딩되어 있으므로, 이는 전송 버퍼에 실제로 존재하는 것보다 더 많은 데이터를 전송하게 되는 결과를 초래한다.

보다 구체적으로 ip_data_len은 이후 TCP 데이터의 길이를 계산하는데 사용되며

이 데이터는 MTU 크기를 초과할 경우 chunk 단위로 malloc 된 버퍼에 복사된다.

int tcp_data_len = ip_data_len - tcp_hlen;
int tcp_chunk_size = ETH_MTU - hlen - tcp_hlen;

int is_last_frame = 0;

for (tcp_send_offset = 0; tcp_send_offset < tcp_data_len;
    tcp_send_offset += tcp_chunk_size) {
    uint16_t chunk_size = tcp_chunk_size;

    /* check if this is the last frame */
    if (tcp_send_offset + tcp_chunk_size >= tcp_data_len) {
        is_last_frame = 1;
        chunk_size = tcp_data_len - tcp_send_offset;
    }

    memcpy(data_to_checksum, saved_ip_header + 12, 8);

    if (tcp_send_offset) {
        memcpy((uint8_t*)p_tcp_hdr + tcp_hlen,
                (uint8_t*)p_tcp_hdr + tcp_hlen + tcp_send_offset,
                chunk_size);
    }

    /* more code follows */
}

따라서 손상된 길이 값을 가진 조작된 패킷을 만들면 (예: ip->ip_len = hlen - 1), QEMU의 힙 메모리에서 약 64KB를 유출할 수 있다.

단일 패킷을 전송하는 대신, 네트워크 카드 디바이스 에뮬레이터는 43개의 분할된 패킷을 전송하게 된다.


네트워크 카드 설정

조작된 패킷을 전송하고 유출된 데이터를 읽기 위해서는 먼저 카드에 RX 및 TX 디스크립터 버퍼를 설정하고 패킷이 취약한 코드 경로를 통과하도록 일부 플래그를 설정해야한다.

Exploit


Heap 기반 버퍼 오버플로 취약점


취약한 코드


네트워크 카드 설정


CRC 역산


Exploit


전체 과정 결합


RIP 제어


대화형 Shell


VM escape exploit



댓글

이 블로그의 인기 게시물

L2 Wireguard

ELF 구조부터 로딩까지