giovedì 27 giugno 2019

01 - Concetti preliminari

Questa lezione introduce i concetti di base o semplicemente ricorrenti che è importante sapere prima di iniziare a scrivere driver per Windows. Altre importanti info e dettagli rilevanti verranno presentati solo quando realmente necessario in lezioni opportune. Qualche aspetto potrebbe risultare poco chiaro anche dopo la lettura di questo articolo ma la cosa non deve preoccupare assolutamente poiché tutto risulterà più chiaro quando gli stessi concetti verranno ripresi e riesaminati in contesti pratici. E' altresì importante sottolineare che prima di iniziare a scrivere codice è di fondamentale importanza la conoscenza, almeno in linea generale, dei concetti esaminati in questo articolo quindi si eviti ad ogni costo la tentazione di saltare la lettura pensando che tutto ciò non sia rilevante ai fini pratici.




Architettura Generale di Sistema




Per modalità utente (User-Mode) si intende la modalità di esecuzione del codice, da parte della CPU, con permessi limitati. Tale codice può essere composto solo da un sottoinsieme sicuro delle istruzioni macchina e può accedere solo allo spazio di memoria utente (User space o Per-process space; si veda più avanti). Di tale modalità fanno parte le seguenti componenti:

Applicazioni: normali programmi eseguiti dagli utenti di Windows.

Windows API: librerie di supporto per applicazioni.

User-Mode Driver: non rilevante per il discorso.



Per modalità kernel (Kernel-Mode), invece, si intende la modalità di esecuzione del codice con pieni permessi, che può utilizzare tutte le istruzioni macchina e che può accedere a tutto lo spazio virtuale assegnato ad un processo (User space e Kernel space; si veda più avanti). Di tale modalità fanno parte le seguenti componenti:

Exported Driver Support Routines: interfaccia, in kernel mode, delle Windows API in user mode.

Operating System Kernel: codice che implementa funzionalità critiche o vitali del sistema operativo (thread scheduling, eccezioni ed interrupt, sincronizzazione, ecc.).

File System Driver: Driver che si occupano di gestire il file system.

Kernel-Mode Driver: Driver che si occupano di gestire i vari dispositivi presenti e riconosciuti dal sistema operativo.

Hardware Abstraction Layer (HAL): Codice di basso livello che permette di trattare l'hardware tramite una interfaccia comune senza dover per forza conoscere le specifiche tecniche dell'hardware con cui si vuole comunicare. Utile solo per Driver che gestiscono dispositivi hardware reali.

Hardware: componenti fisici reali tipo CPU, memoria, periferiche varie, ecc.

Hypervisor: Anche se non presente nell'immagine sopra, se l'OS è eseguito su macchina virtuale, tra HAL e Hardware è presente un Hypervisor che ha il compito di astrarre il concetto di hardware facendo credere all'OS che è in esecuzione su una vera macchina. Non a caso si parla di macchina virtuale gestita dall'hypervisor.




Memoria, Processi e Thread




In sistemi operativi a 64-bit ad ogni processo sono assegnati teoricamente 16 exabyte di spazio virtuale (in pratica lo spazio utilizzabile è molto di meno). Gli indirizzi più bassi di questo spazio virtuale compongono l'User space (o Per-process space) e hanno significato solo nel contesto del processo a cui tale spazio virtuale è collegato. In altre parole, un indirizzo virtuale verrà mappato a indirizzi in pagine fisiche diverse in base al processo. Gli indirizzi alti compongono, invece, il Kernel space (o System space) e contengono dati e codice condiviso da tutti i processi (il kernel stesso, i driver, le chiamate di sistema, le routine di gestione degli interrupt, ecc.). Quindi un indirizzo virtuale in kernel space verrà mappato allo stesso indirizzo fisico in tutti i processi. L'immagine sopra è relativa a sistemi operativi fino a Windows 7 dove 8 terabyte erano dedicati a quello che nell'immagine è indicato come Per-process address virtual space (User space) ed altri 8 terabyte (dei 248 messi a disposizione) per il System virtual address space (Kernel space). Da Windows 8 in poi è di 128TB per entrambi gli spazi virtuali.




Come si può notare nell'immagine sopra, due processi differenti possono avere pagine che partono da uno stesso indirizzo nello spazio virtuale mappate a pagine fisiche diverse. In questo modo fare riferimento ad un indirizzo virtuale $X$ in un processo rimanda ad un indirizzo $Y_p$ fisico che cambia in base al processo. E' altresì vero che, nonostante non sia mostrato nell'immagine, è possibile per due processi differenti avere pagine che partono da indirizzi differenti mappate ad una stessa pagina fisica. In questo modo fare riferimento ad un certo indirizzo virtuale $X_p$, che cambia in base al processo, può rimandare ad uno stesso indirizzo fisico $Y$.





Un processo non esegue codice, è solo un oggetto contenitore di informazioni (file immagine, thread, tabella handle, info di sicurezza, ecc.) necessarie alla corretta e sicura esecuzione del codice nel file immagine. I veri responsabili dell'esecuzione del codice, però, sono i thread. In User space sono allocate zone di memorie dette stack, una per ogni thread del processo, responsabili soprattutto della gestione delle chiamate e dell'allocazione di variabili locali. Il Kernel space, invece, contiene uno stack per ogni thread di ogni processo (il perché si vedrà fra poco). Infine, è importante notare che ogni thread "vede" tutto lo spazio di indirizzamento collegato al processo (kernel space + user space) ma ha accesso diretto al kernel space solo se eseguito in kernel mode.



Quando si programma in kernel mode è importante sapere che la memoria allocata e utilizzata si divide in due categorie: Paged e NonPaged. La memoria Paged ha la caratteristica che può essere "messa da parte" sul disco invece che stare nella memoria principale (solitamente la RAM), sopratutto se non viene utilizzata con continuità o se ci si allontana troppo dai suoi indirizzi. La memoria NonPaged, al contrario, sta sempre nella memoria principale.



Oggetti e Handle

Qualsiasi informazione utile o necessaria al corretto funzionamento del sistema operativo è descritto, nella maggior parte dei casi, da una struttura le cui istanze sono definite oggetti e sono create e gestite in kernel mode dall'Object Manager. Applicazioni eseguite in User-mode quindi possono fare riferimento ad esse o chiederne la creazione solo indirettamente. Come detto in precedenza, ogni processo ha una tabella handle ed è proprio attraverso questi handle che una applicazione può fare riferimento ad un oggetto. Un handle è, in pratica, una sorta di indice (i valori che può assumere un handle sono multipli di 4, partendo da 4; zero non è un valore valido) ad un elemento della tabella handle ed ogni elemento di tale tabella fa riferimento ad un oggetto del kernel. Ad ogni oggetto è collegato un contatore di riferimenti e l'oggetto viene distrutto solo quando il contatore si azzera: cioè quando non ci sono più riferimenti all'oggetto in questione. Per chiederne la creazione, invece, una applicazione in user mode può utilizzare una qualche funzione dell'API di Windows (Create* o Open*). Una volta che l'applicazione ha finito di usare l'oggetto chiude il suo handle ed il conto dei riferimenti diminuisce di uno.
I driver in kernel mode, invece, possono fare riferimento agli oggetti sia in modo indiretto, tramite handle, sia in modo diretto, tramite puntatore all'oggetto. Un driver può ottenere un puntatore ad un oggetto, a partire da un handle passatogli da una applicazione in user mode, attraverso ObReferenceObjectByHandle ed il contatore verrà comunque incrementato così che l'Object Manager non distrugga l'oggetto pensando che non ci siano più riferimenti ad esso se l'applicazione in user mode decide di chiudere l'handle prima che il driver abbia finito con l'oggetto. Alla fine il driver deve chiamare ObDereferenceObject per decrementare il contatore.
I tipi degli oggetti possono essere dei più vari: non solo file, processi, thread, ecc. ma anche callback, chiavi di registro, eventi, ecc.
Gli oggetti possono anche avere un nome. Per vedere gli oggetti con nome creati e gestiti dall'Object Manager si può usare WinObj




Per vedere la lista di handle aperti di un processo si può utilizzare Process Explorer ricordandosi di applicare la spunta a View -> Lower Panel View -> Handles.






Liste

Il sistema operativo spesso mantiene oggetti dello stesso tipo collegati insieme attraverso una lista circolare doppiamente collegata:




La struttura principale su cui si basa tale lista è _LIST_ENTRY

typedef struct _LIST_ENTRY {
 struct _LIST_ENTRY *Flink;
 struct _LIST_ENTRY *Blink;
} LIST_ENTRY, *PLIST_ENTRYPRLIST_ENTRY;


La lista è dunque attraversa usando i campi di tale struttura che forniscono puntatori agli oggetti successivo e precedente. Il problema è che tale lista è annidata dentro l'oggetto quindi dereferenziato il puntatore Flink o Blink quello che si ottiene è l'indirizzo della struttura _LIST_ENTRY dell'oggetto successivo o precedente, rispettivamente. Quello che si vorrebbe è invece l'indirizzo dell'oggetto contenitore. A questo scopo si può usare la macro CONTAINING_RECORD che restituisce l'indirizzo dell'oggetto a partire da tre informazioni utili allo scopo: l'indirizzo di _LIST_ENTRY, il tipo dell'oggetto contenitore ed il nome del campo definito come _LIST_ENTRY nella definizione del tipo dell'oggetto contenitore. Segue un esempio pratico

typedef struct _MYDATA {
 
 // altri membri
 
 LIST_ENTRY Link;
 
 // altri membriMYDATA, *PMYDATA;

MYDATAGetObject(LIST_ENTRYpEntry) {
 return CONTAINING_RECORD(pEntryMYDATA, Link);
}



Client, Driver e Device

Lo scopo di un driver è quello di gestire richieste da parte di un client fatte verso un dispositivo (software o hardware). Una applicazione in user mode, ad esempio, può fare una richiesta ad un device ed il driver che si occupa di quel device gestirà la richiesta in qualche modo (direttamente o indirettamente, passando la richiesta a qualcun altro).
Dopo che un driver è stato caricato in memoria viene avviato. Un thread di sistema chiama il suo entrypoint che si occupa di tutte le inizializzazioni necessarie (fra queste c'è la specificazione dei device per i quali il driver intende gestire le richieste). Fatto questo il thread di sistema termina e del driver resta solo un blocco di codice in memoria. A questo punto quando l'applicazione in user mode fa una richiesta al dispositivo si conosce già quale driver gestirà la richiesta (il driver l'ha specificato nella fase di inizializzazione) e verrà invocato il relativo codice (quale è tema che verrà affrontato in una lezione successiva). La cosa importate da notare è che il thread che esegue il codice del driver in kernel mode è lo stesso dell'applicazione che ha fatto la richiesta in user mode. Lo spazio virtuale "visto" dal thread resta lo stesso ma dato che ora il thread ha accesso diretto alla memoria in kernel space è necessario un context switch in cui vengono salvati tutti i dati che serviranno poi a riprendere il codice in user mode. Per questo motivo esiste uno stack in user space ed uno in kernel space per lo stesso thread ID: lo stesso thread può accedere a spazi diversi che contengono codice in modalità diverse e che, per questo, spesso devono rispettare regole diverse. Una cosa interessante da notare è che lo stack in kernel space è sempre NonPaged quando un thread esegue il codice in kernel space.





System Calls

Molte funzioni dell'API di Windows forniscono funzionalità che richiedono il coinvolgimento del sistema operativo (creare\aprire file, modificare chiavi di registro, ecc.) quindi l'implementazione reale di tali funzioni risiede in kernel space e richiede l'esecuzione in kernel mode. Quando si chiama una qualsiasi di tali funzioni, però, non c'è nessun driver di mezzo. Benché divise in varie librerie, alla fine tutte le funzioni dell'API di Windows passano per NTDLL.DLL che è l'ultima libreria in user mode e la responsabile della transizione tra user e kernel mode. In particolare, la versione in NTDLL.DLL della funzione mette in EAX un indice ed invoca l'istruzione syscall che si occupa della fase di transazione e passa la palla al system service dispatcher che, a sua volta, usa l'indice in EAX per indirizzare un elemento della System Service Dispatch Table (SSDT) che punta alla relativa funzione in kernel space.





IRQL

In kernel mode a volte il codice ha bisogno di essere eseguito con una certa priorità. La gestione di un interrupt hardware, ad esempio, non ha la stessa priorità di una normale funzione in esecuzione in user mode. Si rende quindi necessario un meccanismo di gestione dell'interruzione del codice in base alla sua priorità. L'IRQL (interrupt request level) è unavalore numerico collegato ala CPU che indica il livello di priorità a cui sta lavorando la stessa. Se codice in kernel mode che necessità un'alta priorità deve essere eseguito questo può alzare il valore dell'IRQL della CPU ed essere eseguito senza che nessun codice con IRQL uguale o più bassa possa interrompere la sua esecuzione. Una volta concluso il suo lavoro il codice riporta l'IRQL della CPU al suo stato precedente permettendo l'esecuzione di codice di livello uguale o più basso, che magari era stato interrotto (fondamentali in questo caso sono user e kernel stack che permettono di salvare lo stato del codice interrotto per ripristinarlo al momento della ripresa dell'esecuzione). Il codice in user mode e quello in kernel mode normalmente vengono eseguiti ad un IRQL pari a PASSIVE_LEVEL (valore 0) ma il codice in kernel mode può alzare l'IRQL della CPU. Di particolare interesse è l'IRQL pari a DISPATCH_LEVEL (valore 2) dato che da questo livello in poi è necessaria qualche attenzione in più. In particolare, lo scheduler dei thread non funziona perché anche questo viene eseguito a questo valore di IRQL e quindi mettere in attesa un thread a questo livello significa blocco completo del sistema (nessuno potrà mai risvegliare tale thread). Inoltre, a questo livello non è permesso l'accesso a memoria Paged in quanto i page fault (che sono eccezioni specifiche per portare memoria da paged a RAM) comportano un cambio di thread (context switch) che coinvolge anche lo scheduler.





DPC

A valori ancora più alti di IRQL le priorità del codice diventano massime e le limitazioni aumentano. Ad esempio, con IRQL superiori a DISPATCH_LEVEL non è possibile invocare alcune funzioni dell'API del kernel in quanto eseguono operazioni lente e questo non è permesso in operazioni critiche che richiedono esecuzione immediata. Se del codice a tali valori di IRQL dovesse trovarsi nelle condizioni di effettuare comunque una chiamata a tali funzioni può usare una DPC (Deferred Procedure Call) che altro non è che un oggetto che contiene un puntatore a funzione che viene accodato ad una coda collegata alla CPU e viene eseguita ad IRQL uguale a DISPATCH_LEVEL una volta che il livello IRQL scende fino a tale valore. Si noti che la cosa comporta il fatto che il thread che eseguirà la DPC è arbitrario. Inoltre, codice eseguito a IRQL superiori a DISPATCH_LEVEL cerca quasi sempre di evitare di diminuire l'IRQL direttamente per poi rialzarlo in quanto la cosa potrebbe causare ritardi o addirittura un deadlock (vedi paragrafo successivo).





Sincronizzazione

Un driver funziona un po' come un server: può ricevere chiamate da diversi client nello stesso momento e, volendo, gestirle in parallelo su un multiprocessore dato che ogni thread girerà su una CPU diversa. Se il driver ha in gestione qualche risorsa il lettura ed in scrittura è evidente come si renda necessario un meccanismo di sincronizzazione dei thread che accedono alla stessa risorsa. La cosa funziona in modo simile alla sincronizzazione in user mode con i thread che si mettono in attesa su un oggetto (mutex, semaforo, ecc.) se questo è stato già acquisito e non ancora rilasciato.
Una piccola complicazione si palesa quando entrano in scena le DPC. Come detto in precedenza le DPC sono eseguite con IRQL uguale a DISPATCH_LEVEL dove lo scheduler non funziona (mettere in attesa un thread a tale livello significa bloccare tutto). Se una DPC sta eseguendo operazioni su una risorsa e, allo stesso tempo, del codice a livello IRQL anche più basso ma che è eseguito su una CPU differente esegue operazioni sulla stessa risorsa è evidente che ci sarà un problema di death racing. In questo caso quello che si vuole sincronizzare sono le CPU non i thread. A tale scopo esistono gli spin lock, bit di memoria che vengono acquisiti e rilasciati dalle CPU. Se uno spin lock è già acquisito è la CPU ad "aspettare" eseguendo sempre la stessa operazione (controllare che lo spin lock sia stato rilasciato). Non essendoci nessun thread in attesa il metodo è valido in caso in cui ci sia una DPC che usa una risorsa condivisa. Il motivo per cui codice a livelli più alti di DISPATCH_LEVEL evita di diminuirlo direttamente, quindi, è presto detto: se deve manipolare una risorsa condivisa tramite spin lock la cosa può andare per le lunghe (nel migliore dei casi). Al contrario deferire tale operazione ad una DPC accodata alla CPU risolve il problema.





Restrizioni e Kernel API

Scrivere driver permette di fare cose che non è permesso fare con le normali applicazioni in user mode ma, come recita la famosa frase di un film: "Da un grande potere derivano grandi responsabilità", e questo caso non fa eccezione. Se un'applicazione in user mode sbaglia in modo critico il massimo che può succedere e che crasha l'applicazione. Se a sbagliare gravemente è un driver crasha l'intero sistema restituendo il famoso BSOD (Blue Screen of Death).




Ma anche il semplice leak di risorse in user mode comporta il recupero delle stesse alla chiusura dell'applicazione user mode. Un leak in kernel mode, invece, comporta il recupero al prossimo riavvio del sistema. Mettiamoci anche il fatto che non c'è nozione di memoria heap in kernel mode ed il quadro è completo. Le conseguenza di tutto questo sono che, se si lavora in kernel mode, non si può tralasciare nulla, neanche i valori di ritorno delle funzioni, che spesso indicano se la chiamata ha avuto successo o meno. In secondo luogo non si possono sfruttare le normali librerie standard fornite dai linguaggi come C e C++ (poiché spesso fanno uso della memoria heap in user space) e nemmeno i relativi Runtime (addio quindi a costruttori e distruttori personalizzati in variabili globali).
Esiste comunque una Kernel API da usare in kernel mode e che è molto simile alla sua controparte user mode. Si tratta per la maggior parte di funzione C implementate in NTOSKRNL.EXE o HAL.DLL e che iniziano con un prefisso che ne indica la categoria di appartenenza (file system, object manager, I/O manager, sicurezza, ecc.). Tra queste categorie ci sono le funzioni con prefisso Zw che non sono altro che la controparte in kernel mode delle funzioni user mode in NTDLL.DLL. Come detto in precedenza parlando delle chiamate di sistema, chiamare una funzione in NTDLL.DLL innesca un context switch da user a kernel mode e il codice della funzione in kernel space viene eseguito. Se tale codice è invocato da user mode sarà soggetta ad un certo numero di controlli e verifiche. Se invece l'invocazione è da parte di una delle funzioni Zw tali controlli non vengono effettuati. L'implementazione reale della funzione in kernel space distingue tra i due casi attraverso il campo PreviousMode della struttura KTHREAD. Come visto in precedenza, il thread è l'unico oggetto kernel responsabile dell'esecuzione del codice quindi ha senso salvare info sulla modalità di esecuzione del chiamante in tale oggetto.

Nessun commento:

Posta un commento