배경
Windows 10 (버전 RS4)에서 Microsoft는 WHP( Windows 하이퍼바이저 플랫폼 ) API를 도입했습니다. 이 API는 Microsoft의 기본 제공 하이퍼바이저 기능을 사용자 모드 Windows 애플리케이션에 노출합니다. 2024년에 저자는 이 API를 사용하여 개인 프로젝트인 DOSVisor라는 16비트 MS-DOS 에뮬레이터를 만들었습니다. 릴리스 노트에서 언급했듯이, 이 개념을 더 발전시켜 Windows 애플리케이션 에뮬레이션에 사용할 계획이 항상 있었습니다. Elastic은 직원들이 개인 프로젝트를 진행할 수 있도록 1년에 두 번 연구 주간(ON Week)을 제공하여 이 프로젝트 작업을 시작할 수 있는 좋은 기회를 제공합니다. 이 프로젝트의 이름은 (상상을 초월하는) WinVisor로, 이전 버전인 DOSVisor에서 영감을 받았습니다.
하이퍼바이저는 하드웨어 수준의 가상화를 제공하므로 소프트웨어를 통해 CPU를 에뮬레이션할 필요가 없습니다. 소프트웨어 기반 에뮬레이터는 에지 케이스에서 일관성 없이 동작하는 경우가 많은 반면, 명령어가 물리적 CPU에서와 똑같이 실행되도록 보장합니다.
이 프로젝트는 Windows x64 바이너리를 실행하기 위한 가상 환경을 구축하여 시스템 호출을 로깅(또는 후킹)하고 메모리 인트로스펙션을 가능하게 하는 것을 목표로 합니다. 이 프로젝트의 목표는 포괄적이고 안전한 샌드박스를 구축하는 것이 아니라 기본적으로 모든 시스템 호출이 단순히 기록되어 호스트에 직접 전달되는 것입니다. 초기 형태에서는 가상화된 게스트 내에서 실행되는 코드가 "이스케이프(" )를 호스트에게 보내는 것은 간단합니다. 샌드박스를 안전하게 보호하는 것은 어려운 작업이며 이 프로젝트의 범위를 벗어납니다. 제한 사항은 이 글의 마지막 부분에서 자세히 설명합니다.
6 년 동안(작성 시점 기준) 사용 가능했음에도 불구하고 WHP API는 QEMU 및 VirtualBox와 같은 복잡한 코드베이스를 제외하고는 많은 공개 프로젝트에서 사용되지 않은 것으로 보입니다. 주목할 만한 또 하나의 프로젝트는 알렉스 아이오네스큐의 Simpleator로, WHP API를 활용하는 가벼운 Windows 사용자 모드 에뮬레이터입니다. 이 프로젝트는 구현 방식이 상당히 다르긴 하지만 WinVisor와 많은 부분이 동일한 목표를 가지고 있습니다. WinVisor 프로젝트는 가능한 한 많이 자동화하고 간단한 실행 파일을 지원하는 것을 목표로 합니다(예 ping.exe
)를 즉시 사용할 수 있습니다.
이 글에서는 프로젝트의 전반적인 설계와 몇 가지 문제, 그리고 이를 해결한 방법에 대해 다룹니다. 개발 시간 제약으로 인해 일부 기능은 제한되지만, 최종 제품은 최소한 사용 가능한 개념 증명 수준이 될 것입니다. 이 글의 마지막에 GitHub에서 호스팅되는 소스 코드 및 바이너리 링크가 제공됩니다.
하이퍼바이저 기본 사항
하이퍼바이저는 VT-x(Intel) 및 AMD-V(AMD) 확장으로 구동됩니다. 이러한 하드웨어 지원 프레임워크는 하나 이상의 가상 머신을 단일 물리적 CPU에서 실행할 수 있도록 하여 가상화를 지원합니다. 이러한 확장 기능은 서로 다른 명령어 집합을 사용하므로 본질적으로 서로 호환되지 않으므로 각각에 대해 별도의 코드를 작성해야 합니다.
내부적으로 Hyper-V는 인텔 지원에는 hvix64.exe
을 사용하고 AMD 지원에는 hvax64.exe
을 사용합니다. Microsoft의 WHP API는 이러한 하드웨어 차이를 추상화하여 애플리케이션이 기본 CPU 유형에 관계없이 가상 파티션을 만들고 관리할 수 있도록 합니다. 간단하게 설명하기 위해 다음 설명은 VT-x에만 초점을 맞출 것입니다.
VT-x는 VMX(가상 머신 확장)라는 명령어 집합을 추가하며, 여기에는 VM을 처음 실행하기 시작하는 VMLAUNCH
, VM 종료 후 VM에 다시 들어가는 VMRESUME
등의 명령어가 포함됩니다. VM 종료는 특정 지침, I/O 포트 액세스, 페이지 오류 및 기타 예외와 같은 특정 조건이 게스트에 의해 트리거될 때 발생합니다.
VMX의 중심에는 게스트 및 호스트 컨텍스트의 상태와 실행 환경에 대한 정보를 저장하는 VM별 데이터 구조인 VMCS(가상 머신 제어 구조)가 있습니다. VMCS에는 프로세서 상태, 제어 구성 및 게스트에서 호스트로의 전환을 트리거하는 선택적 조건을 정의하는 필드가 포함되어 있습니다. VMREAD
및 VMWRITE
명령어를 사용하여 VMCS 필드를 읽거나 쓸 수 있습니다.
VM이 종료되는 동안 프로세서는 게스트 상태를 VMCS에 저장하고 하이퍼바이저 개입을 위해 호스트 상태로 다시 전환합니다.
WinVisor 개요
이 프로젝트는 WHP API의 높은 수준의 특성을 활용합니다. 이 API는 하이퍼바이저 기능을 사용자 모드에 노출하고 애플리케이션이 호스트 프로세스의 가상 메모리를 게스트의 물리적 메모리에 직접 매핑할 수 있도록 합니다.
가상 CPU는 실행 전 CPU 상태를 초기화하기 위해 CPL0(커널 모드)에서 실행되는 작은 부트로더를 제외하고는 거의 독점적으로 CPL3(사용자 모드)에서만 작동합니다. 이에 대해서는 가상 CPU 섹션에서 더 자세히 설명합니다.
에뮬레이트된 게스트 환경의 메모리 공간을 구축하려면 대상 실행 파일과 모든 DLL 종속성을 매핑한 다음 프로세스 환경 블록(PEB), 스레드 환경 블록(TEB), KUSER_SHARED_DATA
등과 같은 기타 내부 데이터 구조를 채우면 됩니다.
EXE 및 DLL 종속성을 매핑하는 것은 간단하지만 PEB와 같은 내부 구조를 정확하게 유지 관리하는 것은 더 복잡한 작업입니다. 이러한 구조는 대부분 문서화되지 않은 대규모 구조이며, 그 내용은 Windows 버전에 따라 다를 수 있습니다. 간단한 "Hello World" 애플리케이션을 실행하기 위해 최소한의 필드 집합을 채우는 것은 비교적 간단할 수 있지만, 좋은 호환성을 제공하려면 개선된 접근 방식을 취해야 합니다.
WinVisor는 가상 환경을 수동으로 구축하는 대신 대상 프로세스의 일시 중단된 인스턴스를 시작하고 전체 주소 공간을 게스트에 복제합니다. IAT(가져오기 주소 테이블) 및 TLS(스레드 로컬 저장소) 데이터 디렉토리는 메모리의 PE 헤더에서 일시적으로 제거되어 DLL 종속성이 로드되지 않도록 하고 진입점에 도달하기 전에 TLS 콜백이 실행되는 것을 방지합니다. 그런 다음 프로세스가 재개되어 대상 실행 파일의 진입점에 도달할 때까지 일반적인 프로세스 초기화가 계속(LdrpInitializeProcess
) 진행되며, 이 시점에서 하이퍼바이저가 시작되어 제어권을 갖습니다. 즉, Windows가 모든 작업을 자동으로 처리하고 이제 실행할 준비가 된 대상 실행 파일을 위한 사용자 모드 주소 공간이 미리 채워져 있다는 뜻입니다.
그러면 새 스레드가 일시 중단된 상태로 생성되며 시작 주소는 사용자 지정 로더 함수의 주소를 가리킵니다. 이 함수는 IAT를 채우고, TLS 콜백을 실행하고, 마지막으로 대상 애플리케이션의 원래 진입점을 실행합니다. 이는 기본적으로 프로세스가 기본적으로 실행되는 경우 메인 스레드가 수행하는 작업을 시뮬레이션합니다. 그런 다음 이 스레드의 컨텍스트가 "" 가상 CPU에 복제되고 하이퍼바이저의 제어하에 실행이 시작됩니다.
메모리는 필요에 따라 게스트에 페이징되며, 가상화된 대상 프로세스가 종료될 때까지 시스템 호출을 가로채서 기록하고 호스트 OS로 전달합니다.
WHP API는 현재 프로세스의 메모리만 게스트로 매핑할 수 있으므로 기본 하이퍼바이저 로직은 대상 프로세스에 주입되는 DLL 내에 캡슐화됩니다.
가상 CPU
WHP API는 앞서 설명한 VMX 기능에 대해 "친화적인" 래퍼를 제공하므로 VMLAUNCH
을 실행하기 전에 VMCS를 수동으로 채우는 등의 일반적인 단계가 더 이상 필요하지 않습니다. 또한 사용자 모드에 기능을 노출하므로 사용자 지정 드라이버가 필요하지 않습니다. 그러나 대상 코드를 실행하기 전에 WHP를 통해 가상 CPU를 적절하게 초기화해야 합니다. 중요한 측면은 아래에 설명되어 있습니다.
제어 레지스터
이 프로젝트에는 CR0
, CR3
및 CR4
제어 레지스터만 관련이 있습니다. CR0
및 CR4
는 보호 모드, 페이징 및 PAE와 같은 CPU 구성 옵션을 활성화하는 데 사용됩니다. CR3
에는 PML4
페이징 테이블의 실제 주소가 포함되어 있으며 메모리 페이징 섹션에서 자세히 설명합니다.
모델별 레지스터
가상 CPU의 올바른 작동을 보장하려면 모델별 레지스터(MSR)도 초기화해야 합니다. MSR_EFER
에는 긴 모드(64비트) 및 SYSCALL
명령어 사용과 같은 확장 기능에 대한 플래그가 포함되어 있습니다. MSR_LSTAR
는 시스템 호출 핸들러의 주소를 포함하고, MSR_STAR
은 시스템 호출 중에 CPL0으로(또는 다시 CPL3으로) 전환하기 위한 세그먼트 선택기를 포함합니다. MSR_KERNEL_GS_BASE
에는 GS
선택기의 섀도 기본 주소가 포함되어 있습니다.
글로벌 설명자 테이블
글로벌 설명자 테이블(GDT)은 기본적으로 보호 모드에서 사용할 메모리 영역과 해당 속성을 설명하는 세그먼트 설명자를 정의합니다.
롱 모드에서 GDT는 사용이 제한적이며 대부분 과거의 유물입니다. x64는 항상 모든 선택기가 0
을 기반으로 하는 플랫 메모리 모드로 작동합니다. 이에 대한 유일한 예외는 스레드별 목적으로 사용되는 FS
및 GS
레지스터입니다. 이러한 경우에도 기본 주소는 GDT에 의해 정의되지 않습니다. 대신 MSR(예: 위에서 설명한 MSR_KERNEL_GS_BASE
)을 사용하여 기본 주소를 저장합니다.
이러한 노후화에도 불구하고 GDT는 여전히 x64 모델에서 중요한 부분입니다. 예를 들어 현재 권한 수준은 CS
(코드 세그먼트) 선택기에 의해 정의됩니다.
작업 상태 세그먼트
긴 모드에서는 낮은 권한 수준에서 높은 권한 수준으로 전환할 때 작업 상태 세그먼트(TSS)를 사용하여 스택 포인터를 로드하기만 하면 됩니다. 이 에뮬레이터는 초기 부트로더와 인터럽트 핸들러를 제외하고 거의 독점적으로 CPL3에서 작동하므로 CPL0 스택에는 단일 페이지만 할당됩니다. TSS는 GDT 내에 특수 시스템 항목으로 저장되며 두 개의 슬롯을 차지합니다.
인터럽트 설명자 테이블
IDT(인터럽트 설명자 테이블)에는 핸들러 주소와 같은 각 인터럽트 유형에 대한 정보가 포함되어 있습니다. 이에 대해서는 인터럽트 처리 섹션에서 더 자세히 설명합니다.
부트로더
위에서 언급한 대부분의 CPU 필드는 WHP 래퍼 함수를 사용하여 초기화할 수 있지만 특정 필드에 대한 지원(예 XCR0
)는 이후 버전의 WHP API(Windows 10 RS5)에서만 제공되었습니다. 완성도를 높이기 위해 프로젝트에는 시작 시 CPL0에서 실행되고 대상 코드를 실행하기 전에 CPU의 마지막 부분을 수동으로 초기화하는 작은 '부트로더'가 포함되어 있습니다. 16비트 실제 모드에서 시작하는 물리적 CPU와 달리 가상 CPU는 이미 롱 모드(64비트)에서 실행되도록 초기화되어 있어 부팅 프로세스가 조금 더 간단합니다.
다음 단계는 부트로더가 수행합니다:
-
LGDT
명령어를 사용하여 GDT를 로드합니다. 이 명령의 소스 피연산자는 이전에 채워진 테이블의 기본 주소와 제한(크기)이 포함된 10바이트 메모리 블록을 지정합니다. -
LIDT
명령어를 사용하여 IDT를 로드합니다. 이 명령의 소스 피연산자는 위에서 설명한 LGDT와 동일한 형식을 사용합니다. -
LTR
명령어를 사용하여 작업 레지스터에 TSS 선택기 인덱스를 설정합니다. 위에서 언급했듯이 TSS 설명자는 GDT 내에 특수 항목으로 존재합니다(이 경우0x40
에 있음). -
XCR0 레지스터는
XSETBV
인스트럭션을 사용하여 설정할 수 있습니다. AVX와 같은 옵션 기능에 사용되는 추가 제어 레지스터입니다. 네이티브 프로세스는 XGETBV를 실행하여 호스트 값을 가져온 다음 부트로더의XSETBV
을 통해 게스트에 복사합니다.
이미 로드된 DLL 종속성은 초기화 프로세스 중에 전역 플래그를 설정했을 수 있으므로 이 단계는 중요한 단계입니다. 예를 들어 ucrtbase.dll
은 시작 시 CPU가 CPUID
명령을 통해 AVX를 지원하는지 확인하고, 지원하는 경우 최적화를 위해 CRT가 AVX 명령을 사용할 수 있도록 전역 플래그를 설정합니다. 가상 CPU가 XCR0
에서 명시적으로 활성화하지 않고 이러한 AVX 명령어를 먼저 실행하려고 하면 정의되지 않은 명령어 예외가 발생합니다.
-
DS
,ES
,GS
데이터 세그먼트 선택기를 CPL3에 해당하는 값으로 수동 업데이트합니다(0x2B
).SWAPGS
명령을 실행하여 다음에서 TEB 기본 주소를 로드합니다.MSR_KERNEL_GS_BASE
. -
마지막으로
SYSRET
인스트럭션을 사용하여 CPL3로 전환합니다.SYSRET
인스트럭션 앞에RCX
은 플레이스홀더 주소(CPL3 진입점)로 설정되고R11
은 초기 CPL3 RFLAGS 값(0x202
)으로 설정됩니다.SYSRET
명령은CS
및SS
세그먼트 선택기를 다음에서 CPL3에 해당하는 값으로 자동 전환합니다.MSR_STAR
.
SYSRET
명령이 실행될 때 RIP
의 잘못된 자리 표시자 주소로 인해 페이지 오류가 발생합니다. 에뮬레이터는 이 페이지 오류를 감지하고 이를 '특수' 주소로 인식합니다. 그러면 초기 CPL3 레지스터 값이 가상 CPU에 복사되고 RIP
가 사용자 지정 사용자 모드 로더 함수를 가리키도록 업데이트되며 실행이 다시 시작됩니다. 이 함수는 대상 실행 파일에 대한 모든 DLL 종속성을 로드하고, IAT 테이블을 채우고, TLS 콜백을 실행한 다음 원래 진입점을 실행합니다. 가져오기 테이블과 TLS 콜백은 가상화된 환경 내에서 코드가 실행되도록 하기 위해 이전 단계가 아닌 이 단계에서 처리됩니다.
메모리 페이징
게스트에 대한 모든 메모리 관리는 수동으로 처리해야 합니다. 즉, 가상 CPU가 가상 주소를 실제 주소로 변환할 수 있도록 페이징 테이블을 채우고 유지 관리해야 합니다.
가상 주소 번역
x64의 페이징에 익숙하지 않은 사용자를 위해 페이징 테이블에는 네 가지 레벨이 있습니다: PML4
, PDPT
, PD
및 PT
입니다. 주어진 가상 주소에 대해 CPU는 테이블의 각 계층을 거쳐 최종적으로 목표 물리적 주소에 도달합니다. 최신 CPU는 5레벨 페이징도 지원하지만(4레벨 페이징이 제공하는 256TB의 주소 지정 가능 메모리로 충분하지 않은 경우!), 이 프로젝트의 목적과 무관합니다.
다음 이미지는 샘플 가상 주소의 형식을 보여줍니다:
위의 예제에서 CPU는 다음 표 항목을 통해 가상 주소 0x7FFB7D030D10
에 해당하는 물리적 페이지를 계산합니다: PML4[0xFF]
-> PDPT[0x1ED]
-> PD[0x1E8]
-> PT[0x30]
. 마지막으로 이 실제 페이지에 오프셋(0xD10
)을 추가하여 정확한 주소를 계산합니다.
가상 주소 내의 비트 48
- 63
는 4단계 페이징에서 사용되지 않으며 기본적으로 비트와 일치하도록 부호가 확장됩니다. 47
.
CR3
제어 레지스터에는 기본 PML4
테이블의 물리적 주소가 포함되어 있습니다. 페이징이 활성화된 경우(롱 모드에서는 필수) CPU 컨텍스트 내의 다른 모든 주소는 가상 주소를 참조합니다.
페이지 오류
게스트가 메모리에 액세스를 시도할 때 요청된 페이지가 페이징 테이블에 없는 경우 가상 CPU는 페이지 오류 예외를 발생시킵니다. 이렇게 하면 VM 종료 이벤트가 트리거되고 제어권이 다시 호스트에게 전달됩니다. 이 경우 CR2
제어 레지스터에 요청된 가상 주소가 포함되어 있지만 WHP API는 이미 VM 종료 컨텍스트 데이터 내에서 이 값을 제공합니다. 그러면 호스트는 요청된 페이지를 메모리에 매핑하고(가능한 경우) 실행을 재개하거나 대상 주소가 유효하지 않은 경우 오류를 발생시킬 수 있습니다.
호스트/게스트 메모리 미러링
앞서 언급했듯이 에뮬레이터는 자식 프로세스를 생성하고 해당 프로세스 내의 모든 가상 메모리는 동일한 주소 레이아웃을 사용하여 게스트에 직접 매핑됩니다. 하이퍼바이저 플랫폼 API를 사용하면 호스트 사용자 모드 프로세스의 가상 메모리를 게스트의 물리적 메모리에 직접 매핑할 수 있습니다. 그러면 페이징 테이블이 가상 주소를 해당 물리적 페이지에 매핑합니다.
프로세스의 전체 주소 공간을 미리 매핑하는 대신 고정된 수의 물리적 페이지가 게스트를 위해 할당됩니다. 에뮬레이터에는 매우 기본적인 메모리 관리자가 포함되어 있으며, 페이지는 필요에 따라 "매핑됩니다." 페이지 오류가 발생하면 요청된 페이지가 페이징되고 실행이 재개됩니다. 페이지 "슬롯(" )이 모두 가득 차면 가장 오래된 항목이 새 항목을 위한 공간을 만들기 위해 교체됩니다.
에뮬레이터는 현재 매핑된 고정된 수의 페이지를 사용할 뿐만 아니라 고정된 크기의 페이지 테이블도 사용합니다. 페이지 테이블의 크기는 매핑된 페이지 항목의 양에 대해 가능한 최대 테이블 수를 계산하여 결정됩니다. 이 모델은 단순하고 일관된 물리적 메모리 레이아웃을 제공하지만 효율성을 희생해야 합니다. 실제로 페이징 테이블은 실제 페이지 항목보다 더 많은 공간을 차지합니다.
단일 PML4 테이블이 있으며, 최악의 경우 매핑된 각 페이지 항목은 고유한 PDPT/PD/PT 테이블을 참조하게 됩니다. 각 테이블은 4096
바이트이므로 다음 공식을 사용하여 총 페이지 테이블 크기를 계산할 수 있습니다:
PAGE_TABLE_SIZE = 4096 + (MAXIMUM_MAPPED_PAGES * 4096 * 3)
기본적으로 에뮬레이터는 256
페이지를 한 번에 매핑할 수 있습니다(총 1024KB
개). 위의 공식을 사용하면 아래 그림과 같이 페이징 테이블에 3076KB
가 필요하다는 계산을 할 수 있습니다:
실제로는 많은 페이지 테이블 항목이 공유되며 페이징 테이블에 할당된 많은 공간이 사용되지 않은 채로 남아있게 됩니다. 하지만 이 에뮬레이터는 적은 수의 페이지로도 잘 작동하므로 이 정도의 오버헤드는 큰 문제가 되지 않습니다.
CPU는 TLB(번역 룩어사이드 버퍼)로 알려진 페이징 테이블을 위한 하드웨어 수준 캐시를 유지합니다. 가상 주소를 실제 주소로 변환할 때 CPU는 먼저 TLB를 확인합니다. 캐시에서 일치하는 항목을 찾을 수 없는 경우('TLB 미스'라고 함) 대신 페이징 테이블을 읽습니다. 따라서 페이징 테이블이 재구축될 때마다 TLB 캐시를 플러시하여 동기화되지 않는 것을 방지하는 것이 중요합니다. 전체 TLB를 플러시하는 가장 간단한 방법은 CR3
레지스터 값을 재설정하는 것입니다.
시스템 호출 처리
대상 프로그램이 실행될 때 게스트 내에서 발생하는 모든 시스템 호출은 호스트가 처리해야 합니다. 이 에뮬레이터는 SYSCALL
명령과 레거시(인터럽트 기반) 시스템 호출을 모두 처리합니다. SYSENTER
은 긴 모드에서 사용되지 않으므로 WinVisor에서 지원되지 않습니다.
빠른 시스콜(SYSCALL)
SYSCALL
명령이 실행되면 CPU는 CPL0으로 전환하고 MSR_LSTAR
에서 RIP
을 로드합니다. Windows 커널에서는 KiSystemCall64
을 가리킵니다. SYSCALL
명령은 본질적으로 VM 종료 이벤트를 트리거하지 않지만 에뮬레이터는 MSR_LSTAR
를 예약된 자리 표시자 주소(이 경우 0xFFFF800000000000
)로 설정합니다. SYSCALL
명령이 실행되면 RIP가 이 주소로 설정된 경우 페이지 오류가 발생하고 호출을 가로챌 수 있습니다. 이 자리 표시자는 Windows의 커널 주소이며 사용자 모드 주소 공간과 충돌을 일으키지 않습니다.
레거시 시스템 호출과 달리 SYSCALL
명령은 CPL0으로 전환하는 동안 RSP
값을 바꾸지 않으므로 사용자 모드 스택 포인터를 다음에서 직접 검색할 수 있습니다. RSP
.
레거시 시스템 호출(INT 2E)
레거시 인터럽트 기반 시스템 호출은 SYSCALL
명령보다 속도가 느리고 오버헤드가 더 많지만 그럼에도 불구하고 Windows에서 계속 지원됩니다. 에뮬레이터에는 이미 인터럽트 처리를 위한 프레임워크가 포함되어 있으므로 레거시 시스콜에 대한 지원을 추가하는 것은 매우 간단합니다. 레거시 시스템 호출 인터럽트가 포착되면, CPL0 스택에서 저장된 사용자 모드 RSP
값을 검색하는 등 약간의 변환을 거쳐 "공통" 시스템 호출 핸들러로 전달할 수 있습니다.
시스템 호출 전달
에뮬레이터가 컨텍스트가 가상 CPU에 복제되는 "메인 스레드" 를 생성한 후 이 네이티브 스레드는 프록시로 재사용되어 호스트에 시스템 호출을 전달합니다. 동일한 스레드를 재사용하면 게스트와 호스트 간의 TEB 및 커널 상태에 대한 일관성이 유지됩니다. 특히 Win32k는 많은 스레드별 상태에 의존하므로 에뮬레이터에 이를 반영해야 합니다.
SYSCALL
명령 또는 레거시 인터럽트에 의해 시스템 호출이 발생하면 에뮬레이터가 이를 가로채서 범용 핸들러 함수로 전송합니다. 시스템 호출 번호는 RAX
레지스터에 저장되고 처음 네 개의 매개 변수 값은 각각 R10
, RDX
, R8
, R9
에 저장됩니다. SYSCALL
명령어가 반환 주소로 RCX
를 덮어쓰기 때문에 R10
이 일반적인 RCX
레지스터 대신 첫 번째 매개 변수에 사용됩니다. Windows(KiSystemService
)의 레거시 시스템 호출 핸들러도 호환성을 위해 R10
을 사용하므로 에뮬레이터에서 다르게 처리할 필요가 없습니다. 나머지 매개변수는 스택에서 검색됩니다.
주어진 시스템 호출 번호에 대해 예상되는 정확한 매개변수 수는 알 수 없지만 다행히도 이는 중요하지 않습니다. 단순히 고정된 양을 사용할 수 있으며, 제공된 매개 변수의 수가 실제 수보다 크거나 같으면 시스템 호출이 올바르게 작동합니다. 간단한 어셈블리 스텁이 동적으로 생성되어 모든 매개 변수를 채우고 대상 syscall을 실행한 후 깔끔하게 반환합니다.
테스트 결과 현재 Windows 시스템 호출에서 사용하는 최대 매개 변수 수는 17
(NtAccessCheckByTypeResultListAndAuditAlarmByHandle
, NtCreateTokenEx
및 NtUserCreateWindowEx
)인 것으로 나타났습니다. WinVisor는 향후 잠재적인 확장을 위해 32
를 최대 매개변수 수로 사용합니다.
호스트에서 시스템 호출을 실행하면 반환 값이 게스트의 RAX
에 복사됩니다. RIP
이 SYSRET
명령(또는 레거시 시스템 호출의 경우 IRETQ
)으로 전송된 다음 가상 CPU를 다시 시작하여 사용자 모드로 원활하게 전환합니다.
시스템 호출 로깅
기본적으로 에뮬레이터는 게스트 시스템 호출을 호스트에 전달하고 콘솔에 기록하기만 합니다. 그러나 원시 시스템 호출을 읽기 가능한 형식으로 변환하려면 몇 가지 추가 단계가 필요합니다.
첫 번째 단계는 시스템 호출 번호를 이름으로 변환하는 것입니다. 비트 12
- 13
에는 시스템 서비스 테이블 인덱스(ntoskrnl
의 경우 0
, win32k
의 경우 1
)가 포함되고, 비트 0
에는 시스템 서비스 테이블 인덱스가 포함됩니다. - 비트 11
은 테이블 내의 시스템 호출 인덱스를 포함합니다. 이 정보를 통해 해당 사용자 모드 모듈(ntdll
/ win32u
) 내에서 역방향 조회를 수행하여 원래 시스템 호출 이름을 확인할 수 있습니다.
다음 단계는 각 시스템 호출에 표시할 매개변수 값의 수를 결정하는 것입니다. 위에서 언급했듯이 에뮬레이터는 32
매개변수 값을 대부분 사용하지 않더라도 각 시스템 호출에 전달합니다. 그러나 각 시스템 호출에 대해 32
값을 모두 기록하는 것은 가독성 측면에서 이상적이지 않습니다. 예를 들어 간단한 NtClose(0x100)
호출은 NtClose(0x100, xxx, xxx, xxx, xxx, xxx, xxx, xxx, xxx, ...)
으로 인쇄됩니다. 앞서 언급했듯이 각 시스템 호출에 대한 정확한 매개변수 수를 자동으로 결정하는 간단한 방법은 없지만 높은 정확도로 추정하는 데 사용할 수 있는 트릭이 있습니다.
이 트릭은 와우64에서 사용하는 32비트 시스템 라이브러리에 의존합니다. 이러한 라이브러리는 호출자가 모든 매개변수를 스택에 푸시하고 호출자가 반환하기 전에 내부적으로 정리하는 stdcall 호출 규칙을 사용합니다. 반면 네이티브 x64 코드는 첫 번째 4 파라미터를 레지스터에 배치하고 호출자가 스택을 관리합니다.
예를 들어, WoW64 버전 ntdll.dll
의 NtClose
함수는 RET 4
명령어로 끝납니다. 이렇게 하면 반환 주소 뒤에 스택에서 4바이트가 추가로 튀어나오는데, 이는 함수가 하나의 매개변수를 사용한다는 것을 의미합니다. 함수가 RET 8
을 사용했다면 2 개의 매개변수를 사용한다는 뜻입니다.
에뮬레이터가 64비트 프로세스로 실행되더라도 ntdll.dll
및 win32u.dll
의 32비트 복사본을 수동으로 또는 SEC_IMAGE
를 사용하여 매핑된 상태로 메모리에 로드할 수 있습니다. GetProcAddress
의 사용자 지정 버전을 작성하여 WoW64 내보내기 주소를 해결해야 하지만 이는 간단한 작업입니다. 여기에서 각 시스템 호출에 해당하는 WoW64 내보내기를 자동으로 찾고 RET
명령어를 스캔하여 매개변수 수를 계산한 다음 값을 조회 테이블에 저장할 수 있습니다.
이 방법은 완벽하지 않으며 실패할 수 있는 여러 가지 방법이 있습니다:
- 와우64에는 다음과 같은 소수의 기본 시스템 호출이 존재하지 않습니다.
NtUserSetWindowLongPtr
. - 32비트 함수에 64비트 매개변수가 포함된 경우 내부적으로 32비트 매개변수 2개로 분할되는 반면, 해당 64비트 함수에는 동일한 값에 대해 하나의 매개변수만 필요합니다.
- Windows 내의 WoW64 시스템 호출 스텁 함수가 기존
RET
명령어 검색이 실패하는 방식으로 변경될 수 있습니다.
이러한 함정에도 불구하고 하드코딩된 값에 의존하지 않고도 대부분의 시스템 호출에 대해 정확한 결과를 얻을 수 있습니다. 또한 이러한 값은 로깅 목적으로만 사용되며 다른 항목에는 영향을 미치지 않으므로 사소한 부정확성은 허용됩니다. 실패가 감지되면 최대 매개변수 값 수 표시로 되돌아갑니다.
시스콜 후킹
이 프로젝트가 샌드박싱 목적으로 사용되는 경우 모든 시스템 호출을 무작위로 호스트에 전달하는 것은 명백한 이유로 바람직하지 않습니다. 에뮬레이터에는 필요한 경우 특정 시스템 호출을 쉽게 연결할 수 있는 프레임워크가 포함되어 있습니다.
기본적으로 NtTerminateThread
및 NtTerminateProcess
만 후크되어 게스트 프로세스가 종료되는 것을 포착합니다.
인터럽트 처리
인터럽트는 가상 CPU 실행이 시작되기 전에 채워지는 IDT에 의해 정의됩니다. 인터럽트가 발생하면 현재 CPU 상태가 CPL0 스택(SS
, RSP
, RFLAGS
, CS
, RIP
)으로 푸시되고 RIP
가 대상 핸들러 함수로 설정됩니다.
SYSCALL 핸들러의 MSR_LSTAR
과 마찬가지로 에뮬레이터는 모든 인터럽트 핸들러 주소를 자리 표시자 값(0xFFFFA00000000000
- 0xFFFFA000000000FF
)으로 채웁니다. 인터럽트가 발생하면 이 범위 내에서 페이지 오류가 발생하며, 이를 포착할 수 있습니다. 인터럽트 인덱스는 대상 주소의 가장 낮은 8비트에서 추출할 수 있으며(예: 0xFFFFA00000000003
은 INT 3
), 호스트는 필요에 따라 이를 처리할 수 있습니다.
현재 에뮬레이터는 INT 1
(단일 단계), INT 3
(중단점) 및 INT 2E
(레거시 시스콜)만 처리합니다. 다른 인터럽트가 발생하면 에뮬레이터가 오류와 함께 종료됩니다.
인터럽트가 처리되면 RIP
은 IRETQ
명령으로 전송되어 사용자 모드로 깔끔하게 돌아갑니다. 일부 유형의 인터럽트는 스택에 "오류 코드" 값을 추가로 푸시하는데, 이 경우 스택 손상을 방지하기 위해 IRETQ
명령어 이전에 이 코드를 팝해야 합니다. 이 에뮬레이터 내의 인터럽트 핸들러 프레임워크에는 이를 투명하게 처리하는 선택적 플래그가 포함되어 있습니다.
하이퍼바이저 공유 페이지 버그
Windows 10 에서 KUSER_SHARED_DATA
에 가까운 새로운 유형의 공유 페이지를 도입했습니다. 이 페이지는 타이밍 관련 기능(예: RtlQueryPerformanceCounter
및 RtlGetMultiTimePrecise
.
이 페이지의 정확한 주소는 SystemHypervisorSharedPageInformation
정보 클래스를 사용하여 NtQuerySystemInformation
으로 검색할 수 있습니다. LdrpInitializeProcess
함수는 프로세스 시작 중에 이 페이지의 주소를 전역 변수(RtlpHypervisorSharedUserVa
)에 저장합니다.
이 공유 페이지가 게스트에 매핑되어 가상 CPU에서 읽기를 시도하는 경우 WHP API에 WHvRunVirtualProcessor
함수가 무한 루프에 멈추는 버그가 있는 것 같습니다.
시간 제약으로 인해 이를 완전히 조사하는 데는 한계가 있었지만 간단한 해결 방법을 구현했습니다. 에뮬레이터가 대상 프로세스 내에서 NtQuerySystemInformation
함수를 패치하고 SystemHypervisorSharedPageInformation
요청에 대해 STATUS_INVALID_INFO_CLASS
을 반환하도록 강제합니다. 이로 인해 ntdll
코드가 기존 방법으로 되돌아갑니다.
데모
아래는 이 가상화된 환경에서 에뮬레이션되는 일반적인 Windows 실행 파일의 몇 가지 예입니다:
제한 사항
에뮬레이터에는 현재 형태로는 보안 샌드박스로 사용하기에 안전하지 않은 몇 가지 제한 사항이 있습니다.
안전 문제
단순히 새 프로세스/스레드를 생성하거나, 비동기 프로시저 호출(APC)을 예약하는 등 여러 가지 방법으로 "" VM을 탈출할 수 있습니다.
Windows GUI 관련 시스템 호출은 커널에서 사용자 모드로 직접 중첩 호출을 할 수도 있으며, 현재 하이퍼바이저 계층을 우회합니다. 이러한 이유로 notepad.exe와 같은 GUI 실행 파일은 WinVisor에서 실행할 때 부분적으로만 가상화됩니다.
이를 보여주기 위해 WinVisor에는 에뮬레이터에 대한 -nx
명령줄 스위치가 포함되어 있습니다. 이렇게 하면 가상 CPU를 시작하기 전에 전체 대상 EXE 이미지가 메모리에서 실행 불가능한 것으로 표시되어 호스트 프로세스가 기본적으로 코드를 실행하려고 시도하면 프로세스가 충돌하게 됩니다. 대상 애플리케이션이 해당 영역을 다시 실행 가능하게 만들거나 단순히 실행 가능한 메모리를 다른 곳에 할당할 수 있기 때문에 이 방법은 여전히 안전하지 않습니다.
WinVisor DLL은 대상 프로세스에 주입되므로 대상 실행 파일과 동일한 가상 주소 공간 내에 존재합니다. 즉, 가상 CPU에서 실행되는 코드가 호스트 하이퍼바이저 모듈 내의 메모리에 직접 액세스할 수 있어 잠재적으로 메모리가 손상될 수 있습니다.
비실행 게스트 메모리
가상 CPU가 NX를 지원하도록 설정되어 있지만, 현재 모든 메모리 영역은 전체 RWX 액세스 권한을 가진 게스트에 미러링되어 있습니다.
단일 스레드 전용
에뮬레이터는 현재 단일 스레드 가상화만 지원합니다. 대상 실행 파일이 추가 스레드를 생성하는 경우 기본적으로 실행됩니다. 다중 스레드를 지원하기 위해 향후 이를 처리하는 의사 스케줄러를 개발할 수 있습니다.
모든 모듈 종속성이 단일 스레드에서 로드되도록 하기 위해 Windows 병렬 로더를 비활성화합니다.
소프트웨어 예외
가상화된 소프트웨어 예외는 현재 지원되지 않습니다. 예외가 발생하면 시스템은 평소와 같이 KiUserExceptionDispatcher
함수를 기본적으로 호출합니다.
결론
위에서 보았듯이 에뮬레이터는 현재 형태의 다양한 실행 파일에서 잘 작동합니다. 현재 시스템 호출 및 인터럽트 로깅에는 효과적이지만 멀웨어 분석 목적으로 안전하게 사용하려면 많은 추가 작업이 필요합니다. 그럼에도 불구하고 이 프로젝트는 향후 개발을 위한 효과적인 프레임워크를 제공합니다.
프로젝트 링크
https://github.com/x86matthew/WinVisor
저자는 X에서 @x86matthew로 만날 수 있습니다.