Firecracker는 신뢰할 수 없는 코드를 마이크로VM(microVM) 내부에 격리하는 것을 목표로 합니다. 우리는 이 시스템을 탈출하여 호스트에서 root 권한으로 코드 실행에 성공했습니다.
우리는 Firecracker가 레지스터를 저장하고, 재개 지점을 변경하며, KVM으로부터 반환될 때 우리가 원하는 호출을 실행하도록 만드는 방법을 발견했습니다. 게스트는 임시 스택 변경 사항들을 복원합니다. Firecracker는 계속 실행되었고, VM은 SSH를 통해 접근 가능한 상태로 유지되었습니다.
PwnEscape Part 1에서는 이 작업의 기반이 되는 호스트 메모리 프리미티브(host memory primitive)를 소개했습니다. 여기서는 그 다음 단계, 즉 해당 접근 권한을 사용하여 메모리에서 Firecracker를 찾아내고 게스트 내부로부터 활성 스레트를 장악하는 방법을 다룹니다. 이는 Firecracker 환경에서 CVE-2026-99999 취약점을 보여주기 위한 연속적인 작업입니다.
00 / 제어권을 가질 위치 선택하기
Firecracker 마이크로VM은 이를 관리하는 호스트 프로세스를 가지고 있습니다. 각 가상 CPU(vCPU)는 해당 프로세스 내의 스레트를 가집니다. 이 스레트는 게스트를 실행하기 위해 KVM으로 진입했다가, 유저스페이스에서 처리해야 할 이벤트가 발생하면 Firecracker로 반환됩니다. Firecracker는 아키텍처 개요에 이러한 구성을 문서화하고 있습니다.
firecracker-host-integration-dbdde36ef234
가상 CPU를 실행하는 호출은 다음과 같습니다:
ioctl(vcpu_fd, KVM_RUN, 0);
스레트가 이 시스템 콜 내에 있을 때, 호스트 커널은 그곳의 저장된 유저스페이스 레지스터들을 pt_regs라는 구조체에 보관합니다. 이 레지스터들은 Firecracker 스레트를 설명하는데, 명령어 포인터(instruction pointer)는 실행이 재개되어야 할 위치를 알려주고, 스택 포인터(stack pointer)는 사용해야 할 유저스페이스 스택을 알려줍니다.
이로 인해 KVM_RUN으로부터의 반환은 매력적인 목표가 되었습니다. 만약 우리가 저장된 프레임을 찾아내고 수정할 수 있다면, 우리는 스레트가 유저스페이스로 반환될 때 Firecracker가 실행하게 할 코드를 선택할 수 있습니다. 그러한 반환을 야기하는 게스트 작업이 트리거를 제공합니다.
우리는 이미 게스트 매핑(guest mapping)을 통해 일부 호스트 물리 메모리에 접근할 수 있었습니다. 이를 사용하기 위해서는 Firecracker 프로세스를 식별하고, 해당 프로세스 내에서 호출할 수 있는 코드를 찾고, 활성 vCPU 스레트에 속한 레지스터 프레임을 찾아야 했습니다. 시작점은 게스트 내부에서 우리가 제어하는 메모리 페이지였습니다.
01-guest-to-vmm-chain-5c0fbee0cb07
그림 1. 게스트가 자신의 Firecracker 프로세스를 식별하고, vCPU 스레드의 저장된 레지스터를 찾아내며, KVM에서 반환될 때 해당 스레드를 리디렉션합니다.
01 / 호스트에서 게스트 페이지 찾기
게스트와 호스트는 서로 다른 물리 주소 공간을 사용합니다. 한 페이지는 VM 내부에는 게스트 물리 주소(GPA)를 가지고 있고, 이를 지원하는 머신 내부에는 호스트 물리 주소(HPA)를 가집니다. 타겟 시스템에서는 확장 페이지 테이블(Extended Page Tables, EPT)이 이 둘 사이의 변환을 제공합니다.
저희는 게스트 페이지에 식별 가능한 바이트 패턴인 논스(nonce)를 배치했습니다. 첫 번째 리졸버는 해당 페이지의 알려진 GPA와 그 내용을 입력으로 받았습니다. 이 두 가지 정보를 통해 저희는 번역해야 할 주소와, 그 번역이 저희 페이지에 도달했는지 확인할 수 있는 방법을 얻었습니다.
리졸버는 메모리 프리미티브(memory primitive)가 커버하는 호스트 물리 범위에서 후보 EPT 루트를 검색했습니다. EPT 루트는 변환 테이블의 시작점입니다. 각 후보에 대해, 리졸버는 게스트 주소에 의해 선택된 엔트리를 따라가다가 물리 페이지에 도달하면, 그 페이지에서 논스를 확인했습니다.
아래 스니펫들은 설명적인 헬퍼 이름과 함께 작동하는 설명용 의사 코드(pseudocode)입니다. 이 코드는 단계들 사이에서 전달되는 데이터와 연결에 사용된 검사들을 보여줍니다.
for root in candidate_ept_roots:
page_hpa = walk_ept(root, guest_gpa)
if page_hpa is invalid:
...
논스 확인은 제어하는 메모리와 제안된 변환을 연결했습니다. 더 광범위한 검색은 두 개의 후보를 생성했고, 중첩 페이지 테이블의 검증을 통해 이들은 하나의 EPT 루트로 줄었습니다. 그 루트를 따라가자 저희 게스트 페이지의 호스트 물리 주소를 얻을 수 있었습니다.
저희는 이 결과를 guest_page_hpa라고 명명하고 다음 리졸버에 전달했습니다. 이제 저희는 호스트 메모리 내에서 식별 가능한 객체를 갖게 되었습니다. 다음 과제는 그것을 매핑한 호스트 프로세스를 찾는 것이었습니다.
02 / Firecracker 프로세스 찾기
Firecracker는 게스트가 사용하는 메모리를 자체 주소 공간으로 매핑합니다. 이 매핑은 저희에게 게스트 페이지로부터 해당 프로세스로 거슬러 올라갈 수 있는 방법을 제공했습니다.
호스트 프로세스는 페이지 테이블을 사용하여 가상 주소를 물리 메모리로 변환합니다. 최상위 레벨은 페이지 전역 디렉토리(Page Global Directory), 즉 PGD라고 불립니다. 적절한 PGD를 복구하면 Firecracker 내부의 주소를 번역하고 메모리 프리미티브를 통해 그 코드를 읽고 스택을 읽을 수 있게 됩니다.
저희의 리버스 매퍼는 네이티브 호스트 페이지 테이블에서 guest_page_hpa에 대한 매핑을 검색했습니다. 일반적인 페이지 테이블 워크(page table walk)는 가상 주소로 시작하여 물리 주소를 생성합니다. 여기서는 이미 알고 있는 물리 페이지에 대한 매핑을 찾은 다음, 그와 관련된 가상 주소와 페이지 테이블 루트를 복구했습니다.
이 확인 과정은 양방향으로 실행됩니다:
for mapping in reverse_map_host_page_tables(guest_page_hpa):
pgd = mapping.root
guest_page_va = mapping.virtual_address
...
이것은 호스트 프로세스의 가상 매핑을 EPT를 통해 찾은 물리 페이지에 연결했습니다. 리버스 매퍼는 검증된 주소 공간 하나를 반환했습니다. 저희는 그 루트를 host_pgd로 유지하고 이를 사용하여 프로세스를 검사했습니다.
다음 솔버(resolver)는 해당 매핑들에서 Firecracker 실행 파일을 찾았습니다. 이 코드는 ELF 이미지(ELF image)를 식별하고 Firecracker의 write(2) 래퍼에 있는 알려진 바이트 시퀀스를 확인했습니다. 이는 복구된 주소 공간을 우리가 목표로 하는 바이너리에 연결했습니다.
ASLR은 실행 파일이 로드되는 위치를 변경합니다. ELF 이미지 내의 주소와 그에 상응하는 런타임 주소 사이의 차이를 부하 편향(load bias)이라고 합니다. 매퍼가 이미지를 식별하면, 이 편향을 복구하여 익스플로잇에 필요한 코드를 찾는 데 사용할 수 있습니다:
runtime_code_address = load_bias + code_address_in_elf
매퍼는 부하 편향을 firecracker_base로 반환했습니다. 이 값과 host_pgd를 사용하여, 실행 중인 프로세스에서 코드를 찾고, 그 가상 주소를 호스트 물리 메모리로 변환한 다음, 그곳의 바이트를 예상되는 바이너리와 비교할 수 있었습니다.
저희는 세 곳을 확인했습니다: 증거(proof)를 생성할 write(2) 래퍼, KVM ioctl 이후의 연속 부분, 그리고 짧은 xor eax,eax; ret 명령어입니다.
깨끗하게 반환하는 데 필요한 시퀀스(sequence)입니다. 이들의 목적은 아래에서 리턴 경로를 구성할 때 중요해집니다.
이 시점에서 게스트는 Firecracker 이미지와 그 사설 매핑(private mappings)을 검사할 수 있었습니다. 우리는 여전히 현재 우리의 VM을 실행하고 있는 스레드의 저장된 레지스터가 필요했습니다.
02-address-resolution-0e47fd8bf102
그림 2. 논스(nonce)는 호스트 메모리 내 게스트 페이지를 식별합니다. 이 매핑은 호스트 페이지 테이블 루트로 이어지며, 다음 리졸버가 Firecracker 이미지를 찾고 검증할 수 있게 해줍니다.
03 / 활성 vCPU 프레임 위치 파악 (Locating the live vCPU frame)
프레임 파인더(frame finder)는 이전 단계에서 얻은 host_pgd와 firecracker_base를 사용했습니다. 이를 이용해 호스트 스레드의 저장된 상태를 인식했습니다.
가장 유용한 단서는 리턴 주소(return address)였습니다. KVM_RUN에서 실행되는 vCPU 스레드는 Firecracker 내의 해당 ioctl 연속 지점(continuation)에 저장된 명령어 포인터(instruction pointer)를 가지고 있어야 합니다. 우리는 검증된 바이너리와 그 로드 바이어스(load bias)로부터 이 주소를 계산할 수 있었습니다.
그런 다음 우리는 해당 호출과 일치하는 pt_regs 프레임을 찾기 위해 호스트 메모리를 검색했습니다. 주소 일치만으로는 오래된 스택 내용(stale stack contents)을 가져올 수 있으므로, 후보는 여러 관련 검사를 통과해야 했습니다:
| 저장된 상태 (Saved state) | 우리가 확인한 것 (What we checked) |
|---|
| 시스템 호출 (System call) | syscall 식별자와 진행 중인 상태가 활성 ioctl과 일치했습니다. |
| ... | |
| 스택 검사는 해당 프레임을 게스트 페이지에서 복구한 것과 동일한 주소 공간에 연결했습니다. 명령어 및 호출자 주소는 이를 Firecracker 이미지에 연결했습니다. 시스템 호출 필드는 vCPU를 실행하는 작업에 연결했습니다. | |
검색 결과 하나의 후보만 남았습니다. 그 위치는 저장된 레지스터 프레임의 호스트 물리 주소(host physical address)인 frame_hpa로 저장되었습니다. 우리는 이 값과 PGD, 로드 바이어스를 최종 헬퍼에 전달했습니다.
메모리를 변경하기 전에, 이 헬퍼는 코드 시그니처(code signature), 프레임, 그리고 스택 검사를 반복했습니다. 이는 활성 단계가 리졸버들이 식별한 것과 동일한 프로세스 상태를 사용하도록 보장했습니다. 이후의 실행 및 복구 검사는 선택된 프레임이 살아있는지(live) 확립할 것입니다.
03-live-kvm-run-frame-ddb3ad26cf50
그림 3. 후보 레지스터 프레임은 KVM ioctl, 복구된 프로세스 페이지 테이블, 그리고 검증된 Firecracker 코드와 일치해야 한다.
04 / KVM으로부터의 리턴 리디렉션 (Redirecting the return from KVM)
우리는 간단한 페이로드를 선택했다. Firecracker 프로세스가 자체 stderr에 식별 가능한 문자열을 쓰도록 만드는 것이다. 이렇게 하면 호스트 러너가 우리가 장악한 프로세스로부터 관찰 가능한 결과를 얻을 수 있다.
그 문자열은 N8V23M2\n이었다.
개행 문자를 포함하여 총 7개의 문자이다. 이는 8바이트를 차지하며 하나의 스택 워드에 들어간다. 우리가 Firecracker가 실행하기를 원했던 호출은 다음과 같다:
write(2, "N8V23M2\n", 8);
여기서 2는 Firecracker의 stderr 파일 디스크립터이다. 버퍼 포인터는 Firecracker의 주소 공간에 있는 문자열을 참조해야 하므로, 우리는 이 문자열을 그 사설 사용자 공간 스택(private userspace stack)에 저장했다.
우리는 세 개의 스택 워드를 저장한 후 이를 수정했다. 하나는 문자열이 담겨 있었고, 나머지 두 개는 임시 호출 시퀀스를 위한 복귀 주소였다. 그런 다음 우리는 저장된 레지스터 프레임을 변경하여 래퍼 주소(wrapper address), 그 인자들, 그리고 해당 시퀀스를 위한 스택 포인터를 공급하도록 했다:
RIP = Firecracker의 write 래퍼 주소
RDI = 2
RSI = Firecracker 스택에 있는 마커의 가상 주소
...
write로부터 리턴하는 과정 역시 주의가 필요했다. 8바이트를 성공적으로 쓰면 8이 반환되고, 성공적인 KVM_RUN 호출은 0을 반환한다. 우리는 먼저 복귀 시퀀스에 xor eax,eax; ret의 주소를 배치했다. 이는 두 번째 주소가 실행을 Firecracker의 원래 ioctl 계속 지점으로 되돌리기 전에 리턴 값을 0으로 클리어한다.
결과적인 제어 흐름은 다음과 같았다:
KVM_RUN이 사용자 공간으로 반환 -> Firecracker의 write 래퍼가 마커를 방출 -> xor eax,eax; ret이 리턴 값을 0으로 설정함
...
스택과 저장된 레지스터들이 준비되자, 게스트는 자신의 직렬 포트에 한 바이트를 썼다. 이 작업은 vCPU 스레드가 KVM_RUN으로부터 반환하도록 야기했다. 커널은 수정된 레지스터들을 복원했고, Firecracker는 우리의 인자와 함께 write 래퍼로 진입했다.
래퍼(wrapper)가 마커를 방출했고, 리턴 시퀀스(return sequence)는 eax 레지스터를 비웠으며, 스레드는 원래의 실행 경로로 재합류했습니다. 제어권이 게스트(guest)로 돌아온 후, 헬퍼(helper)는 세 개의 스택 워드(stack words)를 모두 복원했습니다.
04-reversible-control-flow-10ae6af41746
그림 4. 저장된 레지스터 프레임이 쓰기 래퍼(write wrapper)로 진입합니다. 두 개의 리턴 주소는 반환 값 조정(return-value adjustment)을 거쳐 원래의 Firecracker 경로로 돌아갑니다.
05 / 실행 및 복구 확인 (Checking execution and recovery)
호스트 러너(host runner)는 Firecracker의 표준 에러 스트림(stderr)을 게스트와 분리하여 포착했습니다. 전체 출력은 다음과 같았습니다:
이 포착물에는 마지막 개행 문자(newline)를 포함하여 정확히 8바이트가 포함되어 있었습니다. Firecracker는 여전히 실행 중이었고, 게스트 SSH는 응답했으며, 헬퍼는 세 개의 임시 스택 워드를 복원했습니다.
우리는 메모리 변경을 일으키지 않는 새로운 패스(fresh pass)로 복구를 확인했습니다. 이 패스는 동일한 프레임 주소(frame address)를 받았고, 트리거 이후 레지스터, 코드, 스택 검사를 반복했습니다. 검사는 통과했으며, 임시 스택 위치에서 오래된 마커(stale marker)를 발견하지 못했습니다.
이러한 관찰은 세 가지 다른 질문에 답했습니다. 게스트 로그는 트리거와 복원을 기록했습니다. 호스트 포착물(host capture)은 Firecracker가 선택된 작업을 실행했음을 보여주었습니다. 최종 검증(final validation)은 실행 후 상태를 확인했습니다.
| 검사 항목 (Check) | 기록된 결과 (Recorded result) |
|---|
| Firecracker stderr | 마커 사본 하나, 총 8바이트 |
| ... 05-proof-receipt-6e8ad89bf50c | |
그림 5. 게스트 로그, Firecracker stderr 포착물, 최종 검증은 트리거, 호스트 실행 및 복원된 상태를 기록합니다.
06 / 호스트에서의 루트 실행 (Root execution on the host)
마커가 나타날 무렵, 게스트는 호스트 스레드의 명령어 포인터(instruction pointer), 함수 인자(function arguments), 스택 데이터(stack data), 그리고 리턴 경로를 선택했습니다. Firecracker는 자체 권한을 사용하여 호출을 실행했습니다.
목표는 x86-64 환경의 Firecracker v1.16.1이었으며, 호스트 커널은 7.0.0-1011-aws, 게스트 커널은 6.18.44+였습니다. 우리는 systemd-run을 사용하여 Firecracker를 루트(root)로 직접 실행하고 taskset으로 고정했습니다. 결과적으로, 제어된 호출은 호스트에서 루트 권한으로 실행되었습니다.