Unul dintre cele mai interesante și mai frecvent utilizate concepte din arhitectura x86 este modul protejat și suportul său în 4 moduri(aka ringuri):
A fost o idee greu de înțeles și voi încerca să o explic cât mai clar posibil în această postare. Vom acoperi următoarele concepte:
GDT, LDT, IDT.
Traducerea memoriei virtuale.
ASLR și Kernel ASLR (KASLR).
Să începem cu elementele de bază, orice calculator are cel puțin (sperăm) următoarele componente: CPU, Disk și RAM. Fiecare dintre aceste componente deține un rol cheie în fluxul sistemului. CPU-ul execută comenzile și operațiile pe memorie (RAM), RAM-ul deține datele pe care le folosim și permite accesul rapid și fiabil la acestea, discul deține datele persistente de care avem nevoie pentru a exista chiar și după repornire sau oprire. Pornesc de aici pentru că, deși este foarte elementar, este important să rețineți acest lucru și, pe măsură ce citiți acest articol, să vă întrebați despre ce componentă vorbim în acel moment.
Sistemul de operare este softul care orchestrează totul și, de asemenea, cel care permite o interfață rapidă, comodă, coerentă și eficientă pentru a accesa toate capacitățile sale – unele dintre acestea fiind accesul la acel hardware, iar altele pentru a spori confortul și performanța.
Ca orice soft bun, sistemul de operare funcționează în straturi, kernelul este primul strat și – în opinia mea – cel mai important. Pentru a înțelege importanța nucleului trebuie mai întâi să înțelegem acțiunile sale și provocările cu care se confruntă, așa că haideți să ne uităm la câteva dintre responsabilitățile sale:
Gestionează apelurile de sistem (aceeași interfață despre care am vorbit).
Alocarea resurselor (RAM, CPU și multe altele) proceselor/thread-urilor în cauză.
Securizează operațiile efectuate.
Intermediază între hardware și software.
Multe dintre aceste acțiuni realizate cu ajutorul generos al procesorului, în cazul x86, modul protejat este modul care ne permite să limităm puterea (set de instrucțiuni) contextului de execuție în curs de execuție.
Să presupunem că avem două lumi – lumea utilizatorului și lumea supervizorului. La un moment dat vă puteți afla doar într-una din aceste lumi. Când vă aflați în lumea utilizatorului, vedeți lumea așa cum vrea supraveghetorul să o vedeți. Să vedem ce vreau să spun prin asta:
Să spunem că sunteți un proces. Un proces este un container de unul sau mai multe fire de execuție. Un thread este un context de execuție, este unitatea logică din care se execută instrucțiunile mașinii. Aceasta înseamnă că atunci când firul de execuție execută, să zicem, citirea de la adresa de memorie 0x8080808080, se referă efectiv la adresa virtuală 0x808080808080 a procesului curent. După cum puteți ghici, conținutul adresei va fi diferit între două procese. Acum, spațiul virtual de adrese se află la nivelul procesului, ceea ce înseamnă că toate firele de execuție ale aceluiași proces au același spațiu de adrese și pot accesa aceeași memorie virtuală. Pentru a da un exemplu de resursă care se află la nivel de fir de execuție, să folosim faimoasa stivă.
Am un fir de execuție care execută următorul cod:
Funcția noastră execută funcția principală care va apela funcția noastră „func”. Să presupunem că întrerupem firul la linia 9. Dispunerea stivei va fi următoarea:
variabila_a.
parametru.
adresa de retur – adresa liniei 20.
variabila_b.
Pentru a ilustra:
În codul dat creăm 3 fire de execuție pentru procesul nostru și fiecare dintre ele își tipărește id-ul, segmentul de stivă și pointerul de stivă.
O posibilă ieșire a acestui program este:
După cum puteți vedea, toate firele au avut același segment de stivă deoarece au același spațiu de adrese virtuale. pointerul de stivă pentru fiecare este diferit pentru că fiecare are propria stivă în care își stochează valorile.
Nota laterală despre segmentul de stivă – voi explica mai multe despre registrele de segment în secțiunea GDT/LDT – deocamdată credeți-mă pe cuvânt.
De ce este important acest lucru? La un moment dat, procesorul poate îngheța firul de execuție și poate da controlul oricărui alt fir pe care îl dorește. Ca parte a nucleului, planificatorul este cel care alocă CPU-ul firelor existente în acel moment (și „pregătite”). Pentru ca firele să poată rula în mod fiabil și eficient este esențial ca fiecare să aibă propria stivă în care să poată salva valorile sale relevante (variabile locale și adrese de întoarcere, de exemplu).
Pentru a-și gestiona firele, sistemul de operare păstrează o structură specială pentru fiecare fir numită TCB (Thread Control Block), în această structură se salvează – printre altele – contextul acelui fir și starea sa (în curs de execuție / pregătit / etc…). Contextul conține – din nou – printre altele, valorile registrelor CPU:
EBP -> Adresa de bază a stivei, fiecare funcție folosește această adresă ca adresă de bază de la care se decalează pentru a accesa variabilele și parametrii locali.
ESP -> Pointerul curent la ultima valoare (prima la pop) de pe stivă.
Registre de uz general -> EAX, EBX, etc…
Registrul Flags.
C3 -> conține locația directorului de pagină (va fi discutat mai târziu).
EIP – Următoarea instrucțiune care urmează să fie executată.
În afară de firele de execuție, sistemul de operare trebuie să țină evidența după o mulțime de alte lucruri, inclusiv procese. Pentru procese, sistemul de operare salvează structura PCB (Process Control Block), am spus că pentru fiecare proces există un spațiu de adrese izolat. Deocamdată, să presupunem că există un tabel care mapează fiecare adresă virtuală la o adresă fizică și că acest tabel este salvat în PCB, sistemul de operare fiind responsabil de actualizarea acestui tabel și de menținerea lui la starea corectă a memoriei fizice. De fiecare dată când planificatorul comută execuția către un anumit fir de execuție, tabelul salvat pentru procesul proprietar al acelui fir de execuție este aplicat la CPU, astfel încât acesta să poată traduce corect adresele virtuale.
Suficient pentru concepte, haideți să înțelegem cum se face de fapt. Pentru asta să privim lumea din perspectiva procesorului:
Global Descriptor Table
Știm cu toții că procesorul are registre care îl ajută să facă calcule, unele registre mai mult decât altele (;)). Prin proiectare, x86 suportă mai multe moduri, dar cele mai importante sunt utilizator și supravegheat, procesorul are un registru special numit gdtr (Global Descriptor Table Register) care conține adresa unui tabel foarte important. acest tabel mapează fiecare adresă virtuală cu modul corespunzător procesorului, conține și permisiunile pentru acea adresă (READ | WRITE | EXECUTE). evident, acest registru poate fi modificat numai din modul supraveghetor. Ca parte a execuției procesorului, acesta verifică ce instrucțiune trebuie să execute în continuare (și la ce adresă se află), verifică acea adresă în raport cu GDT și astfel știe dacă este o instrucțiune validă pe baza modului dorit (se potrivește modul curent al procesorului cu modul din GDT) și a permisiunilor (dacă nu este executabilă – invalidă). Un exemplu este „lgdtr”, instrucțiunea care încarcă o valoare în registrul gdtr și care poate fi executată numai din modul supravegheat, după cum se menționează. Punctul cheie care trebuie subliniat aici este că orice protecție asupra operațiilor de memorie (executarea instrucțiunii / scrierea într-o locație invalidă / citirea dintr-o locație invalidă) este realizată de GDT și LDT (care urmează) la nivelul procesorului, utilizând aceste structuri care au fost construite de sistemul de operare.
Acesta este modul în care arată conținutul unei intrări în GDT / LDT: