lunedì 15 novembre 2021

4.F - Demo: Inizializzare la VMCS e lanciare il guest

In questa lezione si cercherà di mettere a frutto quanto imparato nelle lezioni precedenti creando un semplice hypervisor che esce dalla VMX operation se il guest esegue l'istruzione HLT. L'immagine seguente mostra i messaggi mostrati dal debugger che vengono generati da tale hypervisor.




Per non rendere troppo tedioso il discorso si eviterà di mostrare nuovamente il codice già analizzato nel corso delle precedenti lezioni. Il progetto completo può essere visionato cliccando sul link fornito a fine lezione.




Variabili globali

Per la corretta esecuzione di questa prima demo serviranno alcune variabili globali, visibili a varie componenti del nostro hypervisor.

// Variabili globali in cui salvare il valore dei registri RSP ed RBP
// al fine di ritornare nel codice di DriverEntry una volta che si
// decide di uscire dalla VMX operation.
UINT64 g_StackPointerForReturning;
UINT64 g_BasePointerForReturning;
 

// Risorse allocate nella virtualizzazione di ogni processore logico.
typedef struct _VCPU
{
    UINT64 VMXON_REGION_VA;			// Indirizzo virtuale di regione VMXON
    UINT64 VMXON_REGION_PA;			// Indirizzo fisico di regione VMXON
    UINT64 VMCS_REGION_VA;			// Indirizzo virtuale di regione VMCS
    UINT64 VMCS_REGION_PA;			// Indirizzo fisico di regione VMCS
    UINT64 VMM_STACK;				// Stack dell'hypervisor durante VM-Exit
    UINT64 GuestRipVA;                          // VM Entry point
    GROUP_AFFINITY OldAffinity;                 // Original Thread Affinity
}VCPU, * PVCPU;

 
// Variabile globale che punterà ad un array di VCPU, con le risorse
// allocate per tutti i processori.
extern PVCPU vmState;

La struttura VCPU è già stata descritta brevemente in una precedente lezione. Il suo scopo è quello di raccogliere tutte le risorse allocate necessarie alla virtualizzazione di ogni processore, in modo che le varie componenti del nostro hypervisor possano accedervi qualora ce ne fosse bisogno. Il campo OldAffinity non punta propriamente ad una risorsa ma la inseriamo lo stesso in tale struttura perché ci farà comodo. La variabile vmState è esterna perché è quella che verrà inizializzata ed usata dalle varie componenti dell'hypervisor.
Sulle variabili globali g_StackPointerForReturning e g_BasePointerForReturning si ritornerà a breve nelle prossime sezioni, dove si spiegherà la loro utilità ed il loro utilizzo.




DriverEntry

Per semplificare le cose, in questa specifica demo ci si limiterà a virtualizzare solo un processore logico. Il motivo è spiegato brevemente nei commenti al codice seguente ma il tutto sarà più chiaro una volta che si saranno esaminati anche gli altri estratti di codice nelle prossime sezioni.

PVCPU vmState = NULL;
 
 
NTSTATUS DriverEntry(_In_ PDRIVER_OBJECT DriverObject_In_ PUNICODE_STRING RegistryPath)
{
 
    // ...
 
    // Lo scopo di questo esempio è quello di uscire dalla VMX operation
    // alla prima istruzione del guest, che sarà HLT.
    // Dato che il guest non è ancora l'intero sistema in uso, si può usare
    // un solo processore logico che, una volta uscito dalla VMX operation,
    // può ritorna qui, per continuare ad eseguire il codice di DriveEntry.
    // Se si volessero usare tutti i processori, cosa eseguirebbero questi una
    // volta usciti dalla VMX operation se solo ad uno di essi è permesso di
    // eseguire DriverEntry? Si ragioni su questo.
    UINT64 ProcessorCount = 1; // KeQueryActiveProcessorCountEx(ALL_PROCESSOR_GROUPS);
 
    ULONG ProcessorIndex = KeGetCurrentProcessorIndex();
 
    // Alloca spazio per un solo VCPU nella variabile globale che conserva le
    // risorse da allocare per la virtualizzazione di ogni processore logico.
    vmState = ExAllocatePoolWithTag(NonPagedPoolsizeof(VCPU) * ProcessorCountPOOLTAG);
    RtlZeroMemory(vmState, sizeof(VCPU) * ProcessorCount);
 
    // Inizializza VMX, VMCS e lancia il guest
    if (VmxInitialize(ProcessorCountProcessorIndex))
    {
    	KdPrint(("[*] Hypervisor loaded successfully :)\n"));
 
    	KdPrint(("[*] Restoring old thread affinity.\n"));
    	KeRevertToUserGroupAffinityThread(&(vmState->OldAffinity));
 
    	KdPrint(("[*] Clearing CR4.VMXE bit.\n"));
    	CpuVmxEnable(FALSE);
    }
    else
    {
    	KdPrint(("[*] Hypervisor was not loaded :("));
 
    	KdPrint(("[*] Restoring old thread affinity.\n"));
    	KeRevertToUserGroupAffinityThread(&(vmState->OldAffinity));
 
    	KdPrint(("[*] Clearing CR4.VMXE bit.\n"));
    	CpuVmxEnable(FALSE);
    }
 
    return STATUS_SUCCESS;
}

Dovendo virtualizzare un solo processore, la variabile globale vmState punterà ad un array di un solo elemento di tipo VCPU.




VmxInitialize

La prima parte di questo metodo si occupa di abilitare la VMX operation e di inizializzare la VMCS. Si noti come le allocazioni di memoria eseguite durante la fase di inizializzazione della VMCS vengano salvate nella variabile globale vmState.

// Inizializza VMX, VMCS e lancia il guest
BOOLEAN VmxInitialize(UINT64 LogicalProcessorsULONG ProcessorIndex)
{
    // Controlla se è possibile entrare in VMX Operation
    if (!CpuIsVmxSupported())
    {
    	KdPrint(("[*] VMX is not supported in this machine !"));
    	return FALSE;
    }
 
    KIRQL OldIrql;
    PROCESSOR_NUMBER ProcessorNumber;
    GROUP_AFFINITY AffinityOldAffinity;
 
    KdPrint(("\n=====================================================\n"));
 
    for (ULONG i = 0; i < LogicalProcessorsi++)
    {
    	// Converte da indice di sistema ad indice locale nel relativo gruppo
    	KeGetProcessorNumberFromIndex(ProcessorIndex, &ProcessorNumber);
 
    	// Imposta affinità del thread corrente per eseguirlo su i-esimo processore
    	RtlSecureZeroMemory(&Affinitysizeof(GROUP_AFFINITY));
    	Affinity.Group = ProcessorNumber.Group;
    	Affinity.Mask = (KAFFINITY)((ULONG64)1 << ProcessorNumber.Number);
    	KeSetSystemGroupAffinityThread(&Affinity, &OldAffinity);
 
    	KdPrint(("\n\t\tCurrent thread is executing in %d th logical processor.\n\n"ProcessorIndex));
 
    	// Eleva IRQL a DISPATCH_LEVEL per impedire context switch
    	OldIrql = KeRaiseIrqlToDpcLevel();
 
    	// Imposta i bit di CR0 e CR4 (compreso CR4.VMXE)
    	CpuFixBits();
 
    	KdPrint(("[*] VMX-Operation Enabled Successfully\n"));
 
    	// Salva vecchia maschera di affinità del thread.
    	KdPrint(("[*] Saving old thread affinity.\n"));
    	vmState[i].OldAffinity = OldAffinity;
 
    	// Alloca spazio per VMXON region
    	if (!AllocateVMCSRegion(&vmState[i], TRUE))
    	{
    		KdPrint(("[*] Error in allocating memory for Vmxon region"));
    		return FALSE;
    	}
 
    	// Alloca spazio per VMCS region
    	if (!AllocateVMCSRegion(&vmState[i], FALSE))
    	{
    		KdPrint(("[*] Error in allocating memory for Vmcs region"));
    		return FALSE;
    	}
 
    	KdPrint(("[*] VMCS Region is allocated at  ===============> %llx\n", vmState[i].VMCS_REGION_VA));
    	KdPrint(("[*] VMXON Region is allocated at ===============> %llx\n", vmState[i].VMXON_REGION_VA));
 
    	// Alloca spazio che verrà usato come stack dall'hypervisor durante le Vm exit
    	if (!AllocateVmmStack(i))
    		return FALSE;
 
    	KdPrint(("[*] HOST stack is allocated at =================> %llx\n", vmState[i].VMM_STACK));
 
    	// Alloca memoria che conterrà il codice del guest.
    	// In questo caso si tratta di una semplice pagina che inizia con l'istruzione HLT.
    	vmState[i].GuestRipVA = (UINT64)ExAllocatePoolWithTag(NonPagedPoolPAGE_SIZEPOOLTAG);
    	if (vmState[i].GuestRipVA == (UINT64)NULL)
    		return FALSE;
    	RtlZeroMemory((PUINT64)vmState[i].GuestRipVA, PAGE_SIZE);
    	voidTempAsm = "\xF4"// F4 opcode di HLT
    	memcpy((PUINT64)vmState[i].GuestRipVA, TempAsm, 1);
 
    	KdPrint(("[*] HOST RIP is allocated at ===================> %llx\n", vmState[i].GuestRipVA));
 
    	// Imposta a clear lo stato della VMCS.
    	if (!VmxClearVmcs(&vmState[i])) {
    		return FALSE;
    	}
 
    	// Imposta la VMCS come corrente ed attiva.
    	if (!VmxLoadVmcs(&vmState[i]))
    	{
    		return FALSE;
    	}
 
    	// Inizializza la VMCS
    	KdPrint(("[*] Setting up VMCS.\n"));
    	SetupVmcs(&vmState[i], vmState[i].GuestRipVA);
 
    	// Ristabilisce IRQL.
    	KeLowerIrql(OldIrql);
 
    	DbgPrint("[*] Executing VMLAUNCH.\n");
    	KdPrint(("\n=====================================================\n"));
 
    	// Dopo VMLAUNCH il controllo viene passato al guest.
    	// In questo caso il guest eseguirà HLT che causerà una VM exit: questa verrà gestita
    	// semplicemente spegnendo tutto con VMXOFF. Cosa succede dopo?
    	// L'idea è quella di proseguire da qui, ed in particolare dal return TRUE
    	// finale che ritorna al codice che ha invocato VmxInitialize (in questo caso
    	// DriverEntry) per proseguire normalmente. 
    	// Il problema è che return TRUE non verrà mai eseguita. Quindi? 
    	// Se si memorizzano i registri RSP ed RBP in questo punto è possibile ripristinarli 
    	// successivamente nel gestore della VM exit provocata dall'HLT. A quel punto
    	// si può manipolare lo stack e ricalcolare manualmente l'indirizzo di ritorno che punta
    	// all'istruzione successiva alla chiamata di VmxInitialize in DriverEntry.
    	// Per simulare il valore di ritorno, invece, è sufficiente impostare RAX ad 1 
    	// (si veda codice di AsmSaveVMXOFFState).
    	AsmSaveVMXOFFState();
 
 
    	// Esegue codice guest.
    	// In questo caso si esegue HLT, che provoca una VM exit.
    	// Quindi si può proseguire andando a vedere il codice di AsmVMExitHandler. 
    	__vmx_vmlaunch();
 
    	//
    	// Se VMLAUNCH viene eseguita con successo non si arriverà mai qui...
    	//
 
    	// ... In caso contrario legge l'errore ed esegue VMXOFF.
    	ULONG64 ErrorCode = 0;
    	__vmx_vmread(VM_INSTRUCTION_ERROR, &ErrorCode);
    	__vmx_off();
    	KdPrint(("[*] VMLAUNCH Error : 0x%llx\n"ErrorCode));
    	DbgBreakPoint();
    }
 
    // Se tutto è andato per il meglio non si arriverà mai qui.
    return TRUE;
}

Dopo aver inizializzato la VMCS (ed averlo reso quello corrente ed attivo) si è pronti a lanciare il relativo guest, da eseguire sull'unico processore virtualizzato. Prima di fare ciò, però, bisogna valutare bene quello che andrà a fare il guest e cosa dovrebbe avvenire una volta usciti dalla VMX operation con VMXOFF (cioè da dove si riprende ad eseguire il codice?). In questo caso si vuole che il guest esegua una sola istruzione (HLT) e che questo causi una VM exit, il cui gestore esegue una VMXOFF per uscire dalla VMX operation. OK! Semplice. E dopo che si fa? La logica suggerisce di riprendere ad eseguire il codice dal punto in cui si era interrotto a seguito del lancio del guest. A tale scopo si possono salvare i valori dei registri RSP ed RBP poco prima di invocare __vmx_vmlaunch. Una volta usciti dalla VMX operation si possono ricaricare i valori salvati nei rispettivi registri al fine di ricalcolare un indirizzo di ritorno valido (si vedrà come a breve). 
Nota: in questo momento la cosa importante da comprendere è che se si riprende da dove si era interrotto (__vmx_vmlaunch) alla fine si rientrerà in DriverEntry (il chiamante di VmxInitialize), il cui codice dovrebbe essere eseguito una sola volta. Ecco spiegato il motivo per cui, almeno in questa demo, si virtualizza un solo processore. Quando si riuscirà ad eseguire l'intero sistema come guest allora le cose saranno diverse (come verrà spiegato al momento opportuno nel corso delle prossime lezioni).




SetupVmcs 

Questo metodo non presenta particolari novità rispetto a quanto già visto nel corso delle lezioni precedenti, dedicate all'inizializzazione della VMCS. Il codice completo può essere consultato visitando il repository del progetto, il cui link è fornito a fine lezione. Da notare come il codice guest eseguito al lancio si trovi nella pagina allocata in VmxInitialize (passata a SetupVmcs come parametro), che ha il primo byte uguale ad F4 (opcode di HLT).

VOID SetupVmcs(PVCPU vmsUINT64 guestRIP)
{

    // ...

 
    // Primary processor-based control
    PRIMARY_PROCBASED_CTLS primary_controls = { 0 };
 
 
    // Imposta bit specifici del primary processor-based control.
    SetPrimaryCtls(&primary_controls);
 
 
 
    // ...
    
    
    // Imposta il codice guest da eseguire al VMLAUNCH
    __vmx_vmwrite(GUEST_RIPguestRIP);
    
    
    // ...
    
    
    // Imposta il gestore delle VM exit per l'hypervisor
    __vmx_vmwrite(HOST_RIP, (UINT64)AsmVMExitHandler);
    

    // ...
    
 
    // Salva primary processor-based control in VMCS corrente ed attiva
    __vmx_vmwrite(CONTROL_PRIMARY_PROCESSOR_BASED_VM_EXECUTION_CONTROLS,
    	AdjustControls(primary_controls.Uint32,
    		vmx_basic.Bits.VmxTrueControls ? MSR_IA32_VMX_TRUE_PROCBASED_CTLS : MSR_IA32_VMX_PROCBASED_CTLS));
 
}

L'unica cosa su cui è importante soffermarsi è l'impostazione di un particolare bit nel primary processor-based control: quello che indica di causare una VM exit se il guest esegue una istruzione HLT. Questo è esattamente il comportamento voluto, almeno in questa demo.

void SetPrimaryCtls(PPRIMARY_PROCBASED_CTLS primary_controls)
{

    // ..
 

    /**
    * This control determines whether the secondary processor-based VM-execution controls are
    * used. If this control is 0, the logical processor operates as if all the secondary processor-based
    * VM-execution controls were also 0.
    */
    primary_controls->Bits.ActivateSecondaryControls = TRUE;
 
    /**
    * This control determines whether executions of HLT cause VM exits.
    */
    primary_controls->Bits.HLTExiting = TRUE;
}

Il gestore delle VM exit (AsmVMExitHandler) sarà esaminato a breve.




AsmSaveVMXOFFState

Prima di eseguire VMLAUNCH (tramite intrinsic __vmx_vmlaunch) in VmxInitialize, si invoca la funzione AsmSaveVMXOFFState, che salva i valori dei registri RSP ed RBP (per tale motivo deve essere scritta in linguaggio assembly).

EXTERN g_StackPointerForReturning : QWORD
EXTERN g_BasePointerForReturning : QWORD



AsmSaveVMXOFFState PROC PUBLIC
 
    ; Salva RSP e RBP nelle variabili globali.
    ; Su stack in questo momento c'è Return Address (RA) a VmxInitialize. 
    ; In altre parole, RSP punta ad istruzione dopo call AsmSaveVMXOFFState in
    ; VmxInitialize, che in questo caso è __vmx_vmlaunch.
 
    mov     g_StackPointerForReturning, rsp
    mov     g_BasePointerForReturning, rbp
 
    ret
 
AsmSaveVMXOFFState ENDP

Al momento dell'invocazione di AsmSaveVMXOFFState sullo stack c'è l'indirizzo di ritorno a VmxInitialize, che punta all'istruzione successiva (e cioè __vmx_vmlaunch). Per il momento è sufficiente essere consapevoli del fatto che RSP punta a __vmx_vmlaunch in VmxInitialize. Il motivo per cui è necessario salvare tali registri in variabili globali sarà spiegato a breve ed allora tutto avrà più senso.




AsmVMExitHandler

Una volta eseguita __vmx_vmlaunch in VmxInitialize viene causata immediatamente una VM exit a seguito del fatto che il primo byte del codice guest rappresenta proprio una istruzione HLT. Come disposto nel codice di inizializzazione della VMCS, tale istruzione causa una VM exit.

EXTERN VmxVMExitHandler : PROC
 
 
 
AsmVMExitHandler PROC

     ; shadow space/storage (4 * 8 byte + altri 8 byte per 
     ; riallineare lo stack a 16 byte, disallineato a causa
     ; dell'inserimento implicito del return address sullo stack
     ; al momento della call). Totale 40 = 28h byte.
     sub	rsp, 28h
     call	VmxVMExitHandler
     add	rsp, 28h	
     ret
 
AsmVMExitHandler ENDP

Il gestore delle VM exit non fa altro che invocare la funzione VmxExitHandler. Anche se al momento non si vede una reale utilità nello scrivere il gestore in assembly, sarà evidente nel corso delle prossime lezioni come questo sia essenziale per salvare alcuni dati di vitale importanza.




VmxVMExitHandler

Questo metodo è, di fatto, il reale gestore delle VM exit nell'hypervisor. Per questa demo è sufficiente intercettare le VM exit causate dall'esecuzione dell'istruzione HLT da parte del guest. L'hypervisor può sapere qual è stata la causa della VM exit leggendo un particolare campo della VMCS, all'interno del VM-Exit Information (area della VMCS già vista brevemente nella scorsa lezione). Il valore codificato VM_EXIT_REASON si riferisce proprio a tale campo ed i valori che è possibile leggere in esso sono definiti nell'appendice C del volume 3D dell'Intel SDM. 
VM_EXIT_QUALIFICATION invece, che si riferisce ad un altro campo del VM-Exit Information, contiene informazioni aggiuntive sulle cause del VM exit (al momento non ci interessa approfondire tale argomento; maggiori dettagli verranno forniti nelle prossime lezioni).

VOID VmxVMExitHandler()
{
     ULONG ExitReason = 0;
     __vmx_vmread(VM_EXIT_REASON, &ExitReason);
 
 
     ULONG ExitQualification = 0;
     __vmx_vmread(VM_EXIT_QUALIFICATION, &ExitQualification);
 
     KdPrint(("\nEXIT_REASION 0x%x\n"ExitReason & 0xffff));
     KdPrint(("EXIT_QUALIFICATION 0x%x\n\n"ExitQualification));
 
 
     switch (ExitReason)
     {
     	case EXIT_REASON_HLT:
     	{
		DbgPrint("[*] Execution of HLT detected... \n");
 
     		// DbgBreakPoint();
 
     		// Spegne tutto con VMXOFF e dirotta l'esecuzione in DriverEntry.
     		AsmRestoreToVMXOFFState();
 
     		break;
     	}
     	default:
     	{
     		// DbgBreakPoint();
     		break;
 
     	}
     }
}

I commenti al codice indicano che se è stata eseguita un'istruzione HLT allora AsmRestoreToVMXOFFState esegue VMXOFF (che permette di uscire dalla VMX operation) e dirotta l'esecuzione del thread in DriverEntry. WOW! Sembra piuttosto interessante perché è molto simile a quello che si sarebbe voluto ottenere una volta usciti dalla VMX operation. Nella sezione sul metodo VmxInitialize si era detto che si voleva riprendere da dopo __vmx_vmlaunch, ma dopo tale istruzione ci sono altre istruzioni dedicate al fallimento del lancio del guest (se __vmx_vmlaunch fallisce il codice riprende normalmente all'istruzione successiva) ed allora è meglio riprendere dal return TRUE finale, che ritorna a DriverEntry.




AsmRestoreToVMXOFFState

La prima cosa che fa AsmRestoreToVMXOFFState è ripristinare i valori di RSP ed RBP al momento dell'invocazione di AsmSaveVMXOFFState. In questo modo lo stato dello stack ritornerà quello di quel preciso istante, dove RSP puntava a __vmx_vmlaunch in VmxInitialize. Ora, come detto nella sezione precedente, non siamo interessati a ritornare a tale punto ma al return finale, che ci conduce nuovamente in DriverEntry. Il modo più semplice per realizzare ciò è quello di simulare il return TRUE inserendo 1 in RAX ed andandosi a recuperare l'indirizzo di ritorno a DriverEntry guardando sullo stack, come spiegato nei commenti al seguente codice.

AsmRestoreToVMXOFFState PROC PUBLIC
 
     ; Esce dalla VMX operation
     vmxoff
 
     ; Dopo le seguenti istruzioni RSP punta nuovamente ad indirizzo 
     ; di __vmx_vmlaunch in VmxInitialize e lo stack è nella stessa
     ; situazione di quando sono state salvati RSP ed RBP in AsmSaveVMXOFFState.
     mov	rsp, g_StackPointerForReturning
     mov	rbp, g_BasePointerForReturning
 
     ; NOTA: All'inizio di VmxInitialize c'è:
 
     ; push	rsi
; push rdi
; sub rsp, 78h
; che serve a salvare (eventualmente) alcuni registri e fare spazio alle var locali:  ; potrebbe cambiare da compilazione a compilazione quindi meglio controllare con un disassembler. ; Ma prima di eseguire tali istruzione, su stack c'era Return Address a chiamante di  ; VmxInitialize, e cioè RSP puntava ad istruzione successiva a call VmxInitialize in DriverEntry. ; Dopo aver eseguito tali istruzioni, invece, su stack c'è lo spazio allocato all'inizio di  ; VmxInitialize (con i vari push e sub) e poi l'indirizzo di __vmx_vmlaunch in VmxInitialize, quindi:  ; 0x78(push) + 8(push) + 8(push) + 8(RA vmlaunch) byte prima di arrivare a ; Return Address che punta all'istruzione successiva a call VmxInitialize in DriverEntry. ; Toglie gli 8 byte dell'RA a __vmx_vmlaunch in VmxInitialize dallo stack add rsp, 8 ; Simula il return TRUE in VmxInitialize xor rax, rax mov rax, 1 ; Controlla con un qualsiasi disassembler come termina VmxInitialize. ; Dopo aver tolto anche questi 0x78 + 0x8 +0x8 byte RSP punta ad  ; istruzione successiva a call VmxInitialize in DriverCreate. mov rcx, [rsp + 88h -20h]
xor rcx, rsp        ; StackCookie
call __security_check_cookie
add rsp, 78h
pop rdi
pop rsi
ret AsmRestoreToVMXOFFState ENDP

Una cosa molto importante da sottolineare è che prologo ed epilogo di VmxInitialize possono variare da compilazione a compilazione, quindi è meglio verificare ogni volta con un disassemblatore ed adattare il codice di AsmRestoreToVMXOFFState di conseguenza.




Conclusioni e note finali

Dopo il ret di AsmRestoreToVMXOFFState il codice riprende dal KdPrint successivo alla chiamata a VmxInitialize in DriverEntry ed è per questo motivo che non si sono virtualizzati tutti i processori: tale codice può essere eseguito una sola volta.
Si noti che se il codice di questa lezione viene eseguito su una macchina virtuale gestita da Hyper-V non è garantito che la prima VM exit dopo il lancio del guest sia quella causata dall'esecuzione dell'HLT da parte di quest'ultimo. Per tale motivo si potrebbe essere costretti a fare più di un tentativo prima che l'esperimento vada a buon fine. Si vedrà come risolvere il problema nel corso delle prossime lezioni.




Repository del progetto: Corso-VT-x (github.com)




Riferimenti:

[4] Intel Software Developer's Manual Vol. 3C

Nessun commento:

Posta un commento