Viaggio all interno del memory management di Win32
Salve ragazzi,
visto la continua espansioni dei Sistemi Operativi basati su Win 32 ho deciso di scrivere qualcosa che riguarda l'argomento ed in particolare su una delle parti più interessanti dei Sistema Operativi il gestore della memoria. Il documento tratta alcuni concetti che sono alla base per della programmazione a basso livello ed anche se non è corredato da esempi, penso che sia utile per chi non ha una visione chiara del modo protetto e del modello di memoria usato nelle piattaforme Win32.
- Prima parte: Breve descrizione sui meccanismi di protezione dei processori 80386+.
- Seconda Parte: Modello di memoria usato nelle piattaforme Win32.
Parte Prima.
L'80386 e tutti i processori compatibili con esso, comprende una serie di meccanismi di protezione. Essi sono utilizzati dal sistema operativo per ridurre l'effetto di un bug di un programma o per limitare l'accesso ad alcune risorse da parte delle applicazioni utente. La protezione si basa su cinque aspetti:
- Verifica del tipo
- Verifica del limite
- Restrizione del dominio indirizzabile
- Restrizione dei punti di entrata delle procedure
- Restrizione del set d'istruzione.
Questi aspetti della protezione sono applicati nella protezione a livello di segmento e di pagina. Senza entrare nel dettaglio possiamo brevemente descrivere i cinque aspetti della protezione.
La verifica del tipo a livello di segmento è usata dal processore per riconoscerne i diversi tipi. Infatti i segmenti possono essere di tipo dati o eseguibili, ma anche i descrittori agli stessi segmenti possono essere di tipi diversi. Per le pagine la verifica del tipo serve solo per comprendere se la pagina è a sola lettura o a lettura/scrittura.
La verifica del limite è applicata solo nella protezione a livello di segmento, infatti ogni segmento definito ha una propria grandezza, quindi la protezione del limite controlla che non si cerchi di leggere/scrivere oltre la grandezza massima del segmento.
La restrizione del dominio indirizzabile è legato al concetto di livello di privilegio. Il meccanismo del privelegio è stato implementato dalla Intel, dividendo in 4 livelli da 0 a 3 (detti anche ring 0 - 3) lo stato di privilegio attuale. Il livello 0 è quello con il privilegio più alto, di solito è usato dal codice di sistema, mentre il livello 3 è quello con priorità minima ed è destinato alle applicazioni utente. Il processore al momento dell'esecuzione ha un proprio livello di privilegio (CPL, livello di privilegio corrente), ed in base a questo livello è abilitato all'accesso o no ad alcuni segmenti ed ha la possibilità o no di eseguire alcune istruzioni particolari. La restrizione del dominio indirizzabile a livello di segmento serve per controllare se con il CPL attuale è possibile accedere ad un determinato segmento. A livello di pagina invece, la restrizione del dominio indirizzabile è implementato assegnando a ciascuna pagina uno dei 2 livelli: Supervisore o Utente. Se il CPL del processore è 3 il livello corrente è Utente; altrimenti è Supervisore. La differenza tra Utente e Supervisore è nella possibilità di poter leggere o leggere/scrivere nelle pagine e nelle tabelle di pagine.
La restrizione dei punti di entrata delle procedure è implementata in modo da poter eseguire solo le istruzioni che si trovano in segmenti con lo stesso livello di privilegio di quello corrente (CPL). Cioè il controllo viene effettuato in ogni jump far e call far, il livello di privilegio del segmento in cui si deve saltare (chaimato DPL) deve essere uguale al CPL. Naturalmente ci sono delle eccezioni è possibile infatti eseguire anche codice in segmenti di livello di privilegio più basso rispetto a quello corrente, ma questo avviene solo in particolari segmenti. Ed infine è anche possibile (com'era logico attendersi) trasferire il controllo a livelli di privilegio numericamete inferiori (cioè a segmenti con privilegio più alto di quello corrente); questo trasferimento è possibile solo tramite particolari descrittori chiamati porte ed il processo di passaggio fra livelli di privilegio diversi viene chiamata callgate (porte di chiamata... brutta traduz.. ma copiata dai manuali Intel).
Infine la restrizione del set d'istruzioni: ci sono alcune istruzioni che possono essere eseguite solo se il CPL è 0, queste comprendono tutte le operazioni sui registri di controllo, di debug, di test, ed in più qualche altra istruzione come HLT, LGDT, LIDT etc.
Ok non sarò stato il massimo della chiarezza ma purtroppo è difficile spiegare il tutto senza entrare nel dettaglio, comunque anche se non avete compreso tutto, l'importante è che avete capito i concetti fondamentali della protezione.
Parte Seconda.
Iniziamo ora un'affascinante discussione sulla gestione della memoria in ambiente Win32.
Win32 a ring 3 usa il modello di memoria flat (o piatta in italiano), mentre a ring 0 esso usa il normale metodo di indirizzamento selettore/spiazzamento, con il paging attivato. Prima di continuare voglio ricordare il protected-mode addressing model dei processori 80386+.
Nel modo protetto ogni segmento è definito dal programmatore, egli, infatti, può scegliere alcuni attributi come l'indirizzo base del segmento, la grandezza, livello di privilegio ed altri parametri. La struttura che definisce questi attributi è chiamata descrittore. I descrittori sono situati in due tabelle la LDT (tabella dei descrittori locale) e la GDT (tabella dei descrittori globale). C'è una sola GDT e più LDT definite a tempo di esecuzione. Il puntatore alla LDT si trova nel registro LDTR mentre quello alla GDT si trova nel registro GDTR. Si può selezionare un descrittore caricando un selettore in un registro di segmento. Un selettore è formato da 16 bit, che indicano: La tabella scelta (LDT o GDT), un indice in questa tabella ed il livello di privilegio del richiedente (RPL). Quindi si può indirizzare una locazione tramite la coppia selettore/spiazzamento (selector/offset); l'offset può essere a 32 bit o a 16, 32 per segmenti definiti a 32 bit e 16 per segmenti definiti a 16 bits.
Quando viene modificato un registro di segmento con un nuovo selettore, l'80x86 legge le informazioni del descrittore (selezionato) e crea un indirizzo lineare (Linear Address) prima di accedere alla memoria. L'indirizzo lineare è creato leggendo il campo "segment base" del descrittore ed aggiungendoci lo spiazzamento.
LDTR or GDTR---->|TABELLA DEI DESCRITTORI|
| |
SELETTORE ----> | entry.Segment_Base_field + SPIAZZAMENTO = INDIRIZZO LINEARE
Ora se il meccanismo della paginazione è disabilitato, l'indirizzo lineare è uguale all'indirizzo fisico. Se invece la paginazione è abilitata l'indirizzo è diverso da quello fisico. In questo caso, l'indirizzo lineare è visto dall'80x86 in questo modo:
Indirizzo Lineare = DIR:PAGE:OFFSET
Indirizzo Lineare a 32 bit:
Bits 0..11 = OFFSET
Bits 12..21 = PAGE
Bits 22..31 = DIR
Il meccanismo della paginazione è implementato dall'80x86 in questo modo:
- una pagina è generalmente di 4k (sui 486+ può essere maggiore)
- ci sono due livelli di tabelle di pagine, nelle quali ogni elemento specifica l'indirizzo del page frame, la protezione e così via. Nel registro CR3 (detto anche PDBR Page Directory Base Register) c'è un puntatore alla tabella di pagine di 1° livello (la directory table). L'elemento della directory table punta ad un page table (la tabella di 2° livello). L'indirizzo fisico sarà allora creato in questo modo:
Indirizzo Lineare = DIR:PAGE:OFFSET
CR3 ------>| PAGE DIRECTORY | (o directory table)
| |
DIR -----> | entry ---------| -> |PAGE TABLE|
| |
PAGE------>| entry.frame_address_field + OFFSET = PHYSICAL ADDRESS
Nota: questo schema è valido solo se le pagine sono di 4k e l'extending address è disabilitato.
Il modo di indirizzamento FLAT è un semplice modello usato per bypassare la segmentazione. Il modo di indirizzamento FLAT è creato assegnando ai segmenti dati e codice (CS e DS..ed anche ES di solito) un indirizzo base uguale a 0. In questo modo lo spiazzamento altro non è che l'indirizzo lineare, infatti il campo segment base (che è uguale a 0) viene sommato allo spiazzamento ma SPIAZZAMENTO + 0 = INDIRIZZO LINEARE! Ci sono diverse implementazione del modo di indirizzamento FLAT con il meccanismo della paginazione abilitata, disabilitata o con qualche altra lieve differenza rispetto a quanto spiegato. Nota, non è possibile disabilitare la segmentazione solo la paginazione può essere abilitata o disabilitata, il modello FLAT non disabilita la paginazione la "nasconde" solamente!
Per maggiori informazioni su questi argomenti leggete i manuali Intel.
Ed ora il modello di memoria utilizzato dalle piattaforme win32 per i chip Intel. Nota: per le versioni di Win NT per processori diversi da quelli Intel e da quelli compatibili 80386, la discussione seguente potrebbe non essere valida!
A ring 3 win32 usa il modello FLAT, 3 segmenti principali sono creati uno per il codice e due per i dati (sono CS,DS e ES), entrambi con il campo indirizzo base impostato a 0 e limite a 4 GB. Win32 usa la paginazione per questo l'indirizzo lineare è diverso da quello fisico (ogni pagina è di 4kb). Per la natura stessa del modello FLAT con la paginazione abilitata a ring 3 i processi possono vedere solo gli indirizzi mappati nel loro address space e nient'altro. Non ci sono win32 API (o almeno io spero) che permettono di allocare un descrittore nella GDT o nella LDT, ma se sappiano dove un descrittore in una GDT o LDT punta, noi possiamo caricarlo in un registro di segmento e possiamo quindi indirizzare anche questa regione di memoria con i relativi attributi. Naturalmente sappiamo che la stessa regione è comunque indirizzabile anche utilizzando il registro DS di default in quando è l'indirizzo lineare quello che realmente ci indica una regione di memoria e non i segmenti!. Come risultato del modello FLAT implementato a ring 3 è possibile scrivere in un sezione di codice (bisogna però usare WritememoryProcess o altri trucchetti). Questo è possibile solo perché il segmento dati e di codice hanno lo stesso indirizzo base e conseguentemente lo stesso spazio di indirizzi, ed essendo possibile scrivere nel segmento dati... ;). Nota: l'80x86 non permette di scrivere in un segmento eseguibile!. Facciamo un esempio per rendere tutto più semplice, supponiamo di avere il seguente frammento di codice:
.data
data_code db 40 dup(?)
.code
mov eax,34 ;fake number!
@1: mov [data_code], eax ;accessing data through DS
....
;è possibile apportare ulteriori modifiche sull'array data_code :-)
.....
;ora supponiamo di aver copiato del "codice" nell'array data_code
;per eseguirlo basterà:
@2: jmp offset data_code ;accessing data through CS..uh executing ;)
; karino no?!!
L'esempio è abbastanza semplice, ed in realtà le uniche istruzioni di rilievo sono le @1 e @2. Si capisce subito che nell'istruzione @1, in realtà è sottointeso l'uso del registro DS come segmento da utilizzare perciò l'indirizzo data_code sarà visto dal processore come un indirizzo che fà parte di un segmento dati (DS:data_code), ed è quindi possibile scriverci sù. Analogamente nell'istruzione @2 è implicito l'uso del registro CS e quindi l'indirizzo data_code è visto come parte di un segmento eseguibile (CS:data_code) ed è perciò possibile eseguirlo. Da questo se ne deduce che in win32 ogni pagina in memoria può essere eseguita, per questo motivo i flags PAGE_EXECUTE (usati in VirtualAlloc e VirtualProtect) negli ambienti win32 progettati per processori Intel praticamente non servono ma esistono solo per compatibilità con gli ambienti progettati per altri processori. Premesso quindi che possiamo allocare un qualsiasi blocco di memoria ed eseguirci del codice, un ulteriore aspetto da tener in considerazione riguarda invece il codice che si automodifica. Ho già detto che possiamo scrivere anche nelle pagine che contengono codice, ma questo può essere fatto solo ad una condizione, e cioè che queste pagine non siano protette da scrittura (se ci troviamo a ring 0 nemmeno questo aspetto ci interesserà più e quindi potremo fare ciò che vogliamo! Viva la libertà!). Per ottenere questa informazione basta chiamare un'apposita Api, VirtualQuery, che ci darà alcune informazioni sulla pagina tra cui il tipo protezione (Lettura o Lettura/scrittura). Per modificare invece il tipo di protezione basta solo usare VirtualProtect ed il gioco è fatto! Si potrà ora scrivere nella pagina scelta.
Win32, logicamente, non può usare solo il modello FLAT; infatti, a ring 0 si possono utilizzare i selettori (o meglio è solo a ring 0 che normalmente si usano!). In win 9x ogni VM (Virtual Machine) ha una propria LDT e la usa per accedere alla memoria mentre le applicazioni utente usano praticamente la stessa LDT. In Nt invece ogni processo ha la sua LDT. Proprio su questa differenza si basa un metodo per riconoscere Nt da win9x, infatti i selettori delle appz (a ring 3) win9x usano la LDT mentre quelle Nt usano la GDT. Nella LDT, ci sono segmenti a 32 o a 16 bit, questo è dovuto al fatto che Win9x ed Nt possono eseguire codice a 16 bit. In realtà i segmenti non servono a molto per gestire le applicazioni win 32 anche da ring 0, essi sono invece importanti nelle applicazioni a 16 bits. Un'ultima nota sui selettori: la LDT e la GDT in win 9x non sono protetti quindi si può benissimo scriverci sù ;), la cosa (logicamente) non è vera per Nt (e per tutti i sistemi operativi creati con una certa logica in mente!).
Naturalmente, anche a ring 0 la paginazione è abilitata, perciò creare l'indirizzo fisico è un pò più complesso. A ring 0 è possibile allocare una pagina, e generalmente è la pagina l'unità di allocazione base. Infatti alcuni servizi VMM richiedono un numero di pagina come argomento, questo altro non è che la combinazione di DIR:TABLE dell'indirizzo lineare. Per esempio se abbiamo un indirizzo lineare possiamo trovare il numero di pagina corispondente, semplicemente con l'istruzione: shr linear_address,12 (la DIR:TABLE è shiftata a destra nella posizione dello spiazzamento).
Il meccanismo della paginazione implementato nel 386+ è uno strumento molto comodo per la gestione della memoria virtuale e quindi windows lo sfrutta a pieno. Infatti un indirizzo lineare, come ho già detto, mi indentifica una pagina e uno spiazzamento nella pagina, ma nessuno ci assicura che la pagina sia allocata o che sia in memoria. Se si prova ad accedere ad una pagina non presente si verifica un'eccezione. Il gestore dell'interruzione (che in questo caso è la parte del kernel di winzoz che implementa la memoria virtuale) controlla se la pagina sia presente nel file di swap ed in questo caso rialloca la pagina in memoria e fa in modo di rieseguire l'istruzione che ha causato l'eccezione. Nel caso, invece, in cui la pagina non sia presente nel file di swap, windows visualizza la magica finestra "Errore di pagina non valida" (o qualcosa di simile non mi ricordo bene!). Capirete quindi come sia facile implementare la gestione della memoria virtuale da parte di winzoz (grazie Intel almeno per questo!).
Un'altra caratteristica importante della paginazione è che elimina in parte il problema della deframmentazione della memoria. Infatti una serie di indirizzi lineari contigui (che indirizzano più di una pagina) non è detto che facciano riferimento ad aree di memoria fisicamente contigue. Ciò permette alle applicazioni di allocare blocchi di memoria molto grandi e trattarli come se fossero costituiti da uno spazio fisico contiguo (anche se ciò la maggior parte delle volte non è vero!). Bisogna comunque sapere che se la paginazione risolve il problema della frammentazione della memoria fisica, il problema più generico della "frammentazione" in generale rimane. Infatti si deve stare attenti alla frammentazione degli indirizzi lineari. Cioè può succedere che gli indirizzi lineari contigui utilizzabili nello spazio di indirizzamento di un processo finiscano. Per ovviare a questo problema Win32 mette a disposizione una serie di flags da utilizzare nelle Api per l'allocazione della memoria. In pratica una volta allocato il blocco ti viene restituito un handle e non un indirizzo lineare. Prima di accedere al blocco lo devi "bloccare" (cioè chiami l'api GlobalLock che ti retituisce l'indirizzo lineare) quindi a fine accesso lo devi "sbloccare" (api GlobalUnlock), facendo quindi uso di questi handle invece che di indirizzi lineari, lo spazio degli indirizzi lineari verrà automaticamente deframmentato da windows; per maggiori informazioni vedere l'Api GlobalAlloc con il flag GHND.
Ritornado a noi, cerchiamo ora di capire cosa si intende per address space (spazio di indirizzamento) di un processo. Sicuramente avrete sentito parlare di Address Space! In effetti è una delle caratteristiche di win 32 ed sostanzialmente consiste nell'assegnare ad ogni processo un proprio spazio di indirizzamento. Cioè l'indirizzo 400000 di un processo A punterà ad un'area di memoria fisica diversa dall'indirizzo 400000 di un processo B. Questo permette ad ogni processo di accedere solo alle regioni di memoria private ed a quelle che il sistema operativo decide di "concedergli". Bene ora spieghiamo come il S.O. implementa questa caratteristica :P
Sappiamo che la memoria è divisa in pagine, che queste pagine sono specificate dalle tabelle di pagine e che la posizione in memoria di queste tabelle è specificata nel registro CR3. Sicuramente già sapete che quando c'è un context switch (il passaggio da un processo ad un'altro) il processore legge le informazioni per avviare il nuovo processo dal TSS (Task State Segment) dove oltre alle normali informazioni sui registri ci sono i campi che specificano la nuova LDT (che andrà in LDTR) e la nuova tabella di pagine di 1° livello (che andrà in CR3), quindi potenzialmente al passaggio tra un processo ed un'altro si avrà un cambio delle tabelle di pagine e della LDT. In win32 le LDT cambiano solo tra le diverse Virtual Machine (in Nt anche tra i diversi processi), invece le tabelle di pagine cambiano sempre. Bisogna ora porci una domanda:in un context switch si possono cambiare le tabelle di pagine in modo da indirizzare memoria fisica completamente differente da quella del processo precedente? Beh, in teoria sì in pratica no, infatti ci sono alcune aree di memoria che devono essere comunque comuni a tutti i processi, come ad esempio la GDT che per definizione è comune a tutti i processi. Ed infatti anche in win32 alcune parti di memoria sono condivise tra processi, in particolare il Kernel è condiviso tra tutti i processi. Ogni processo avrà quindi una serie di indirizzi lineari a disposizione per se stesso (indirizzi che punteranno ad altre aree fisiche in altri processi) ed in più avrà a disposizione anche un'altra serie di indirizzi lineari, come quelli riferiti al Kernel, che saranno uguali per qualunque processo. Questo significa che ci sono delle tabelle di pagine di secondo livello che verranno usate in ogni processo (ricordate? esse sono indirizzate tramite la tabella di primo livello), mentre altre invece saranno uniche per ogni processo.
In particolare in win 9x è documentata la diversificazione degli indirizzi lineari:
00000000H - 003fffffH usato dalle VM a livello DOS
00400000H - 7fffffffH l'area privata di un processo, corrisponde all'address space privato
80000000H - 0bfffffffH usata per codice e dati condivisi, appz 16 bits DPMI data etc.
0c0000000H - 0ffbfffffH usata per codice e dati per le VM
E' evidente quindi che la massima memoria privata allocabile dal processo è circa 2 GB. Inoltre l'esistenza dell'area shared (80000000H - 0bfffffffH) viene utilizzata da windows per la condivisione di aree di memoria tra i vari processi. In particolare i memory mapped file vengono creati proprio in quest'area e ciò ha come conseguenza il fatto che l'indirizzo lineare restituito dall'Api MapVieOfFile è lo stesso per tutti i processi ;).
In NT il discorso è un po' diverso. Prima della service pack 3 in NT 4.0 le applicazioni utente (user mode) avevano a disposizione gli indirizzi lineari da 0 a 2 GB mentre i programmi di sistema (kernel mode) avevano a disposizione gli indirizzi da 2 a 4 GB. Dall'introduzione del service pack 3 i programmi di sistema hanno a disposizione gli indirizzi lineari da 3 a 4 GB, con una conseguente diminuzione dello spazio di indirizzamento di un GB per i programmi in kernel mode, ed un'aumento di un GB invece per le applicazioni utente. WinNt non ha aree shared, quindi per condividere blocchi di memoria tra più processi lavora in maniera diversa da win9x, in particolare egli tiene traccia delle pagine che devono essere condivise e le mappa nello spazio di indirizzamento di tutti processi che le utilizzano. In altre parole i blocchi di memoria pubblici saranno mappati per ogni processo che ne fà richiesta ed avranno in genere indirizzi lineare diversi.
Un'ulteriore aspetto sulla gestione della memoria di win 32 è il copy-on-write. Quando due o più processi condividono un'area di memoria ed uno di questi la modifica, il copy-on-write entra in gioco creando una copia privata del blocco al processo che lo ha modificato. Tutte le modifiche quindi influenzeranno da ora in poi la copia privata e solo la copia non modificata sarà condivisa con gli altri processi. Questo è in generale come funziona in copy-on-write in molti sistemi operativi, vediamolo ora in particolare come è stato implementato in Nt e win 9x.
In Nt, la cosa funziona così: le pagine dati scrivibili sono inizializzate dal sistema operativo come a sola lettura. Quando un processo proverà a scriverci ci sarà un page faults (si verifica quando si prova a scrivere ad pagina a sola lettura da ring 3) ed il S.O. creerà quindi un copia privata della pagina per il processo, la mapperà nell'address space del processo stesso (con l'attributo di lettura/scrittura) e quindi rieseguirà l'istruzione che ha provocato il page fault. In questo modo se una nuova istanza del processo viene eseguita essa condividerà solo le pagine che hanno l'attributo a sola lettura (quelle che non sono state modificate) con l'istanza precedente. Se poi quest'ultima istanza proverà a scrivere su queste pagine, scatterà di nuovo il copy-on-write. Questo è un meccanismo molto importante per il S.O. e gli permette infatti di condividere dati tra più processi senza eccessivi problemi e nello stesso tempo di copiare solo le pagine effettivamente modificate dai processi stessi ottimizzando così l'uso della memoria e la velocità di esecuzione.
In win 9x il copy-on-write non è implementato ma è in certo senso emulato tramite l'Api WriteProcessMemory. Cioè se si prova a scrivere una pagina tramite WriteProcessMemory scatterà il copy-on-write come già descritto (copia privata della pagina, rimapping etc..). Sfortunatamente WriteProcessMemory non permette di scrivere nell'area shared di win 9x e quindi non è utilizzabile il copy-on-write di dati in quell'area (memory mapped files, shared dlls etc). Un'altro metodo di emulare il copy-on-write in win 9x consiste nell'utilizzare i memory mapped file creandoli con il parametro PAGE_WRITECOPY, con conseguente meccanismo del copy-on-write nel caso che un processo provi a scrivere nell'area di memoria del m.m.f. stesso.
Abbiamo ora finito la discussione generale su come è gestita la memoria negli ambienti win 32 progettati per processori Intel (gran parte del discorso è comunque applicabile anche alla versione di Nt per processori Alpha), tra breve presenterò anche alcuni esempi di programmi a ring 0 (per win 9x la maggior parte... e forse qualcuno per Nt) che usano le funzioni base per la manipolazione delle pagine (almeno spero!). Spero che la discussione vi possa essere stata utile :D
!!!! Fine !!!!
Soliti saluti:
Saluto tutti i membri del gruppo RingZerO e tutti gli amici di #crack-it ed in particolare (per l'occasione di questo tut):
Kill3xx: per avermi dato alcuni suggerimenti per il tut e per averlo riletto dopo ogni modifica... dovrebbero darti il premio nobel per la pazienza :-)
NeuRal_NoiSE: per averlo letto e per averne dato un giudizio positivo anche se a domanda specifica se l'è cavata con un "domani lo leggo meglio" :P
D4eMoN: per aver avuto la pazienza di leggerlo..ma non ho avuto un suo giudizio :D...cmq il tuo cz di crackme lo potevi fà un pò + umano ...no???!!! :P
Insanity: detto anche l'uomo con il saluto + veloce del west..:-D per aver pubblicato il documento fidandosi di ciò che ho scritto ;-)