sabato 11 dicembre 2021

05.B - Convivere con Hyper-V

Per testare il nostro driver su una macchina virtuale gestita da Hyper-V è necessario prendere coscienza del fatto che l'hypervisor implementato in esso si mette in mezzo tra Hyper-V e Windows 10 in quanto verrà trattato come guest speciale a cui vengono passate tutte le VM exit. Questo non sarebbe un problema se, come già detto in una lezione precedente, Windows 10 non richiedesse supporto ad Hyper-V per svolgere alcuni dei suoi compiti in modo alternativo rispetto al solito. Se tutte le VM exit vengono passate al nostro hypervisor allora è necessario catturare quelle destinate a Hyper-v e ripassargliele per farle gestire a lui. La domanda è quindi: quali sono le VM exit destinate ad Hyper-V? Fortunatamente a facile rispondere a tale domanda in quanto esiste una documentazione specifica che indica quale dovrebbe essere l'interfaccia pubblica di un hypervisor conforme alle specifiche indicate da Microsoft. Il documento in questione è linkato a fine lezione nei riferimenti, si veda [5].



Microsoft Hypervisor Interface

La documentazione indica quali sono le funzionalità minime che un hypervisor conforme alle specifiche Microsoft debba offrire ad un sistema operativo che voglia interagire con esso (tipo Windows 10 che comunica con Hyper-V). In sostanza, tali funzionalità minime si riducono a:

- Verifica sulla presenza dell'hypervisor attraverso CPUID.EAX_1:ECX [bit 31] = 1. Nel senso che se il guest mette 1 in EAX ed esegue CPUID, può controllare il bit 31 di ECX e verificare se è impostato ad 1. Tale bit è riservato da Intel proprio a questo scopo: permettere ad un hypervisor di segnalare al guest la sua presenza.

- Informazioni aggiuntive sull'hypervisor attraverso CPUID.EAX_40000000-FF. Se il guest ha verificato la presenza dell'hypervisor può chiedergli ulteriori informazioni mettendo in EAX valori tra 0x40000000 e 0x400000FF ed eseguendo CPUID. L'hypervisor gli ritornerà tali informazioni secondo quanto descritto nella documentazione. A noi interessano solo i valori 0x40000000 e 0x40000001. Maggiori informazioni verranno date al momento della presentazione del codice relativo.

- Lettura e scrittura di MSR ad indirizzi normalmente non validi. In particolare, RDMSR.ECX_40000000-FF oppure WRMSR.ECX_40000000-FF. Il guest può richiedere o inviare informazioni all'hypervisor attraverso MSR riservati a questo scopo.

- Gestione di alcune VMCALL, chiamate HyperCall, che servono al guest per comunicare con l'hypervisor ed a richiedere l'esecuzione di alcune operazioni particolari. Se l'operazione è stata completata con successo al guest viene ritornano 0x0000 in RAX.



Implementazione

Alla luce di quanto appena visto è sufficiente assicurarsi che il gestore del nostro hypervisor gestisca correttamente tutte quelle VM exit destinate ad Hyper-V, oltre a quelle proprie.


BOOLEAN VmxRootVmExitHandler(PGUEST_REGS GuestRegs)
{
    ULONG ExitReason = 0;
    __vmx_vmread(VM_EXIT_REASON, &ExitReason);
 
    ULONG ExitQualification = 0;
    __vmx_vmread(VM_EXIT_QUALIFICATION, &ExitQualification);
 
    ULONG CurrentProcessorIndex = KeGetCurrentProcessorNumber();
 
    // Segnala che si è in VMX root operation e che, normalmente,
    // si dovrebbe procedere a rieseguire il guest a partire
    // dall'istruzione succesiva a quella che ha causato la
    // VM exit (altrimenti si entra in un loop infinito di
    // VM exit e VM entry).
    GuestState[CurrentProcessorIndex].IsOnVmxRootMode = TRUE;
    GuestState[CurrentProcessorIndex].IncrementRip = TRUE;
 
 
    switch (ExitReason)
    {

    	// ...
		
 
    	// Gestisce VM exit causate da RDMSR
    	case EXIT_REASON_MSR_READ:
    	{
    	    VmxRootExitHandleMsrRead(GuestRegs);
 
    	    break;
} // Gestisce VM exit causate da WRMSR case EXIT_REASON_MSR_WRITE: { VmxRootExitHandleMsrWrite(GuestRegs);
break;
} // Gestisce VM exit causate da CPUID case EXIT_REASON_CPUID: { VmxRootExitHandleCpuid(GuestRegs);
break;
} // Gestisce VM exit causate da VMCALL case EXIT_REASON_VMCALL: { // Controlla se è stata una delle nostre funzioni (quelle contenute nel // driver che implementa l'hypervisor e che vengono eseguite in non-root  // operation in quanto ora l'intero sistema è il guest) ad eseguire la  // VMCALL oppure se è stato qualcun'altro che intendeva rivolgersi ad Hyper-V. // Nota che, a prescindere dalla funzione invocata, il valore di ritorno // viene caricato nel registro RAX del guest, in modo che questo lo possa  // vedere una volta tornati in non-root operation. if (GuestRegs->r10 == 0x4e4f485950455256 && GuestRegs->r11 == 0x564d43414c4c) { // La VMCALL è roba nostra e la gestiamo nel nostro hypervisor // Salva il valore di ritorno di VmxRootVmcallHandler in RAX del guest: // in questo modo è possibile informare il codice guest sulla // corretta gestione o meno della VMCALL da parte dell'hypervisor. GuestRegs->rax = VmxRootVmcallHandler(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8, GuestRegs->r9); } else { // Rimandiamo la VMCALL a Hyper-V perché è roba sua. // Il valore di ritorno della HyperCall viene salvato in RAX del guest. GuestRegs->rax = AsmHypervVmcall(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8); } break; } // ... }


Non ci si soffermi troppo nel cercare di comprendere il blocco EXIT_REASON_VMCALL, che gestisce le VM exit causate dall'esecuzione di VMCALL. Verrà tutto spiegato in dettaglio a breve.



CPUID

Il nostro driver, eseguito in non-root operation, non usa CPUID per comunicare con il nostro hypervisor quindi si possono rimandare ad Hyper-V tutte le VM exit causate dall'esecuzione di tale istruzione.


VOID VmxRootExitHandleCpuid(PGUEST_REGS RegistersState)
{
    INT32 cpu_info[4];
 
    // Ripassa la palla ad Hyper-V eseguendo CPUIDEX in non-root operation (dal 
    // punto di vista del nostro hypervisor siamo in root operation perché Hyper-V 
    // ci passa tutte le VM exit ma in realtà siamo in non-root operation), che
    // causa una nuova VM exit che indica ad Hyper-V che non eravamo interessati
    // alla gestione della VM exit originaria, di non ripassarcela più e di
    // gestirsela per conto proprio.
    // Alla fine si ritorna qui in quanto, dato che siamo in non-root operation, dopo 
    // che Hyper-V ha gestito la VM exit ed eseguito VMRESUME, si riprende da questo 
    // punto come se il nostro hypervisor fosse un normalissimo guest.
    __cpuidex(cpu_info, (INT32)RegistersState->rax, (INT32)RegistersState->rcx);
 
 
    if (RegistersState->rax == 0x1)
    {
    	// Se il valore in RAX del guest è 1 significa che il guest è interessato
    	// a conoscere le funzionalità supportata dal processore.
    	// Il 32_esimo bit (bit alla posizione 31) di ECX normalmente ritorna sempre 0 
    	// ma Intel lo riserva come bit per segnalare la presenza di un hypervisor
    	// a del codice guest che è interessato a sapere tale informazione.
    	// Dato che siamo in effetti su un sistema virtualizzato e che il nostro
    	// hypervisor è quello a cui Hyper-V passa tutte le VM exit è bene
    	// impostare tale bit per confermare al guest la nostra presenza come hypervisor.
    	cpu_info[2] |= 0x80000000;
    }
    else if (RegistersState->rax == 0x40000000)
    {
    	// I valori nell'intervallo [0x40000000, 0x4FFFFFFF] non vengono normalmente
    	// accettati come input validi da mettere in EAX per eseguire CPUID.
    	// Intel però riserva i valori in [0x40000000, 0x400000FF] per fornire 
    	// ulteriori informazioni al guest sull'hypervisor, se presente.
    	// Il valore 0x40000000 indica che il guest è interessato a sapere
    	// chi è l'hypervisor (Vendor ID signature) e qual'è il valore massimo 
    	// che accetta per EAX (nell'intervallo [0x40000001, 0x400000FF])
    	// quando usato come operatore nell'esecuzione di CPUID.
    	cpu_info[0] = 0x40000001;
    	cpu_info[1] = 'sroC';  // "Corso VT-x"
    	cpu_info[2] = 'TV o';
    	cpu_info[3] = '\x00\x00x-';
    }
    else if (RegistersState->rax == 0x40000001)
    {
    	// Se il valore in RAX del guest è 0x40000001 significa che il guest 
    	// è interessato a sapere se l'hypervisor è conforme al Microsoft 
    	// Hypervisor Interface.
    	// In questo caso segnaliamo che il nostro hypervisor non è conforme
    	// (restituendo Hv#0 invece che Hv#1) perché siamo interessati solo
    	// alle richieste del guest quando queste vengono esclusivamente dal
    	// nostro driver eseguito in non-root operation e non da altre parti
    	// (in quel caso si ripassa semplicemente la palla ad Hyper-V).
    	cpu_info[0] = '0#vH';  // Hv#0
    	cpu_info[1] = cpu_info[2] = cpu_info[3] = 0;
    }
 
    // Copia i valori nella zona di memoria dove sono salvati i registri del guest.
    RegistersState->rax = cpu_info[0];
    RegistersState->rbx = cpu_info[1];
    RegistersState->rcx = cpu_info[2];
    RegistersState->rdx = cpu_info[3];
}



Lettura e scrittura di MSR

Allo stesso modo di CPUID, il nostro driver non usa RDMSR o WRMSR per comunicare con il nostro driver quindi si può passare tutto ad Hyper-V.


VOID VmxRootExitHandleMsrRead(PGUEST_REGS GuestRegs)
{
    MSR msr = { 0 };
 
    // L'istruzione RDMSR causa VM exit se almeno una delle seguenti condizioni è vera:
    // 
    // Il bit "use MSR bitmaps" del primary processor-based è 0.
    // Il valore di ECX è fuori dai range [0x00000000, 0x00001FFF] e [0xC0000000, 0xC0001FFF]
    // Il valore di ECX è nel range [0x00000000, 0x00001FFF] ed il bit n nella bitmap degli
    // MSR bassi è 1, dove n è il valore di ECX.
    // Il valore di ECX è nel range [0xC0000000, 0xC0001FFF] ed il bit n nella bitmap degli
    // MSR alti è 1, dove n è il valore di ECX & 0x00001FFF.
 
    // Controlla che ECX sia nei range validi per leggere un MSR oppure sia nel range
    // riservato [0x40000000, 0x400000F0], usato da Windows per chiedere o riportare
    // informazioni ad Hyper-V.
    if ((GuestRegs->rcx <= 0x00001FFF) || ((0xC0000000 <= GuestRegs->rcx) && (GuestRegs->rcx <= 0xC0001FFF))
    	|| (GuestRegs->rcx >= RESERVED_MSR_RANGE_LOW && (GuestRegs->rcx <= RESERVED_MSR_RANGE_HI)))
    {
    	// Esegue RDMSR in VMX non-root operation (dal punto di vista del nostro hypervisor
    	// siamo in root operation perché Hyper-V ci passa tutte le VM exit ma in realtà 
    	// siamo in non-root operation), che causa una nuova VM exit che indica ad Hyper-V 
    	// che non eravamo interessati alla gestione della VM exit originaria, di non 
    	// ripassarcela più e di gestirsela per conto proprio.
    	// Alla fine si ritorna qui in quanto, dato che siamo in non-root operation, dopo 
    	// che Hyper-V ha gestito la VM exit ed eseguito VMRESUME, si riprende da questo 
    	// punto come se il nostro hypervisor fosse un normalissimo guest.
    	msr.Uint64 = __readmsr(GuestRegs->rcx);
    }
 
    // Salva il risultato della lettura dell'MSR nella zona di memoria dove sono stati
    // salvati i registri del guest.
    // RDMSR prende da ECX l'indirizzo dell'MSR da leggere e mette il risultato in EAX:EDX.
    GuestRegs->rax = msr.Low;
    GuestRegs->rdx = msr.High;
}

VOID VmxRootExitHandleMsrWrite(PGUEST_REGS GuestRegs)
{
    MSR msr = { 0 };
 
    // L'istruzione WRMSR causa VM exit alle stesse condizioni viste in VmExitHandleMsrRead
    // per RDMSR.
 
    // Controlla che ECX sia nei range validi per leggere un MSR oppure sia nel range
    // riservato [0x40000000, 0x400000F0], usato da Windows per chiedere o riportare
    // informazioni ad Hyper-V.
    if ((GuestRegs->rcx <= 0x00001FFF) || ((0xC0000000 <= GuestRegs->rcx) && (GuestRegs->rcx <= 0xC0001FFF))
    	|| (GuestRegs->rcx >= RESERVED_MSR_RANGE_LOW && (GuestRegs->rcx <= RESERVED_MSR_RANGE_HI)))
    {
    	// WRMSR prende da ECX l'indirizzo dell'MSR e legge il valore da scrivere
    	// in tale registro da EAX:EDX.
    	// Si noti che RAX ed RDX sono quelli del guest.
    	msr.Low = (ULONG)GuestRegs->rax;
    	msr.High = (ULONG)GuestRegs->rdx;
 
    	// Esegue WRMSR in VMX non-root operation (dal punto di vista del nostro hypervisor
    	// siamo in root operation perché Hyper-V ci passa tutte le VM exit ma in realtà 
    	// siamo in non-root operation), che causa una nuova VM exit che indica ad Hyper-V 
    	// che non eravamo interessati alla gestione della VM exit originaria, di non 
    	// ripassarcela più e di gestirsela per conto proprio.
    	// Alla fine si ritorna qui in quanto, dato che siamo in non-root operation, dopo 
    	// che Hyper-V ha gestito la VM exit ed eseguito VMRESUME, si riprende da questo 
    	// punto come se il nostro hypervisor fosse un normalissimo guest.
    	__writemsr(GuestRegs->rcx, msr.Uint64);
    }
}



HyperCall

Per la gestione delle VM exit causate dall'esecuzione di VMCALL, la questione è un po' più delicata. Nel gestore delle VM exit si è visto il seguente codice.


// Gestisce VM exit causate da VMCALL
case EXIT_REASON_VMCALL:
{
    // Controlla se è stata una delle nostre funzioni (quelle contenute nel
    // driver che implementa l'hypervisor e che vengono eseguite in non-root 
    // operation in quanto ora l'intero sistema è il guest) ad eseguire la 
    // VMCALL oppure se è stato qualcun altro che intendeva rivolgersi ad Hyper-V.
    // Nota che, a prescindere dalla funzione invocata, il valore di ritorno
    // viene caricato nel registro RAX del guest, in modo che questo lo possa 
    // vedere una volta tornati in non-root operation.
    if (GuestRegs->r10 == 0x4e4f485950455256 && GuestRegs->r11 == 0x564d43414c4c)
    {
    	// La VMCALL è roba nostra e la gestiamo nel nostro hypervisor
    	// Salva il valore di ritorno di VmxRootVmcallHandler in RAX del guest:
    	// in questo modo è possibile informare il codice guest sulla
    	// corretta gestione o meno della VMCALL da parte dell'hypervisor.
    	GuestRegs->rax = VmxRootVmcallHandler(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8, GuestRegs->r9);
    }
    else
    {
    	// Rimandiamo la VMCALL a Hyper-V perché è roba sua.
    	// Il valore di ritorno della HyperCall viene salvato in RAX del guest.
    	GuestRegs->rax = AsmHypervVmcall(GuestRegs->rcx, GuestRegs->rdx, GuestRegs->r8);
    }

    break;
}


Ma cosa vuol dire? Al contrario di CPUID, RDMSR e WRMSR, le VMCALL vengono usate per comunicare con il nostro hypervisor ed è necessario distinguere tra quelle indirizzate a lui, da parte del nostro driver eseguito in non-root operation, e quelle indirizzate ad Hyper-V. L'idea è quella di usare i registri R10 ed R11 per marcare le VMCALL inviate al nostro driver e permettere quindi di effettuare una distinzione. Di seguito viene mostrato il codice di AsmVmxNonRootVmcall, funzione usata nella scorsa lezione per verificare che il nostro driver sia eseguito effettivamente in non-root operation (insieme all'intero sistema operativo) una volta lanciato il guest.


AsmVmxNonRootVmcall PROC
    
    ;NTSTATUS AsmVmxNonRootVmcall(ULONG64 VmcallNumber, ULONG64 OptParam1, ULONG64 OptParam2, LONG64 OptParam3);

    ; Imposta i valori esadecimali delle stringhe ASCII "NOHYPERV" e "VMCALL" nei 
    ; registri R10 ed R11 (che non sono coinvolti nella convenzione di chiamata 
    ; __fastcall) così si è sicuri che la VMCALL è indirizzata al nostro hypervisor 
    ; (e non a Hyper-V).
    ; Segnala al nostro hypervisor di gestire la relativa VM exit.
    pushfq
    push    r10
    push    r11
    mov     r10, 4e4f485950455256H   ; [NOHYPERV]
    mov     r11, 564d43414c4cH       ; [VMCALL]
 
    ; Causa VM exit che porterà ad invocazione di 
    ; VmxRootVmcallHandler(rcx = vmcallnumber, rdx = optparam1, r8 = optparam2, r9 = optparam3)
    ; all'interno del gestore delle VM exit 
    vmcall                           
    pop     r11
    pop     r10
    popfq
    ret        ; Ritorna NTSTATUS che si trova in RAX (valore di ritorno di VmxRootVmcallHandler)
 
AsmVmxNonRootVmcall ENDP


Dunque, in base ai valori nei registri R10 ed R11 si è in grado di stabilire se una VMCALL è indirizzata a noi (da parte del nostro driver in non-root operation) oppure ad Hyper-V. Questo risponde alla domanda posta nella precedente lezione sul perché fosse necessario scrivere AsmVmxNonRootVmcall in linguaggio assembly. Ad ogni modo, se la VMCALL è indirizzata al nostro hypervisor viene invocata VmxRootVmcallHandler, che esegue un certo tipo di operazione in base al valore del parametro VmcallNumber, che si trova in RCX.


NTSTATUS VmxRootVmcallHandler(UINT64 VmcallNumberUINT64 OptionalParam1UINT64 OptionalParam2UINT64 OptionalParam3)
{
    NTSTATUS VmcallStatus;
 
    VmcallStatus = STATUS_UNSUCCESSFUL;
 
    // I primi 32 bit sono sufficienti a distinguere tra (2^(32) - 1) VMCALL diverse.
    // Questo permette di usare, volendo, i restanti 32 bit alti per altro.
    switch (VmcallNumber & 0xffffffff)
    {
 
        case VMCALL_TEST:
        {
    	    VmcallStatus = VmxRootVmCallTest(OptionalParam1OptionalParam2OptionalParam3);
    	    break;
        }
 
        case VMCALL_VMXOFF:
        {
    	    VmxRootVmxoff();
    	    VmcallStatus = STATUS_SUCCESS;
    	    break;
        }
 
        default:
        {
    	    VmcallStatus = STATUS_UNSUCCESSFUL;
    	    break;
        }
 
    }
 
    return VmcallStatus;
}


Nella scorsa lezione si era passato VMCALL_TEST come primo parametro di AsmVmxNonrootVmcall, che permette di eseguire VmxRootVmcalltest al fine di stampare un messaggio sulla console di output del debugger.


NTSTATUS VmxRootVmCallTest(UINT64 Param1UINT64 Param2UINT64 Param3)
{
    KdPrint(("VmcallTest called with @Param1 = 0x%llx , @Param2 = 0x%llx , @Param3 = 0x%llx\n"Param1Param2Param3));
    return STATUS_SUCCESS;
}


L'invocazione di AsmVmxNonRootVmcall con argomento VMCALL_VMXOFF e la funzione VmxRootVmxoff verranno esaminate nella prossima lezione.
Per concludere la questione sulle VMCALL, se questa è indirizzata ad Hyper-V allora si esegue AsmHypervVmcall.


AsmHypervVmcall PROC
 
    ; Esegue VMCALL in VMX non-root operation (a seguito di una VMCALL destinata
    ; ad Hyper-V ma che Hyper-V non ha gestito in quanto viene passata prima
    ; al nostro driver per fargli credere di essere l'hypervisor reale in
    ; root operation). 
    ; Questo causa una nuova VM exit che indica ad Hyper-V che non eravamo
    ; interessati alla gestione della VM exit originaria e rimandiamo ad esso 
    ; ogni responsabilità.
    ; Una volta che Hyper-V ha gestito la VMCALL si ritorna qui, ad
    ; istruzione successiva a VMCALL: in questo caso RET.
    ; Le VMCALL indirizzate a Hyper-V si chiamano HyperCall e ritornano 0x0000 
    ; se completate con successo.
    vmcall
    ret       ; Ritorna HV_STATUS che si trova in RAX (valore di ritorno della HyperCall)
 
AsmHypervVmcall ENDP



Considerazioni finali

Quanto visto in questa lezione permette di eseguire e testare il driver che implementa il nostro hypervisor su una macchina virtuale gestita da Hyper-V. Così, finalmente, si può verificare se effettivamente Windows 10 viene lanciato come guest. Questo però non conclude l'argomento perché, oltre che ad entrarci, sarebbe auspicabile trovare un modo anche per uscire dalla VMX operation e riportare Windows 10 ad essere eseguito normalmente. Questo perché se il modulo del nostro driver viene scaricato dalla memoria si resterebbe in VMX operation ma senza più alcun hypervisor. Nella prossima lezione si discuterà proprio di questo aspetto.



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



Nessun commento:

Posta un commento