venerdì 1 ottobre 2021

01 - Concetti preliminari

L'argomento principale attorno al quale ruoterà questo corso è VT-x, la tecnologia sviluppata da Intel per consentire la virtualizzazione delle CPU. Grazie a questa tecnologia il normale software eseguito su una CPU virtuale risulterà, in molti aspetti, vincolato: nel senso che le istruzioni lecite che vorrebbe eseguire potrebbero essere preventivamente vagliate da un supervisore. Persino gli eventi generati durante la sua esecuzione (interrupt ed eccezioni) potrebbero essere controllati e, nel caso, fatti sparire nel nulla. In passato, leggendo semplicemente la documentazione Intel, non era semplice comprendere a pieno come funzionasse la virtualizzazione. Fortunatamente, nel corso degli anni, sono stati rilasciati codici sorgenti pubblici e tutorial sull'argomento che hanno aiutato tantissimo nella sua comprensione (si vedano i riferimenti a fine lezione). Leggere la documentazione Intel di questi tempi risulta quindi molto più semplice che in passato.
Non si discuterà invece della virtualizzazione delle periferiche di I/O (dischi e schede varie) in quanto per quest'ultima viene usata una tecnologia dedicata a parte (VT-d).




Virtualizzazione della CPU

In questo corso ci si concentrerà sulla virtualizzazione così come implementata nelle CPU Intel (anche se molti concetti generali restano validi anche nel contesto AMD).  Per virtualizzazione si intende la capacità, da parte della CPU, di subordinare le sue funzionalità senza che il codice normalmente eseguito se ne renda conto. A tale scopo la CPU deve operare in una modalità particolare, che in ambito Intel viene chiamata VMX operation (Virtual Machine eXtension operation). Quando la CPU entra in questa modalità è in grado di eseguire giusto una manciata di istruzioni in più del normale ma la vera novità è rappresentata dal fatto che i soggetti operanti diventano due: Host e Guest.

Il VMM (Virtual Machine Monitor), detto anche hypervisor, funge da host ed ha completa disponibilità e controllo su tutte le funzionalità offerte dalla CPU. Può esserci un solo host ed il suo compito è quello di permettere l'esecuzione del codice del guest in un ambiente controllato, senza che quest'ultimo se ne renda conto. Dal punto di vista del programmatore spesso si tratta di un semplice driver che va ad attivare la modalità operativa VMX di tutti i processori logici della CPU e, successivamente, ad indicare e controllare ciò che possono o non possono fare i guest. Il numero di processori logici si può calcolare moltiplicando il numero di core fisici sulla CPU per il numero di thread che è in grado di eseguire parallelamente ognuno di essi. Spesso si usa anche il termine virtual processor per intendere processore logico ma dato che nel contesto della virtualizzazione della CPU tale termine potrebbe essere confuso con una CPU in VMX operation si preferirà l'uso di processore logico.

Il Guest (o i guest dato che possono essere più di uno) è il soggetto che crede di eseguire il suo codice su una macchina reale avendone pieno controllo quando, in realtà, questo è subordinato all'host. In genere si tratta di intere macchine virtuali, con dischi e schede virtuali e su cui gira un particolare OS ma noi non siamo interessati alla virtualizzazione a 360° quanto piuttosto a quella relativa alle sole CPU. Per tale motivo, nel contesto di questo corso, per guest si intenderà esclusivamente il sistema operativo (nello specifico Windows 10) che gira sulla macchina virtuale o fisica su cui si andrà ad installare ed eseguire l'hypervisor. In questo modo l'hypervisor controllerà tutti i driver, i servizi e le applicazioni in esecuzione su tale sistema. Se ne deduce che l'hypervisor debba girare ad un livello di privilegi più basso rispetto al sistema operativo guest (e cioè CPL < 0).

In VMX operation è possibile eseguire codice in due modi: root operation e non-root operation. Solitamente l''hypervisor esegue il suo codice in root-operation mentre il codice guest lo esegue in non-root (ma, come si vedrà nelle prossime lezioni, spesso il driver che implementa l'hypervisor esegue anche codice in non-root operation). E' possibile passare da root operation a non-root operation. Tale transizione è nota con in termine di VM entry. Si fa invece riferimento alla transizione opposta, da non-root operation a root operation, con il termine VM exit.

La root operation non è molto diversa dalla normale modalità operativa che si ha quando si esegue il codice quando non si è in VMX operation. Le uniche due differenze sono che, in VMX operation, si possono eseguire una manciata di istruzioni in più e che è la scrittura su alcuni registri di controllo ha delle limitazioni (tale argomento verrà ripreso alla fine di questa stessa lezione).

La non-root operation è una modalità di esecuzione molto limitata in cui alcune istruzioni o generazione di eventi possono causare una VM exit, che passa il controllo all'hypervisor (eseguito in root operation con pieni poteri). A quel punto la decisione finale sulla effettiva esecuzione dell'istruzione o gestione dell'evento è nelle mani dell'hypervisor che, prima di ripassare il controllo al guest con una VM entry, può alterare l'output dell'istruzione come pure impedire od ignorare del tutto la gestione dell'evento.




Host e Guest in VMX operation

L'immagine seguente mostra l'interazione ed il ciclo di vita dell'hypervisor e dei relativi guest.




- Si entra in VMX operation tramite l'istruzione VMXON. 
Per eseguire tale istruzione è necessarie, come minimo, che il CPL sia 0. Per tale motivo spesso viene eseguita dal driver che implementa l'hypervisor.

- A quel punto l'hypervisor può passare il controllo ad un guest (transizione VM entry) tramite le istruzioni VMLAUNCH.

- All'esecuzione, da parte del guest, di una particolare istruzione o alla generazione di uno specifico evento (entrambi indicati dallo stesso hypervisor) avviene una transizione VM exit ed il controllo ripassa all'hypervisor, che può svolgere le azioni che ritiene più opportune in base ciò che ha causato la VM exit. Fatto questo può restituire la palla al guest con l'istruzione VMRESUME.

- L'alternanza tra transizioni VM entry e VM exit continua finché l'hypervisor decide di eseguire l'istruzione VMOFF per uscire dalla VMX operation. A quel punto se il codice dei guest continuerà ad essere eseguito non sarà più soggetto alle limitazioni imposte dalla VMX operation.



Tipi di hypervisor

Esistono due tipi di hypervisor.

Gli hypervisor di tipo 1 (detti anche bare-metal hypervisor) vengono eseguiti direttamente sull'hardware. Per tale motivo offrono performance migliori ma sono più difficili da sviluppare. Esempi di tali hypervisor sono VMWare ESXi Server, Xen Server e lo stesso Hyper-V.

Gli hypervisor di tipo 2 hanno bisogno di un sistema operativo su cui essere eseguiti. Per tale motivo le prestazioni ne risentono perché l'OS ospitante deve gestire anche le normali applicazioni e tutto il resto (non può dedicarsi esclusivamente all'esecuzione dell'hypervisor). L'aspetto positivo è che possono essere sviluppati facilmente come normali kernel driver.




In questo corso si creerà un hypervisor di tipo 2.




Hyper-V e Windows

Hyper-V è un hypervisor di tipo 1 perfettamente integrato con Windows 10, che è consapevole della sua esistenza. Se Windows 10 riesce a rilevare di star girando in un ambiente virtualizzato deduce che dietro di lui c'è Hyper-V come hypervisor e gli fa delle richieste (provocando intenzionalmente delle VM exit) per svolgere alcuni compiti in modo differente dal solito. Questo rappresenta un piccolo ostacolo al nostro desiderio di voler testare il nostro hypervisor su una macchina virtuale creata e gestita da Hyper-V. Fortunatamente è possibile superare tale problema prendendo le dovute precauzioni. Quella che segue è una breve digressione che aiuterà a comprendere e spiegare meglio quanto appena detto.
Quando si usa Hyper-V per creare e gestire macchina virtuale su cui si esegue un Guest OS, la configurazione che viene a delinearsi è quella mostrata nell'immagine seguente




Hyper-V è l'hypervisor che gira, al livello 0 (L0) dell'immagine sopra, sull'hardware layer e Windows 10 si trova a L1. 

Nota: in questo contesto il termine livello non aderisce perfettamente a quello di livello di privilegi corrente (CPL) ma è comunque un modo per distinguere tra componenti che hanno privilegi diversi.

Le funzionalità di virtualizzazione della CPU vengono esposte a L0 in modo che Hyper-V sia in grado di concretizzare e mettere in pratica la virtualizzazione della CPU (vCPU) creando una macchina virtuale su cui gira un Guest OS a L1 . A tale livello, almeno di default, non vengono esposte le funzionalità di virtualizzazione della CPU e se si installasse Windows 10 come Guest OS questo non avrebbe sentore del fatto di star girando in un ambiente virtualizzato, quindi senza capacità di dedurre se anche Hyper-V sia presente a L1, cosa che permetterebbe di virtualizzare un sistema già virtualizzato. Se si riflette un attimo si potrà comprendere che quello appena descritto come non replicabile è esattamente lo scenario che ci si aspetterebbe se si volesse installare e testare un hypervisor sul Guest OS a L1, dato che si vorrebbe virtualizzare proprio tale OS già virtualizzato. Fortunatamente in Hyper-V è possibile attivare la virtualizzazione nidificata. In tal caso le funzionalità di virtualizzazione della CPU vengono esposte anche a L1 dove, a quel punto, è possibile installare Windows 10 come Guest OS con Hyper-V attivo e funzionante, permettendo la virtualizzazione di un sistema già virtualizzato. A quel punto Windows 10 a L1 è perfettamente consapevole di star girando su un ambiente virtuale e di poter invocare il supporto di Hyper-V.




Questo è un primo passo verso la soluzione al problema di come testare un hypervisor su una copia di Windows 10 che gira sul Guest OS di una VM creata e gestita da Hyper-V. Mancano ancora un paio di tasselli. L'immagine precedente mostra che, con la nidificazione attiva, è Hyper-V l'hypervisor a livello L1 avviato sul Guest OS mentre noi vogliamo che tale compito spetti al nostro driver. Fortunatamente Hyper-V offre la possibilità di indicare un hypervisor diverso, anche se forse in un modo che potrebbe sembrare del tutto inaspettato. In sintesi, Hyper-V resta a tutti gli effetti l'hypervisor a L1 ed il nostro hypervisor è trattato come componente speciale nel codice del Guest OS. Nel senso che il nostro hypervisor girerà sempre in non-root operation ma Hyper-V lo tratterà con riguardo, passandogli tutte le VM exit che riceve e prendendo in consegna tutte le VM entry del nostro hypervisor, rendendolo di fatto molto simile ad un hypervisor reale. Fantastico! Il problema è che non tutto il codice guest è inconsapevole di girare in un sistema virtualizzato. Come si è detto in precedenza, se Windows 10 si accorge di girare in un ambiente virtuale cercherà di provocare volutamente delle VM exit per chiedere il supporto di Hyper-V per svolgere alcune funzioni. Dato che ci troviamo proprio in questo scenario il nostro hypervisor dovrà aspettarsi anche la segnalazione di tali VM exit da Hyper-V e rispondere in modo adeguato. Hyper-V ci passa tutte le VM exit ma si aspetta che le gestiamo correttamente o, almeno, che lo informiamo del fatto che non siamo interessati a gestirle lasciando a lui tale compito. Se una VM exit provocata da Windows 10 non viene gestita per niente dal nostro hypervisor può portare a gravi conseguenze (si legga BSOD). 
Per il momento questo è tutto quello che serve sapere sul rapporto tra Window e Hyper-V e sul testing di hypervisor in ambienti virtualizzati tramite Hyper-V. 




Virtual Machine Control Structure

La Virtual Machine Control Structure (VMCS) è usata per controllare e governare la non-root operation e le VM exit. A questa struttura verrà dedicata una intera lezione ma, per il momento, è sufficiente sapere che una VMCS conserva lo stato del processore logico nel momento in cui avviene una qualsiasi transizione ed in più indica ciò che è consentito al guest e ciò che invece non lo è (in quest'ultimo caso la palla passa all'hypervisor). Ad esempio, se viene indicato che una istruzione non dovrebbe essere eseguita allora la sua esecuzione, da parte del guest, provocherà una VM exit che darà il controllo all'hypervisor. Quest'ultimo provvederà ad effettuare le opportune modifiche prima di restituire il risultato al guest, che penserà di aver eseguito l'istruzione come al solito. Tutto questo, però, non prima di aver salvato lo stato del processore al momento dell'interruzione del guest ed aver caricato quello richiesto per l'esecuzione del codice dell'hypervisor. Il punto esatto dove l'hypervisor riceverà il controllo al momento della VM exit ed il valore dei registri al momento dell'invocazione di tale entry point fa parte dello stato caricato al momento della transizione da non-root a root operation. Alla successiva VM entry viene caricato lo stato del processore salvato in precedenza (al momento della VM exit) per riprendere l'esecuzione del codice guest che si era interrotto.

In pratica, una VMCS è composta da sei gruppi di campi:

Guest state area: campi in cui lo stato di un processore logico viene salvato al momento di una VM exit e caricato al momento di una VM entry.

Host state area: campi in cui lo stato di un processore è caricato al momento di una VM exit. Non è necessario salvarlo al momento di una VM entry perché, se ci sono state delle modifiche a questi campi, le ha già effettuate tutte l'hypervisor durante la sua esecuzione in root operation (prima di ridare il controllo al guest con una VM entry). In altre parole, il compito di salvarle è affidato completamente all'hypervisor.

VM-execution control fields: campi che controllano e governano la non-root operation. Attraverso tali campi l'hypervisor può indicare quali sono le istruzioni eseguite e gli eventi generati dal guest che provocano una VM exit, e che quindi devono cedere il controllo all'hypervisor per la gestione di tali istruzioni o eventi.  

VM-exit control fields: campi che indicano i comportamenti ed il contesto esecutivo durante le VM exit. 

VM-entry control fields: campi che indicano i comportamenti ed il contesto esecutivo durante le VM entry. 

VM-exit information fields: campi che ricevono una descrizione sulle cause che hanno portato ad una VM exit.

L'immagine seguente (presa da [7]) mostra visivamente cinque dei sei gruppi di campi. Al momento una figura del genere avrà certamente poco senso ma, più avanti, potrebbe tornare utile averla a portata di mano, soprattutto quando si ha necessità di andare a vedere dove si trova un campo specifico o cosa contiene un intero gruppo senza andarsi per forza a sfogliare la documentazione.




Per ogni guest è necessario avere un'istanza diversa della VMCS, copiata su tutti i processori logici della CPU. In questo modo il codice guest può essere eseguito indistintamente su tutti i core virtuali. Questi ultimi, quando sono in VMX operation, usano la VMCS del guest correntemente in esecuzione per gestire le eventuali transizioni tra non-root e root operation (e viceversa). Ogni processore logico ha una componente, nel suo stato, dedicata alla conservazione dell'indirizzo di una VMCS. Ci si riferisce a tale componente con il termine di VMCS pointer. Se ne deduce che ogni processore può lavorare con una sola VMCS (e quindi con un solo guest) per volta. Il VMCS pointer può essere sia letto che scritto (in genere dall'hypervisor) dalle istruzioni VMPTRST e VMPTRLD, rispettivamente. Infine, una struttura VMCS  può essere configurata (nel senso di leggerne o scriverne i campi) dall'hypervisor tramite le istruzioni VMREAD, VMWRITE e WMCLEAR.
Se si ha la sensazione che non tutto quello che si è spiegato in questa sezione non sia stato propriamente chiarissimo non si disperi. Le cose miglioreranno quando si andrà ad esaminare la struttura VMCS in dettaglio (cosa che avverrà in una prossima lezione).




Verificare il supporto ed abilitare la VMX operation

Prima di eseguire il codice dell' hypervisor è necessario verificare che la CPU supporti la virtualizzazione ed è quindi in grado di entrare in VMX operation. Se si è riusciti ad abilitare la virtualizzazione da BIOS e si è installato Hyper-V non dovrebbero esserci problemi da questo punto di vista ma un check di questo genere non è tanto utile per lo sviluppatore quanto per l'utente finale, che può voler caricare ed eseguire il nostro driver/hypervisor sulla sua macchina fisica.
La documentazione Intel indica che è possibile fare tale verifica tramite l'istruzione CPUID.






Innanzitutto, una semplice verifica che è possibile fare anche in user-mode, anche se non strettamente collegata al supporto della virtualizzazione, è quella di verificare semplicemente che ci si trovi effettivamente su una CPU Intel. Impostando a 0 il valore del registro EAX ed eseguendo CPUID, i valori nei registri EBX, EDX ed ECX (in questo ordine) dovrebbero formare la stringa "GenuineIntel".

BOOL CpuIsIntel()
{
    CHAR CPUString[0x20];
    INT CPUInfo[4] = { -1 };
    UINT    nIds;
 
    // Usa l'intrinsic __cpuid dove valore da assegnare ad eax è passato come
    // secondo parametro mentre valori restituiti nei registri da CPUID vengono
    // salvati nel secondo parametro, che quindi è di output.
    /*
    	void __cpuid(
    	int cpuInfo[4],
    	int function_id
    	);
    */
 
    // In EAX viene restituito il massimo valore che CPUID riconosce e per cui
    // restituisce informazioni di base sulla CPU. Non ci interessa! 
    __cpuid(CPUInfo, 0);
    nIds = CPUInfo[0];
 
    // Riempie di 0 così carattere nullo terminatore già piazzato alla fine, qualunque essa sia.
    memset(CPUString, 0, sizeof(CPUString));
 
    // Se si monitora il valore degli elementi di CPUInfo in fase di debug si ottiene:
    // CPUInfo[1] = 1970169159 = 0x756E6547 = 'uneG' 
    // (0x75 = 'u'; 0x6E = 'n'; 0x65 = 'e'; 0x47 = 'G')
    // (0x47 = 'G' byte meno significativo; si ricordi che Intel usa ordine little-endian)
    // CPUInfo[2] = 1818588270 = 0x6C65746E = 'letn'
    // CPUInfo[3] = 1231384169 = 0x49656E69 = 'Ieni'
    *((PINT)CPUString) = CPUInfo[1];                           // EBX
    *((PINT)(CPUString + 4)) = CPUInfo[3];                     // EDX
    *((PINT)(CPUString + 8)) = CPUInfo[2];                     // ECX
 
    if (_stricmp(CPUString"GenuineIntel") == 0) {
    	return TRUE;
    }
 
    return FALSE;
}

Impostando invece ad 1 il valore di EAX, prima di eseguire CPUID, è possibile farsi ritornare le informazioni sulle funzionalità offerte dalla CPU in ECX e, in particolare, il quinto bit di tale registro indica se la CPU è in grado di entrare in VMX operation.

BOOLEAN CpuIsVMXSupported()
{
    CPUID_EAX_01 data = { 0 };
 
    // Check VMX bit
    __cpuid((int*)&data, 1);
 
    if (!_bittest(&data.ecx, 5))   //if ((data.ecx & (1 << 5)) == 0)
    	return FALSE;
 
    return TRUE;
}

Anche questa verifica può essere fatta in user-mode ma, dato che c'è ancora un altro check da fare, collegato al supporto alla VMX operation, e che è possibile farlo solo in kernel-mode, risulta più logico accorparlo nella funzione CpuIsVMXSupported, estendendola ed invocandola nel codice del driver che implementerà il nostro hypervisor. La definizione della struttura CPUID_EAX_01 verrà mostrata in quell'occasione. Non resta che vedere qual è questa ulteriore verifica richiesta. Per entrare in VMX operation si usa l'istruzione VMXON e l'esito della sua esecuzione può essere controllata da uno dei registri specifici del processore (MSR, model-specific register). Il registro in questione è IA32_FEATURE_CONTROL, MSR a cui si può accedere all'indirizzo 0x3A.




Il primo paragrafo dell'immagine precedente riguarda l'abilitazione della VMX operation e verrà trattata più avanti in questa stessa sezione. Proseguendo, la documentazione indica che il bit 0 di IA32_FEATURE_CONTROL deve essere 1, altrimenti verrà generata una eccezione se si cerca di eseguire VMXON. Allo stesso tempo, questo dà la possibilità al BIOS di disattivare la virtualizzazione impostando semplicemente tale bit a 0. Quando invece vuole abilitarla deve impostarlo a 1, insieme ad uno o entrambi i bit 1 e 2. SMX è un'altra modalità operativa della CPU a cui però non siamo interessati perché normalmente la CPU lavora al di fuori di tale modalità.
Alla luce di quanto appena scritto, è sufficiente controllare che il bit 2 sia impostato perché, se nel BIOS è stata attivata la virtualizzazione e non si sia in SMX operation (che è la condizione desiderata), allora verrà impostato proprio questo bit (oltre che il bit 0).

#define MSR_IA32_FEATURE_CONTROL        0x0000003A


// Registri utili in istruzione CPUID quando EAX == 0 o EAX == 1
typedef struct _CPUID_EAX_01
{
    INT eax;
    INT ebx;
    INT ecx;
    INT edx;
} CPUID_EAX_01, * PCPUID_EAX_01;


// IA32_FEATURE_CONTROL
// MSR di controllo sulle funzionalità offerte dalla CPU
typedef union {
    struct {
        UINT64  Lock : 1;
        UINT64  EnableVmxInsideSmx : 1;
        UINT64  EnableVmxOutsideSmx : 1;
        UINT64  Reserved1 : 5;
        UINT64  SenterLocalFunctionEnables : 7;
        UINT64  SenterGlobalEnable : 1;
        UINT64  Reserved2 : 1;
        UINT64  SgxLaunchControlEnable : 1;
        UINT64  SgxEnable : 1;
        UINT64  Reserved3 : 1;
        UINT64  LmceOn : 1;
        UINT64  Reserved4 : 11;
        UINT64  Reserved5 : 32;
    } Bits;
    UINT64  Uint64;
} IA32_FEATURE_CONTROL_MSR, * PIA32_FEATURE_CONTROL_MSR;

BOOLEAN CpuIsVMXSupported()
{
    CPUID_EAX_01 data = { 0 };
 
    // Check VMX bit
    // La struct CPUID_EAX_01 è definita con 4 campi interi contigui 
    // quindi può essere trattata come fosse un array.
    __cpuid((PINT32)&data, 1);
 
    if (!_bittest(&data.ecx, 5))   //if ((data.ecx & (1 << 5)) == 0)
    	return FALSE;
 
    // Usa intrinsic __readmsr, che prende indirizzo del MSR
    IA32_FEATURE_CONTROL_MSR Control = { 0 };
    Control.Uint64 = __readmsr(MSR_IA32_FEATURE_CONTROL);
 
    // Controlla bit 2
    if (Control.Bits.EnableVmxOutsideSmx == FALSE)
    {
    	DbgPrint("Please enable Virtualization from BIOS");
    	return FALSE;
    }
 
    return TRUE;
}

Come si può notare, lavorare con tipi definiti dall''utente (in questo caso strutture, unioni e campi di bit), è molto più comodo, intelligibile e riusabile che andare ogni volta a manipolare i bit in un singolo ed anonimo valore a 64 bit (ULONG64). Meglio fare un piccolo sforzo all'inizio e ricavarne tutti i benefici in seguito. Tutto quello che è necessario è sapere come si lavora con i campi di bit (si veda [6]). Per la definizione delle strutture, un'opzione è quella di fare riferimento alla documentazione e costruirsele da soli. Se invece si vuole semplicemente qualcosa di già pronto, l'opzione migliore è quella di dare uno sguardo e cercare nel codice sorgente di EDK II (si veda [4]). Nel caso di IA32_FEATURE_CONTROL il volume 4 riporta la seguente tabella




da cui è possibile derivare una definizione simile a quella vista nel codice precedente, che in verità è stata copiata dal repository di EDK II.

Non resta che abilitare la possibilità di entrare in VMX operation. Se necessario si legga il primo paragrafo che era stato escluso dall'immagine mostrata in precedenza prima di proseguire con la lettura. La documentazione afferma che è necessario impostare ad 1 il bit 13 del registro di controllo CR4. In realtà non è strettamente necessario farlo in maniera isolata perché, come vedremo nella prossima sezione, prima di entrare in VMX operation è necessario fixare i bit dei registri CR0 e CR4 a valori prestabiliti che non possono essere modificati per tutto il tempo in cui si rimane in tale modalità. Tra i bit da fixare c'è anche il bit 13 di CR4, che può quindi essere modificato insieme a tutti gli altri. Ciononostante, di seguito viene comunque fornito il codice per impostare tale bit in modo isolato.

/// Byte packed structure for Control Register 4 (CR4).
typedef union {
    struct {
        UINT32  VME : 1;          ///< Virtual-8086 Mode Extensions.
        UINT32  PVI : 1;          ///< Protected-Mode Virtual Interrupts.
        UINT32  TSD : 1;          ///< Time Stamp Disable.
        UINT32  DE : 1;           ///< Debugging Extensions.
        UINT32  PSE : 1;          ///< Page Size Extensions.
        UINT32  PAE : 1;          ///< Physical Address Extension.
        UINT32  MCE : 1;          ///< Machine Check Enable.
        UINT32  PGE : 1;          ///< Page Global Enable.
        UINT32  PCE : 1;          ///< Performance Monitoring Counter Enable.
        UINT32  OSFXSR : 1;       ///< Operating System Support for FXSAVE and FXRSTOR instructions
        UINT32  OSXMMEXCPT : 1;   ///< Operating System Support for Unmasked SIMD Floating Point Exceptions.
        UINT32  UMIP : 1;         ///< User-Mode Instruction Prevention.
        UINT32  LA57 : 1;         ///< Linear Address 57bit.
        UINT32  VMXE : 1;         ///< VMX Enable.
        UINT32  SMXE : 1;         ///< SMX Enable.
        UINT32  Reserved_3 : 1;   ///< Reserved.
        UINT32  FSGSBASE : 1;     ///< FSGSBASE Enable.
        UINT32  PCIDE : 1;        ///< PCID Enable.
        UINT32  OSXSAVE : 1;      ///< XSAVE and Processor Extended States Enable.
        UINT32  Reserved_4 : 1;   ///< Reserved.
        UINT32  SMEP : 1;         ///< SMEP Enable.
        UINT32  SMAP : 1;         ///< SMAP Enable.
        UINT32  PKE : 1;          ///< Protection-Key Enable.
        UINT32  Reserved_5 : 9;   ///< Reserved.
    } Bits;
    UINT64     Uint64;
} CR4, *PCR4;

// Abilita il bit 13 nel registro CR4, che la cui descrizione è
// "Virtual Machine Extensions Enable"
VOID VmxEnable()
{
    CR4 Register;
 
    // Recupera registro CR4
    Register.Uint64 = __readcr4();
 
    // Cambia ad 1 il bit 13 (VMX Enable)
    Register.Bits.VMXE = 1;
 
    // Scrive il nuovo valore nel registro CR4
    __writecr4(Register.Uint64);
}

Anche per la definizione della struttura che rappresenta il registro di controllo CR4 si può consultare la documentazione



o cercare nel repository di EDK II, come è stato fatto in questo caso.




Limitazioni della VMX operation

Eseguiti tutti i check ed abilitata la possibilità di entrare in VMX operation non resta che vedere cosa non è permesso fare mentre si è in questa modalità operativa. Nel contesto di questa sezione, con limitazione, non si intende quella del guest nell'eseguire operazioni vincolate al controllo dell'hypervisor quanto, piuttosto, a quella che descrive cosa non si può fare in generale dopo che la CPU entra in VMX operation.




La documentazione afferma che, prima di eseguire VMXON per entrare in VMX operation, è necessario fissare alcuni bit nei registri di controllo CR0 e CR4 a specifici valori. Una volta terminata tale modifica ed essere entrati in VMX operation non è più possibile toccare tali bit modifica fino all'uscita da tale modalità. Per conoscere quali sono questi bit ed i relativi valori da assegnare si possono consultare degli MSR specifici.




La sintesi di quanto affermato dalla documentazione è: tutti i bit 1 in IA32_VMX_CRX_FIXED0 devono essere 1 anche in CRX mentre tutti i bit 0 in IA32_VMX_CRX_FIXED1 devono essere 0 anche in CRX. Il codice per eseguire il fix dei bit in CR0 e CR4 è il seguente

#define MSR_IA32_VMX_CR0_FIXED0          0x00000486
#define MSR_IA32_VMX_CR0_FIXED1          0x00000487
#define MSR_IA32_VMX_CR4_FIXED0          0x00000488
#define MSR_IA32_VMX_CR4_FIXED1          0x00000489


typedef union {
    struct {
        UINT32  PE : 1;           ///< Protection Enable.
        UINT32  MP : 1;           ///< Monitor Coprocessor.
        UINT32  EM : 1;           ///< Emulation.
        UINT32  TS : 1;           ///< Task Switched.
        UINT32  ET : 1;           ///< Extension Type.
        UINT32  NE : 1;           ///< Numeric Error.
        UINT32  Reserved_0 : 10;  ///< Reserved.
        UINT32  WP : 1;           ///< Write Protect.
        UINT32  Reserved_1 : 1;   ///< Reserved.
        UINT32  AM : 1;           ///< Alignment Mask.
        UINT32  Reserved_2 : 10;  ///< Reserved.
        UINT32  NW : 1;           ///< Mot Write-through.
        UINT32  CD : 1;           ///< Cache Disable.
        UINT32  PG : 1;           ///< Paging.
    } Bits;
    UINT64     Uint64;
} CR0, *PCR0;

VOID VmxFixBits()
{
    CR4 Cr4 = { 0 };
    CR0 Cr0 = { 0 };
 
    // Fix Cr0
    Cr0.Uint64 = __readcr0();
    Cr0.Uint64 |= __readmsr(MSR_IA32_VMX_CR0_FIXED0);
    Cr0.Uint64 &= __readmsr(MSR_IA32_VMX_CR0_FIXED1);
    __writecr0(Cr0.Uint64);
 
    // Fix Cr4
    Cr4.Uint64 = __readcr4();
    Cr4.Uint64 |= __readmsr(MSR_IA32_VMX_CR4_FIXED0);
    Cr4.Uint64 &= __readmsr(MSR_IA32_VMX_CR4_FIXED1);
    __writecr4(Cr4.Uint64);
}

Infine, non resta che dare un'occhiata alle note sulle restrizioni nella VMX operation (incluse nell'immagine mostrata all'inizio di questa sezione). La documentazione afferma che alcuni processori, in particolare i più datati, richiedono che i bit CR0.PE e CR0.PG siano sempre impostati ad 1. Questo significa che devono essere attive sia la modalità protetta sia il paging. Dovendo creare un hypervisor che gira su Windows 10 al fine di virtualizzare tale sistema la cosa non ci crea problemi poiché la modalità protetta ed il paging sono attivi di default in Windows. I valori restituiti dagli MSR indicano quindi che tali bit devono essere impostati ad 1 e non più toccati. Stessa sorte tocca ai bit CR0.NE e CR4.VMXE. Ecco spiegato perché non è necessario impostare ad 1 CR4.VMXE in maniera isolata. Si può avere conferma di ciò provando a debuggare VmxFixBits e vedendo quali valori restituisce IA32_VMX_VR4_FIXED0. Nel mio caso:

_readmsr(MSR_IA32_VMX_CR4_FIXED0)  =  0x2000  =  0010 0000 0000 0000

L'unico impostato ad uno è il bit 13 (CR4.VMXE), che quindi deve essere 1 in anche CR4.




Riferimenti:

Nessun commento:

Posta un commento