sabato 6 novembre 2021

04.C - VMCS: Guest-state area

La guest-state area contiene tutti quei campi che vengono caricati nello stato del processore al momento di una VM entry ed in cui viene salvato lo stato del processore al momento di una VM exit. Per tale motivo, è necessario inizializzare tali campi prima di lanciare il guest. Convenzionalmente la guest-state area si può dividere in due parti: quella relativa ai registri e quella non relativa ai registri. A prescindere da questo, tutti i campi devono essere inizializzati in qualche modo. L'immagine seguente mostra la guest-state area nella sua interezza.




Come nel caso della host-state area, si tralasceranno (cioè verranno impostati a zero) tutti quei campi che richiedono che il processore sia in grado di supportare l'impostazione ad 1 di un particolare bit che si trova al di fuori di quest'area. Stessa sorte verrà riservata per tutti quei campi ritenuti marginali o non essenziali per un corso di base sulla virtualizzazione delle CPU.




Area relativa ai registri

L'immagine seguente, presa dalla documentazione, mostra un elenco dei campi che si possono trovare nella guest-state area e che fanno riferimento ai registri.





Registri di controllo (CR0, CR3 e CR4)

Per inizializzare i campi della VMCS identificati dai valori codificati GUEST_CRX (e che verranno caricati nei registri di controllo CR0, CR3 e CR4 al momento di una VM-entry), si possono usare gli intrinsic __readcrX.

// Registri di controllo
__vmx_vmwrite(GUEST_CR0__readcr0());
__vmx_vmwrite(GUEST_CR3__readcr3());
__vmx_vmwrite(GUEST_CR4__readcr4());

Diversamente da quanto si era fatto per HOST_C3 (si veda lezione precedente) in questo caso è possibile usare l'intrinsic __readcr3 se l'intento è quello di usare come guest l'intero sistema operativo su cui è in esecuzione il nostro hypervisor. Nel contesto di questo corso tale condizione è soddisfatta in quanto il nostro obiettivo è proprio quello di usare Windows 10 come guest e metterlo sotto il controllo del nostro hypervisor. In pratica, è possibile fare in modo che il codice che lancia il guest in realtà lanci se stesso. Tale affermazione non è tecnicamente corretta ma rende l'idea. Maggiori informazioni verranno date in una prossima lezione. Al momento è importante capire che si può usare __readcr3 perché qualunque sia il thread che esegue il codice mostrato sopra (sia esso un thread di sistema o appartenente ad un processo user-mode, se si usano le DPC) alla fine lancerà come guest del codice eseguito da lui stesso e quindi, a parte qualche ovvia eccezione, molti registri non cambieranno.



Debug register (DR7)

Tale registro è usato per abilitare o meno i breakpoint in fase di debugging ed impostarne eventualmente le condizioni.

// Registro di debug
__vmx_vmwrite(GUEST_DR7__readdr(7));

Per impostare il relativo campo nella VMCS si può usare l'intrinsic __readdr passandogli un valore numerico che identifica a quale dei registri di debug si è interessati.



Registri general purpose (RSP e RIP) ed RFLAGS

Ogni volta che avviene una VM entry i registri RSP e RIP vengono caricati con gli indirizzi conservati nei campi della VMCS identificati dai valori codificati GUEST_RSP e GUEST_RIP. Ad ogni VM exit, invece, RSP e RIP vengono salvati in tali campi.

// Registri general purpose (RSP e RIP) ed RFLAGS
__vmx_vmwrite(GUEST_RSP, GuestRspVA);
__vmx_vmwrite(GUEST_RIP, GuestRipVA);
__vmx_vmwrite(GUEST_RFLAGS__readeflags());

In questo caso GuestRipVA è una variabile che conterrà l'indirizzo di memoria che contiene le istruzioni da cui vogliamo che il guest parta dopo la prima VM entry. Per quanto detto nella sezione sui registri di controllo, GuestRipVA punterà a del codice che permette al thread che sta eseguendo l'inizializzazione della VMCS di diventare un thread che esegue codice guest. Tutto verrà chiarito nel corso delle prossime lezioni. GuestRspVA è una variabile che punterà alla cima dello stack dopo la prima VM entry. Per RFLAGS vale lo stesso discorso fatto per i registri di controllo: è sufficiente assegnare al campo identificato da GUEST_RFLAGS il valore del registro RFLAGS al momento dell'esecuzione di tale codice.



Registri di segmento

Rispetto alla host-state area, la parte che riguarda la segmentazione assume un ruolo più marcato nel contesto del guest. Questo perché il guest, soprattutto se è un intero sistema operativo, potrebbe voler eseguire applicazioni a 32 bit (in compatibility mode) che fanno ampio uso dei segmenti. A tale scopo, la documentazione richiede che vengano salvate nella VMCS tutte le informazioni contenute nei registri di segmento, anche quelle non visibili. Questo complica un po' le cose ma, fortunatamente, è possibile estrarre tali informazioni dal segment descriptor a cui si riferisce il relativo selector, che si trova nella parte visibile del segment register. Inoltre, lo stesso codice può essere sfruttato per estrarre la parte non visibile di tutti i registri di segmento (si veda il metodo FillGuestSegmentData).

enum SEGREGS
{
    ES = 0,
    CS,
    SS,
    DS,
    FS,
    GS,
    LDTR,
    TR
};


// Registri di segmento
FillGuestSegmentData((PVOID)gdtr.base_address, ES__read_es());
FillGuestSegmentData((PVOID)gdtr.base_address, CS__read_cs());
FillGuestSegmentData((PVOID)gdtr.base_address, SS__read_ss());
FillGuestSegmentData((PVOID)gdtr.base_address, DS__read_ds());
FillGuestSegmentData((PVOID)gdtr.base_address, FS__read_fs());
FillGuestSegmentData((PVOID)gdtr.base_address, GS__read_gs());
FillGuestSegmentData((PVOID)gdtr.base_address, LDTR__read_ldtr());
FillGuestSegmentData((PVOID)gdtr.base_address, TR__read_tr());

La variabile gdtr è la stessa vista nella lezione precedente, dove la funzione di supporto __sgdtr salva base e limite del registro GDTR. Anche le funzioni __read_xx sono le stesse esaminate nella precedente lezione.

typedef union _SEGMENT_ATTRIBUTES {
    struct {
    	UINT16  Type : 4;
    	UINT16  Sbit : 1;
    	UINT16  Dpl : 2;
    	UINT16  Present : 1;
    	UINT16  Reserved_8 : 4;
    	UINT16  Avl : 1;
    	UINT16  Reserved_13 : 1;
    	UINT16  Db : 1;
    	UINT16  Granularity : 1;
    	UINT16  Unusable : 1;
    	UINT16  Reserved_17 : 15;
    } Bits;
    UINT32  Uint32;
} SEGMENT_ATTRIBUTES, * PSEGMENT_ATTRIBUTES;

 
typedef union _SEGMENT_DESCRIPTOR {
    struct {
    	UINT32  LimitLow : 16;    ///< Segment Limit 15..00
    	UINT32  BaseLow : 16;     ///< Base Address  15..00
    	UINT32  BaseMid : 8;      ///< Base Address  23..16
    	UINT32  Type : 4;         ///< Segment Type (1 0 B 1)
    	UINT32  S : 1;            ///< Descriptor Type
    	UINT32  DPL : 2;          ///< Descriptor Privilege Level
    	UINT32  P : 1;            ///< Segment Present
    	UINT32  LimitHigh : 4;    ///< Segment Limit 19..16
    	UINT32  AVL : 1;          ///< Available for use by system software
    	UINT32  L : 1;		  ///< 0 0
    	UINT32  Reserved_54 : 1;  ///< 0 0
    	UINT32  G : 1;            ///< Granularity
    	UINT32  BaseHigh : 8;     ///< Base Address  31..24
    	UINT32  BaseUpper : 32;   ///< Base Address  63..32
    	UINT32  Reserved_96 : 32; ///< Reserved
    } Bits;
    struct {
    	UINT64  Uint64;
    	UINT64  Uint64_1;
    } Uint128;
} SEGMENT_DESCRIPTOR, * PSEGMENT_DESCRIPTOR;


 
VOID FillGuestSegmentData(PVOID gdt_baseUINT32 segment_registerUINT16 selector)
{
    SEGMENT_ATTRIBUTES segment_access_rights;
    PSEGMENT_DESCRIPTOR segment_descriptor;
 
    // Se il bit 2 del selector è impostato ad 1 significa che tale selector 
    // si riferisce ad elementi all'interno della LDT (non ci interessa).
    if (selector & 0x4)
    	return;
 
    // L'indice del segment descriptor (all'interno della GDT) si trova in 
    // bit 3-15 del selector e viene moltiplicato per 8 (i byte dei descriptor nella GDT) 
    // per calcolare il byte offset del descriptor a partire dall'inizio della GDT.
    // Moltiplicare per 8 equivale a shiftare a sinistra di 3 posizioni
    // (cioè gli ultimi 3 bit saranno zero). Dato che negli ultimi 3 bit del
    // selector c'è roba che non ci interessa si possono azzerare ed è come
    // se avessimo moltiplicato per 8 (0x7 = 0111; ~0x7 = 1000).
    segment_descriptor = (PSEGMENT_DESCRIPTOR)((PCHAR)gdt_base + (selector & ~0x7));
 
    // Base del segmento si compone attraverso i bit 16-31 (low), 32-39 (mid) e 
    // 56-63 (high) del segment descriptor.
    UINT64 segment_base = segment_descriptor->Bits.BaseLow | 
    	segment_descriptor->Bits.BaseMid << 16 | 
    	segment_descriptor->Bits.BaseHigh << 24;
 
    // Limite del segmento si compone attraverso i bit 0-15 (low) e 
    // 48-51 (high) del segment descriptor.
    UINT32 segment_limit = segment_descriptor->Bits.LimitLow | (segment_descriptor->Bits.LimitHigh << 16);
 
    // __load_ar usa l'istruzione LAR per leggere e ritornare i bit 32-63
    // del segment descriptor a cui si riferisce il selector passato come argomento.
    // LAR serve a ritornare gli attributi di accesso di un segmento, ma i 
    // primi 8 bit (32-39) vengono azzerati perché contengono la BaseMid, che
    // non fa parte degli attributi, quindi è necessario shiftare.
    segment_access_rights.Uint32 = __load_ar(selector) >> 8;
    segment_access_rights.Bits.Unusable = 0;
    segment_access_rights.Bits.Reserved_17 = 0;
 
    // Se il bit relativo al flag S è impostato a 0 allora si tratta di descriptor 
    // di sistema. In sistemi a 64 bit tali descriptor sono di 16 byte e per 
    // comporre la base del segmento è necessario aggiungere anche i bit 95:64 
    // (i bit 96:127 sono riservati) come 32 bit alti di un indirizzo a 64 bit.
    // Dato che questo codice è eseguito in modalità a 64 bit non c'è pericolo
    // di incontrare un segment descriptor di sistema che sia di 8 byte.
    if (segment_descriptor->Bits.S == FALSE)
    	segment_base = (segment_base & 0xFFFFFFFF) | (UINT64)segment_descriptor->Bits.BaseUpper << 32;
 
    // Se il bit della granularità è impostato ad 1 il limite del segmento
    // non è più inteso in byte ma in unità di 4 KB.
    if (segment_descriptor->Bits.G == TRUE)
    	segment_limit = (segment_limit << 12) + 0xfff;
 
    // Se il selector è nullo si imposta ad 1 il bit Unusable degli attributi di accesso.
    if (selector == 0)
    	segment_access_rights.Uint32 |= 0x10000;
 
    // Al valore codificato GUEST_ES_SELECTOR seguono GUEST_CS_SELECTOR, GUEST_SS_SELECTOR, ecc.
    // e la distanza numerica tra loro è sempre di 2 unità. 
    // Stessa cosa per tutti gli altri valori codificati GUEST_ES_XX.
    // Quindi, per indicare tutti questi valori codificati è sufficiente usare un indice 
    // (in questo caso segment_register) da moltiplicare per 2 ed aggiungere il risultato 
    // a GUEST_ES_XX.
    __vmx_vmwrite(GUEST_ES_SELECTOR + segment_register * 2, selector);
    __vmx_vmwrite(GUEST_ES_LIMIT + segment_register * 2, segment_limit);
    __vmx_vmwrite(GUEST_ES_BASE + segment_register * 2, segment_base);
    __vmx_vmwrite(GUEST_ES_ACCESS_RIGHTS + segment_register * 2, segment_access_rights.Uint32);
}

Per prima cosa si escludono i segment descriptor che non sono nella GDT. Questo perché la LDT non è praticamente usata in nessuna modalità (compresa la compatibility mode). Allora ci si potrebbe chiedere perché si richiedono informazioni sul registro LDTR. Il fatto è che all'interno della GDT esiste un segment descriptor relativo al segmento che contiene la LDT e per questo è necessario recuperarne le informazioni.




Gli attributi di accesso di un segmento si possono trovare nella seconda DWORD del relativo segment descriptor, con l'aggiunta di un bit (unusable) che indica se il selector è nullo (cioè si riferisce implicitamente al primo segment descriptor della GDT).






Se, nonostante i commenti, il codice di FillGuestSegmentData dovesse risultare poco chiaro si faccia riferimento alla documentazione Intel.



Base e limite dei registri GDTR e IDTR

Aver scritto le funzioni di supporto __sgdtr e __sidtr rende questo compito relativamente banale.

// GDTR ed IDTR (base e limit)
__vmx_vmwrite(GUEST_GDTR_BASEgdtr.base_address);
__vmx_vmwrite(GUEST_GDTR_LIMITgdtr.limit);
__vmx_vmwrite(GUEST_IDTR_BASEidtr.base_address);
__vmx_vmwrite(GUEST_IDTR_LIMITidtr.limit);

In pratica sono gli stessi dati scritti nei campi della host-state area poiché si tratta di tabelle globali, che hanno una visibilità a livello di sistema e sono valide per tutti i thread/processi.



MSR

Gli MSR relativi a SYSENTER sono già stati affrontati nella lezione precedente ed il discorso non cambia per quanto riguarda la guest-state area.

// MSR
__vmx_vmwrite(GUEST_DEBUG_CONTROL__readmsr(MSR_IA32_DEBUGCTL));
__vmx_vmwrite(GUEST_SYSENTER_CS__readmsr(MSR_IA32_SYSENTER_CS));
__vmx_vmwrite(GUEST_SYSENTER_ESP__readmsr(MSR_IA32_SYSENTER_ESP));
__vmx_vmwrite(GUEST_SYSENTER_EIP__readmsr(MSR_IA32_SYSENTER_EIP));
__vmx_vmwrite(GUEST_FS_BASE__readmsr(MSR_IA32_FS_BASE));
__vmx_vmwrite(GUEST_GS_BASE__readmsr(MSR_IA32_GS_BASE));

L'MSR IA32_DEBUGCTL è usato per il branch tracing. Al momento non è fondamentale approfondire tale argomento. Per maggiori informazioni si veda il volume 3B dell'Intel SDM.
Interessante è vedere perché si debbano impostare le basi dei registri FS e GS dato che lo si era già fatto durante l'inizializzazione dei registri di segmento (si riveda l'omonimo paragrafo). Il fatto è che tali registri possono essere usati per scopi diversi da quello della segmentazione. In particolare, in IA-32e Mode (la modalità in cui avviene l'inizializzazione) il campo base del registro GS viene modificato ogni volta che c'è una transizione da user a kernel mode (e viceversa). Per tale motivo, invece che modificare il segment descriptor tutte le volte che cambia la base del segment register, nella parte non visibile, è preferibile lasciare il campo base nel segment descriptor a zero e mappare il campo base del segment register ad un paio di MSR, uno per l'user mode e l'altro per il kernel mode. Si veda [5] per maggiori dettagli (pag. 2-5 e 92-94).




Area non relativa ai registri

L'immagine seguente, presa dalla documentazione, mostra un elenco dei campi che si possono trovare nella guest-state area ma che non fanno riferimento ai registri.




Praticamente nessuno dei campi elencati nell'immagine precedente è utile ai fini di questo corso. Detto questo, la documentazione richiede che se non si è interessati ad usare una VMCS shadow è necessario impostare a (-1) il VMCS link pointer. Una VMCS shadow è una VMCS che viene usata in caso il guest cerchi di accedere ai campi della VMCS corrente ed attiva con una delle istruzioni __vmx_vmwrite o __vmx_vmread. Dato che l'intenzione è quella di lasciare il guest ignaro del fatto che stia girando su una CPU virtualizzata si inizializzerà tale campo proprio a (-1).

// VMCS Link Pointer
__vmx_vmwrite(GUEST_VMCS_LINK_POINTER, ~0ULL);

In questo modo, se il guest cercherà di accedere alla VMCS verrà generata una VM exit ed il controllo passerà all'hypervisor. Quello che l'hypervisor ritornerà al guest è argomento di una prossima lezione.




[4] Intel Software Developer's Manual Vol. 3C
[5] Windows Internals 7 ed. Part 2

Nessun commento:

Posta un commento