Uno dei concetti più interessanti e comunemente usati nell’architettura x86 è la modalità protetta e il suo supporto in 4 modalità (aka anelli):
È stata un’idea difficile da afferrare e cercherò di spiegarla il più chiaramente possibile in questo post. Tratteremo i seguenti concetti:
- GDT, LDT, IDT.
- Traduzione della memoria virtuale.
- ASLR e Kernel ASLR (KASLR).
Partiamo dalle basi, ogni computer ha almeno (si spera) i seguenti componenti: CPU, Disco e RAM. Ognuno di questi componenti ha un ruolo chiave nel flusso del sistema. La CPU esegue i comandi e le operazioni sulla memoria (RAM), la RAM tiene i dati che stiamo usando e permette un accesso veloce e affidabile ad essi, il disco tiene i dati persistenti di cui abbiamo bisogno per esistere anche dopo il riavvio o lo spegnimento. Parto da questo perché, anche se questo è molto basilare, è importante tenerlo a mente e mentre leggete questo articolo chiedetevi di quale componente stiamo parlando in quel momento.
Il sistema operativo è il software che orchestra il tutto, e anche quello che permette un’interfaccia veloce, conveniente, coerente ed efficiente per accedere a tutte le sue capacità – alcune delle quali sono l’accesso all’hardware, e altre sono per migliorare la comodità e le prestazioni.
Come ogni buon software il sistema operativo lavora a strati, il kernel è il primo strato e – a mio parere – il più importante. Per capire l’importanza del kernel dobbiamo prima capire le sue azioni e le sfide che deve affrontare, quindi vediamo alcune delle sue responsabilità:
- Gestire le chiamate di sistema (la stessa interfaccia di cui abbiamo parlato).
- Allocare le risorse (RAM, CPU, e molto altro) ai processi/threads in mano.
- Sicurare le operazioni eseguite.
- Intermediare tra l’hardware e il software.
Molte di queste azioni eseguite con il generoso aiuto del processore, nel caso di x86, la modalità protetta è la modalità che ci permette di limitare la potenza (set di istruzioni) del contesto di esecuzione in corso.
Immaginiamo di avere due mondi: il mondo dell’utente e il mondo del supervisore. In qualsiasi momento potete essere solo in uno di questi mondi. Quando sei nel mondo dell’utente vedi il mondo come il supervisore vuole che tu lo veda. Vediamo cosa intendo con questo:
Diciamo che siete un processo. Un processo è un contenitore di uno o più thread. Un thread è un contesto di esecuzione, è l’unità logica di cui vengono eseguite le istruzioni della macchina. Significa che quando il thread sta eseguendo, diciamo, la lettura dall’indirizzo di memoria 0x80808080, si riferisce effettivamente all’indirizzo virtuale 0x80808080 del processo corrente. Come potete immaginare, il contenuto dell’indirizzo sarà diverso tra due processi. Ora, lo spazio di indirizzo virtuale è a livello di processo, il che significa che tutti i thread dello stesso processo hanno lo stesso spazio di indirizzo e possono accedere alla stessa memoria virtuale. Per dare un esempio di risorsa che è a livello di thread usiamo il famoso stack.
Allora ho un thread che esegue il seguente codice:
Il nostro thread esegue la funzione principale che chiamerà la nostra funzione “func”. Diciamo che interrompiamo il thread alla linea 9. la disposizione dello stack sarà la seguente:
- variabile_a.
- parametro.
- indirizzo di ritorno – indirizzo della linea 20.
- variabile_b.
Per illustrare:
Nel codice dato creiamo 3 thread per il nostro processo e ognuno di essi stampa il suo id, segmento di stack e puntatore di stack.
Un possibile output di quel programma è:
Come potete vedere tutti i thread hanno lo stesso segmento di stack perché hanno lo stesso spazio di indirizzo virtuale. Il puntatore allo stack per ognuno è diverso perché ognuno ha il proprio stack in cui memorizzare i propri valori.
Nota a margine sul segmento dello stack – spiegherò di più sui registri di segmento nella sezione GDT/LDT – per ora prendete la mia parola per questo.
Perché questo è importante? In qualsiasi momento, il processore può congelare il thread e dare il controllo a qualsiasi altro thread voglia. Come parte del kernel, lo scheduler è quello che alloca la CPU ai thread attualmente esistenti (e “pronti”). Affinché i thread siano in grado di funzionare in modo affidabile ed efficiente, è essenziale che ciascuno abbia il proprio stack in cui può salvare i suoi valori rilevanti (variabili locali e indirizzi di ritorno, per esempio).
Per gestire i suoi thread, il sistema operativo mantiene una struttura speciale per ogni thread chiamata TCB (Thread Control Block), in tale struttura salva – tra le altre cose – il contesto di quel thread e il suo stato (in esecuzione / pronto / ecc …). Il contesto contiene – di nuovo – tra le altre cose, i valori dei registri della CPU:
- EBP -> Indirizzo di base dello stack, ogni funzione usa questo indirizzo come indirizzo di base da cui ha compensato per accedere alle variabili locali e ai parametri.
- ESP -> Il puntatore corrente all’ultimo valore (primo a pop) sullo stack.
- Registri di uso generale -> EAX, EBX, ecc…
- Registro delle flags.
- C3 -> contiene la posizione della directory della pagina (sarà discussa più avanti).
- EIP – La prossima istruzione da eseguire.
Oltre ai threads il sistema operativo ha bisogno di tenere traccia di molte altre cose, inclusi i processi. Per i processi il sistema operativo salva la struttura PCB (Process Control Block), abbiamo detto che per ogni processo c’è uno spazio di indirizzi isolato. Per ora supponiamo che ci sia una tabella che mappa ogni indirizzo virtuale ad uno fisico e che la tabella sia salvata nel PCB, il sistema operativo è responsabile dell’aggiornamento di quella tabella e la tiene aggiornata allo stato corretto della memoria fisica. Ogni volta che lo scheduler passa l’esecuzione a un determinato thread, la tabella salvata per il processo proprietario di quel thread viene applicata alla CPU in modo che sia in grado di tradurre correttamente gli indirizzi virtuali.
Questo è abbastanza per i concetti, cerchiamo di capire come viene fatto in realtà. Per questo guardiamo il mondo dal punto di vista del processore:
Global Descriptor Table
Sappiamo tutti che il processore ha registri che lo aiutano a fare calcoli, alcuni registri più di altri (;)). Per progettazione l’x86 supporta più modalità ma le più importanti sono utente e supervisionato, la CPU ha un registro speciale chiamato gdtr (Global Descriptor Table Register) che contiene l’indirizzo di una tabella molto importante. quella tabella mappa ogni indirizzo virtuale alla sua modalità corrispondente del processore, contiene anche i permessi per quell’indirizzo (READ | WRITE | EXECUTE). ovviamente quel registro può essere cambiato solo dalla modalità supervisor. Come parte dell’esecuzione del processore, controlla quale istruzione eseguire dopo (e a quale indirizzo si trova), controlla quell’indirizzo con il GDT e in questo modo sa se è un’istruzione valida in base alla modalità desiderata (abbina la modalità corrente della CPU alla modalità nel GDT) e ai permessi (se non eseguibile – non valida). Un esempio è ‘lgdtr’ l’istruzione che carica il valore nel registro gdtr e può essere eseguita solo dalla modalità supervisionata come detto. Il punto chiave da sottolineare qui è che qualsiasi protezione sulle operazioni di memoria (esecuzione di istruzioni / scrittura in posizioni non valide / lettura da posizioni non valide) è fatta dal GDT e LDT (in arrivo) a livello di processore utilizzando queste strutture che sono state costruite dal SO.
Questo è l’aspetto del contenuto di una voce in GDT / LDT:
http://wiki.osdev.org/Global_Descriptor_Table
Non solo questi due eseguibili hanno lo stesso indirizzo base ma entrambi non supportano ASLR (l’ho disabilitato):
Lo puoi vedere anche incluso nel formato PE sotto Caratteristiche file:
Ora eseguiamo entrambi gli eseguibili allo stesso tempo ed entrambi condividono lo stesso indirizzo di base (userò vmmap di Sysinternals per visualizzare l’immagine di base):
Possiamo vedere che entrambi i processi non usano ASLR e hanno lo stesso indirizzo base di 0x00400000. Se fossimo degli attaccanti e avessimo accesso a questo eseguibile, avremmo potuto sapere esattamente quali indirizzi saranno disponibili per questo processo una volta trovato via per iniettarci nella sua esecuzione. abilitiamo ASLR nel nostro eseguibile 1.exe e vediamo la sua magia:
È cambiato!
KASLR (Kernel ASLR) è la stessa cosa di ASLR, solo che lavora a livello del kernel, il che significa che una volta che un attaccante è stato in grado di iniettarsi nel contesto del kernel non potrà (si spera) sapere quali indirizzi contengono quali strutture (per esempio dove si trova il GDT in memoria). Una cosa da menzionare qui è che ASLR fa la sua magia ad ogni spawn di un nuovo processo (che supporta ASLR, ovviamente) mentre KASLR la fa ad ogni riavvio perché questo è quando il kernel viene “spawnato”.
Come funziona ASLR?
Come funziona e come è collegato alla modalità protetta? Il responsabile dell’implementazione di ASLR è il caricatore. Quando un processo viene lanciato, il caricatore è quello che deve metterlo in memoria, creare le relative strutture e lanciare il suo thread. Il caricatore prima controlla se l’eseguibile supporta l’ASLR e, in caso affermativo, randomizza qualche indirizzo base all’interno della gamma di indirizzi disponibili (lo spazio del kernel per esempio non è disponibile, ovviamente). Sulla base di quell’indirizzo il caricatore ora inizializza la Page Directory per quel processo per puntare lo spazio di indirizzi randomizzato a quello fisico. La flessibilità di LDT viene anche in nostro soccorso, poiché il caricatore crea semplicemente LDT che corrisponde all’indirizzo randomizzato con i relativi permessi. La bellezza qui è che la modalità protetta non è nemmeno consapevole che ASLR è in uso, è abbastanza flessibile da non preoccuparsene.
Alcuni interessanti dettagli di implementazione sono che in Windows l’indirizzo randomizzato per specifici eseguibili è fisso per ragioni di efficienza. Quello che voglio dire è che se abbiamo randomizzato l’indirizzo per, diciamo, calc.exe, la seconda volta che viene eseguito l’indirizzo base sarà lo stesso. Quindi se apro 2 calcolatrici allo stesso tempo – avranno lo stesso indirizzo di base. Quando chiuderò entrambe le calcolatrici e le riaprirò, avranno di nuovo lo stesso indirizzo, solo che questo sarà diverso da quello delle calcolatrici precedenti. Perché non è efficiente, vi chiederete? Pensate alle DLL di uso comune. Molti processi le usano e se i loro indirizzi di base fossero diversi per ogni istanza di processo, anche il loro codice sarebbe diverso (il codice fa riferimento ai dati usando questo indirizzo di base) e se il codice è diverso la DLL dovrà essere caricata in memoria per ogni processo. In realtà il sistema operativo carica le immagini solo una volta per tutti i processi che usano questa immagine. Si risparmia spazio – molto spazio!
Conclusione
A questo punto dovreste essere in grado di immaginare il kernel al lavoro e capire come tutte le strutture chiave dell’architettura x86 giocano insieme in un quadro più grande e ci permettono di eseguire applicazioni possibilmente pericolose in modalità utente senza (o con poca) paura.