mercoledì 3 novembre 2021

04.B - VMCS: Host-state area

La host-state area contiene tutti quei campi che vengono caricati nello stato del processore al momento di una VM exit poiché sono necessari all'esecuzione dell'hypervisor. Per tale motivo, è necessario inizializzare tali campi prima di lanciare il guest. L'immagine seguente, presa dalla documentazione, mostra un elenco dei campi che si possono trovare nella host-state area. Tutti devono essere inizializzati in qualche modo.




Come si può notare dall'immagine sotto, tale elenco corrisponde perfettamente all'immagine vista in una precedente lezione, che mostrava la host-state area dal punto di vista visivo.




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 (spesso nella VM exit control). In questo modo si ha la possibilità di eseguire la virtualizzazione su una fetta più ampia di processori. Nella lezione precedente si è visto che nell'allocare lo spazio per la VMCS si è azzerata la relativa memoria. In questo modo si è sicuri che tutti i campi saranno almeno inizializzati a zero. Inoltre, dato che l'hypervisor che si sta sviluppando è di tipo 2, per inizializzare la maggior parte dei campi nella host-state area è possibile prendere i valori corrispondenti dallo stato del processore quando è in esecuzione il processo System, all'interno del quale è caricato il modulo del driver che implementa il nostro hypervisor. In questo modo le cose si semplificano (come si vedrà a breve). Infine, si ricordi che devono essere inizializzate tutte le copie (relative ad uno stesso guest) caricate sui processori logici.




Registri di controllo (CR0, CR3 e CR4)

Come visto nelle lezioni precedenti, prima di entrare in VMX operation è necessario fisare alcuni bit nei registri di controllo CR0 e CR4. Per tale motivo è facile immaginare perché sia necessario differenziare tali registri quando si opera in root operation o, piuttosto, in non-root operation (magari per rendere trasparenti le modifiche al guest). CR3 entra a far parte del gruppo in quanto il suo significato dipende direttamente da uno specifico bit di CR0. In particolare, se la paginazione è abilitata (bit CR0.PG) allora CR3 contiene l'indirizzo della page directory table di livello più alto usata nel contesto del thread in esecuzione in quel momento. Per tale motivo svolge un ruolo importante quando si abilita la Extended Page table (EPT). Maggiori info verranno date in una prossima lezione.
Per inizializzare i campi della VMCS, identificati dai valori codificati HOST_CR0 e HOST_CR4, che verranno caricati nei registri di controllo CR0 e CR4 durante una VM-exit, si possono usare gli intrinsics __readcrX.

// Registri di controllo
__vmx_vmwrite(HOST_CR0__readcr0());
__vmx_vmwrite(HOST_CR3FindSystemDirectoryTableBase());
__vmx_vmwrite(HOST_CR4__readcr4());

Questo potrebbe valere anche per CR3 ma se si usano le DPC (deferred procedure call) per eseguire tale codice su ogni processore logico allora si deve mettere in conto che il contesto di esecuzione della callback potrebbe non essere quello del processo System (all'interno del quale è caricato il driver dell'hypervisor). Infatti, la callback viene invocata non appena il livello IRQL è minore o uguale a DISPATCH_LEVEL (2) e quindi c'è la possibilità che sia eseguita nel contesto di un processo user-mode dove il valore di CR3 punta ad una directory table diversa da quella puntata dallo stesso registro quando è in esecuzione il processo System. Per evitare problemi, è sempre meglio usare del codice specifico per l'inizializzazione di CR3. 

typedef struct _NT_KPROCESS
{
    DISPATCHER_HEADER Header;
    LIST_ENTRY ProfileListHead;
    ULONG_PTR DirectoryTableBase;
    UCHAR Data[1];
}NT_KPROCESS, * PNT_KPROCESS;
 
 
UINT64 FindSystemDirectoryTableBase()
{
    NT_KPROCESSSystemProcess = (NT_KPROCESS*)(PsInitialSystemProcess);
    return SystemProcess->DirectoryTableBase;
}

In questo caso si è deciso di scrivere una funzione di supporto (FindSystemDirectoryTable) che restituisce il valore di CR3 nel contesto del processo System. A tale scopo si è usata la variabile globale PsInitialSystemProcess, che punta alla struttura EPROCESS del processo System (si veda [3]).




Registri general purpose (RSP e RIP)

Quando si verifica una VM-exit i registri RSP e RIP vengono caricati con i valori conservati in HOST_RSP e HOST_RIP. 

// RSP e RIP
__vmx_vmwrite(HOST_RIP, (UINT64)AsmVMExitHandler);
__vmx_vmwrite(HOST_RSPvms->VMM_STACK + VMM_STACK_SIZE - 16);

HOST_RIP punta all'entry point in cui l'hypervisor riceverà il controllo. In questo caso si tratta di una semplice funzione scritta in assembly nel progetto del driver che implementa l'hypervisor. Nelle prossime lezioni si vedrà perché è conveniente scriverla in questo linguaggio. Al momento, però, si può apprezzare il fatto che aver inizializzato HOST_CR3 con l'indirizzo della directory table usata dal processo System permette di inizializzare HOST_RIP indicando semplicemente il nome di una funzione: il driver che implementa l'hypervisor ma non è ancora in root operation ed il codice dell'hypervisor in root operation usano la stessa directory table e quindi ogni un indirizzo virtuale viene mappato allo stesso indirizzo fisico in tutti e due i contesti. Naturalmente questo vale per tutti i campi dell'host-state area, non solo HOST_RIP.

// Risorse da allocare 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

    // ...

}VCPU, * PVCPU;

vms è puntatore ad una variabile globale di tipo VCPU, che contiene le risorse allocate durante la virtualizzazione di ogni processore logico. In pratica vms è un array di VCPU con tanti elementi quanti sono i processori logici. Tale variabile si rende necessaria per conservare gli spazi di memoria allocati durante la fase di inizializzazione e liberarli successivamente durante la fase di terminazione.
VMM_STACK conterrà lo spazio di memoria che verrà usato come stack dall'hypervisor per svolgere le sue funzioni in root operation (come conseguenza di una VM-exit). Solitamente lo stack, su Windows a 64-bit, è di 16 o 20 KB ed è allineato a 16 byte.




Selettori di segmento

Il nostro hypervisor è implementato all'interno di un driver che gira su un sistema a 64 bit e questo richiede che tale driver sia eseguito in tale modalità (niente compatibility mode per i driver). Benché la segmentazione non sia così importante nell'esecuzione di codice a 64 bit, i relativi registri e segmenti vengono comunque inizializzati o usati in qualche modo quindi è opportuno prevedere dei valori appropriati per i selettori di segmento. La documentazione indica che gli ultimi 3 bit dei campi che verranno caricati nei selettori contenuti nei registri di segmento devono essere zero. Con questo, in pratica, si indica che si è interessati solo all'indice del descrittore del segmento all'interno della Global Descriptor Table (GDT).



// I selector dell'host devono avere gli ultimi 3 bit azzerati
UINT8 selector_mask = 7;
 
// Selettori dei registri di segmenti
__vmx_vmwrite(HOST_CS_SELECTOR__read_cs() & ~selector_mask);
__vmx_vmwrite(HOST_SS_SELECTOR__read_ss() & ~selector_mask);
__vmx_vmwrite(HOST_DS_SELECTOR__read_ds() & ~selector_mask);
__vmx_vmwrite(HOST_ES_SELECTOR__read_es() & ~selector_mask);
__vmx_vmwrite(HOST_FS_SELECTOR__read_fs() & ~selector_mask);
__vmx_vmwrite(HOST_GS_SELECTOR__read_gs() & ~selector_mask);
__vmx_vmwrite(HOST_TR_SELECTOR__read_tr() & ~selector_mask);

Per inizializzare i campi che verranno caricati nei selettori durante una VM-exit sono stati implementati alcuni metodi di supporto in codice assembly, che funzionano in modo simile agli intrinsics forniti dai compilatori C e C++ di Microsoft. In breve __read_cs, __read_ss, ecc. ritornano la parte visibile di un particolare registro di segmento.




Infine, il valore decimale 7 in binario è 0111. La sua negazione bit a bit è indicata con ~7, che in binario è 1000. Applicare un AND bit a bit tra i vari selettori e ~7 non fa altro che spegnere (azzerare) gli ultimi 3 bit del selettore.
Per una panoramica sulla segmentazione si vedano [4] (sezione "Processor execution model") e [6] (sezione "Protected Mode"). Per un approfondimento, invece, si veda il volume 3A dell'Intel SDM.




Campo Base di alcuni registri (GDTR, IDTR, TR, FS e GS)

La documentazione indica che è necessario conservare il campo Base che si trova nei registri GDTR, IDTR, TR, FS e GS. I registri GDTR ed IDTR possono essere letti indirettamente attraverso due funzione di supporto (__sgdt ed __sidt), create appositamente in linguaggio assembly per permettere l'uso delle istruzioni SGDT ed SIDT ed ottenere così base e limite contenuti nel relativo registro. Spesso si fa riferimento al valore ritornato da SGDT ed SIDT con il termine di pseudo descriptor.




La base dei registri FS e GS, invece, può essere letta da specifici MSR.

#pragma pack(push, 1)
    typedef struct _PSEUDO_DESCRIPTOR64	{
    	UINT16 limit;
    	UINT64 base_address;
    PSEUDO_DESCRIPTOR64, * PPSEUDO_DESCRIPTOR64;
#pragma pack(pop)
 
// Istruzioni SGDT e SIDT restituiscono i registri GDTR e IDTR.
// In realtà restituiscono pseudo descriptor che indicano
// indirizzo di base e limite della GDT e della IDT.
PSEUDO_DESCRIPTOR64 gdtr = { 0 };
PSEUDO_DESCRIPTOR64 idtr = { 0 };
 
// Legge GDTR e IDTR
__sgdt(&gdtr);
__sidt(&idtr);


// Campo Base che si trova nei registri GDTR, IDTR, TR, FS e GS
__vmx_vmwrite(HOST_GDTR_BASEgdtr.base_address);
__vmx_vmwrite(HOST_IDTR_BASEidtr.base_address);
__vmx_vmwrite(HOST_FS_BASE__readmsr(MSR_IA32_FS_BASE));
__vmx_vmwrite(HOST_GS_BASE__readmsr(MSR_IA32_GS_BASE));
__vmx_vmwrite(HOST_TR_BASEGetSegmentBase(__read_tr(), (PUINT8)gdtr.base_address));

La lettura della base del registro TR è un po' più complicata in quanto questa si trova nella parte non visibile di tale registro di segmento. Il metodo di supporto GetSegmentBase è stato creato proprio per recuperare tale dato andando ad estrarlo dal relativo segment descriptor.

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;


 
UINT64 GetSegmentBase(UINT16 selectorPUINT8 gdt_base)
{
    PSEGMENT_DESCRIPTOR segment_descriptor;
 
    // Recupera segment descriptor dalla GDT.
    // L'indice del segment descriptor si trova a partire dal bit 3 del selector
    // e viene moltiplicato per 8 (la dimensione dei descriptor nella GDT) per
    // calcolare il byte offset del descriptor a partire dalla base della GDT.
    // Moltiplicare per 8 è equivalente 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)(gdt_base + (selector & ~0x7));
 
    // Calcola base del segmento.
    // Base del segmento si compone attraverso i bit 16-31 (low), 32-39 (mid) e 
    // 56-63 (high) del segment descriptor.
    unsigned __int64 segment_base = segment_descriptor->Bits.BaseLow | 
    	segment_descriptor->Bits.BaseMid << 16 | segment_descriptor->Bits.BaseHigh << 24;
 
    // Se il bit relativo al flag S è impostato a 0 allora si tratta di descriptor
    //  di sistema. In x64 tali descriptor sono di 16 byte e per comporre la base 
    // è necessario aggiungere anche i bit 64-95 (i bit 96-127 sono riservati) 
    // come 32 bit alti di un indirizzo a 64 bit.
    if (segment_descriptor->Bits.S == FALSE)
    	segment_base = (segment_base & 0xFFFFFFFF) | (UINT64)segment_descriptor->Bits.BaseUpper << 32;
 
    return segment_base;
}

Il registro TR fa riferimento al segmento TSS, che è un segmento di sistema e quindi la dimensione del relativo segment descriptor è di 16 byte. Tale descriptor (che si trova nella GDT) è raffigurato nella seguente immagine.




Come si può notare, i 32 bit alti della base (e cioè i bit 32-63) partono al byte offset 8 (bit 64).




MSR (SYSENTER)

Come per la segmentazione, l'uso delle le istruzioni SYSENTER e SYSEXIT, al fine di invocare funzioni di sistema, è del tutto marginale in sistemi a 64 bit. Tuttavia, l'architettura Intel prevede la possibilità di usarle anche in quei contesti e quindi e necessario avere dei valori appropriati da caricare in alcuni registri (in questo caso CS, RIP ed RSP) se si dovesse fare uso di tali istruzioni. L'architettura Intel prevede che CS, RIP ed RSP siano caricati con valori presi da MSR specifici. Per tale motivo si inizializzeranno i relativi campi della VMCS prendendoli proprio da tali MSR. 

// MSR (SYSENTER)
__vmx_vmwrite(HOST_SYSENTER_CS__readmsr(MSR_IA32_SYSENTER_CS));
__vmx_vmwrite(HOST_SYSENTER_EIP__readmsr(MSR_IA32_SYSENTER_EIP));
__vmx_vmwrite(HOST_SYSENTER_ESP__readmsr(MSR_IA32_SYSENTER_ESP));

Per maggiori informazioni si veda [5] (sezione "Anatomy of System Calls") oppure [6] (sezione "The Native API"), che spiegano il funzionamento di SYSENTER nell'invocazione di funzioni di sistema. Per un approfondimento pratico, invece, si veda [7] (in cinese).




Riferimenti:

[4] Windows Internals 7th ed. Part 2 - Allievi, Ionescu
[5] Inside Windows Debugging - Soulami
[6] The Rootkit Arsenal 2nd ed. - Blunden

Nessun commento:

Posta un commento