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:

  1. variabila_a.
  2. parametru.
  3. adresa de retur – adresa liniei 20.
  4. 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:

http://wiki.osdev.org/Global_Descriptor_Table

După cum puteți vedea, are intervalul de adrese la care este relevantă intrarea, și are atributele (permisiuni) așa cum v-ați aștepta.

Local Descriptor Table

Tot ce am spus despre GDT este valabil și pentru LDT cu mici (dar mari) diferențe. După cum sugerează și numele său, GDT se aplică la nivel global pe sistem, în timp ce LDT se aplică la nivel local, ce vreau să spun prin global și local? GDT ține evidența permisiunilor pentru toate procesele, pentru fiecare fir de execuție și nu se schimbă între schimbările de context, în timp ce LDT, pe de altă parte, da. Este logic că, dacă fiecare proces are propriul spațiu de adrese, este posibil ca pentru un proces adresa 0x10000000 să fie executabilă, iar pentru altul să fie doar de citire/scriere. Acest lucru este valabil în special în cazul în care ASLR este activat (va fi discutat mai târziu). LDT este responsabil pentru a păstra permisiunile care disting fiecare proces în parte.

Un lucru de reținut este că tot ceea ce s-a spus este scopul structurii, dar în realitate unele sisteme de operare ar putea sau nu să folosească deloc o parte din structură. de exemplu este posibil să se folosească doar GDT și să se schimbe între comutările de context și să nu se folosească niciodată LDT. Totul face parte din proiectarea sistemului de operare și din compromisuri. Intrările din acest tabel arată similar cu cel al GDT.

Selectori

Cum știe procesorul unde să caute în GDT sau LDT atunci când execută o anumită instrucțiune? Procesorul are registre speciale care se numesc registre de segment:

https://en.wikibooks.org/wiki/X86_Assembly/X86_Architecture

- Stack Segment (SS). Pointer to the stack. - Code Segment (CS). Pointer to the code. - Data Segment (DS). Pointer to the data. - Extra Segment (ES). Pointer to extra data ('E' stands for 'Extra'). - F Segment (FS). Pointer to more extra data ('F' comes after 'E'). - G Segment (GS). Pointer to still more extra data ('G' comes after 'F').

Care registru are o lungime de 16 biți și structura sa este următoarea:

http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm

Atunci avem indexul GDT/LDT, avem de asemenea bitul care spune dacă este LDT sau GDT și care este modul în care trebuie să fie (RPL 0 este supervizor, 4 este utilizator).

Interrupt Descriptor Table

Pe lângă GDT și LDT avem și IDT (Interrupt Descriptor Table), IDT este pur și simplu un tabel care conține adresele unor funcții foarte importante, unele dintre ele aparțin sistemului de operare, altele driverelor și dispozitivelor fizice conectate la PC. Ca și gdtr avem idtr care, după cum probabil ați ghicit, este registrul care conține adresa IDT. Ce îl face pe IDT atât de special? Atunci când inițiem o întrerupere, procesorul trece automat în modul supravegheat, ceea ce înseamnă că fiecare funcție din interiorul IDT rulează în modul supravegheat. Fiecare fir din fiecare mod poate declanșa o întrerupere prin emiterea instrucțiunii „int” urmată de un număr care indică CPU-ului pe ce index se află funcția țintă. Acestea fiind spuse, este acum evident că fiecare funcție din interiorul IDT este o potențială poartă de acces în modul supravegheat.

Acum știm că avem GDT/LDT care îi spune CPU-ului permisiunile pentru fiecare adresă virtuală și avem IDT-ul care indică funcțiile ‘poartă de acces’ către iubitul nostru kernel (care, evident, rezidă în interiorul secțiunii supravegheate a memoriei). Cum se comportă aceste structuri într-un sistem care rulează?

Memorie virtuală

Înainte de a înțelege cum joacă toate acestea împreună, trebuie să mai acoperim un concept – memoria virtuală. Vă amintiți când am spus că există un tabel care mapează fiecare adresă de memorie virtuală cu cea fizică? De fapt, este un pic mai complicat decât atât. În primul rând, nu putem pur și simplu să cartografiem fiecare adresă virtuală, deoarece acest lucru va ocupa mai mult spațiu decât avem de fapt și, lăsând la o parte necesitatea de a fi eficienți, sistemul de operare poate, de asemenea, să schimbe pagini de memorie pe disc (pentru eficiență și performanță), este posibil ca pagina de memorie a adresei virtuale necesare să nu se afle în memorie în acest moment, astfel încât, pe lângă traducerea adresei virtuale în cea fizică, trebuie să salvăm dacă memoria se află în RAM și, dacă nu, unde se află (ar putea exista mai mult de un fișier de pagină). MMU (Memory Management Unit) este componenta responsabilă de traducerea memoriei virtuale în una fizică.

Un lucru foarte important de înțeles este că fiecare instrucțiune din fiecare mod trece prin procesul de traducere a adresei virtuale, chiar și codul în modul supravegheat. Odată ce procesorul se află în modul protejat, fiecare instrucțiune pe care o execută folosește adresa virtuală – niciodată fizică (există unele trucuri care fac ca adresa virtuală reală să se traducă întotdeauna în exact aceeași memorie virtuală, dar acest lucru iese din sfera de aplicare a acestei postări).

Atunci, odată intrat în modul protejat, cum știe procesorul unde să se uite atunci când trebuie să traducă adresa virtuală? răspunsul este registrul CR3, acest registru deține adresa către structura care conține informațiile necesare – directorul de pagină. Valoarea lui se schimbă în funcție de procesul care rulează în acel moment (din nou, spațiu de adrese virtuale diferit).

Atunci cum arată acest director de pagină? În ceea ce privește eficiența, trebuie să putem interoga acest „tabel” cât mai repede posibil, de asemenea, trebuie să fie cât mai mic posibil, deoarece acest tabel va fi creat pentru fiecare proces. Soluția la această problemă nu este mai puțin decât genială. Cea mai bună imagine pe care am putut-o găsi pentru a ilustra procesul de traducere este aceasta (de pe wikipedia):

MMU are 2 intrări, adresa virtuală de tradus și CR3 (adresa către directorul paginii relevante în acel moment). Specificația x86 taie adresa virtuală în 3 bucăți:

  • Număr de 10 biți – indexul către directorul de pagini.
  • Număr de 10 biți – indexul către tabelul de pagini.
  • Număr de 12 biți – decalajul către adresa fizică propriu-zisă.

Deci procesorul ia primul număr de 10 biți și îl folosește ca index pentru directorul de pagini, pentru fiecare intrare în directorul de pagini avem tabelul de pagini, care apoi procesorul folosește următorul număr de 10 biți ca index. Fiecare intrare din tabelul de directoare indică o pagină de memorie limită de 4K, iar ultimul decalaj de 12 biți din adresa virtuală este utilizat pentru a indica locația exactă în memoria fizică. Această soluție este strălucită prin:

  • Flexibilitatea cu care fiecare adresă virtuală poate fi localizată la o adresă fizică fără nicio legătură între ele.
  • Eficiența spațială a structurilor implicate este uimitoare.
  • Nu fiecare intrare din fiecare tabel este utilizată, ci doar adresele virtuale care sunt efectiv utilizate și mapate de proces există în tabele.

Îmi pare sincer rău că nu explic acest proces în mai multe detalii, acesta este un proces bine documentat pe care mulți oameni au muncit din greu pentru a-l explica mai bine decât aș putea eu să o fac vreodată – căutați pe Google.

Kernel vs. utilizator

Aici devine interesant (și magic, dacă îmi permiteți).

Am început acest articol afirmând că sistemul de operare orchestrează totul, face acest lucru folosind kernel-ul. După cum s-a spus deja, nucleul rulează într-o secțiune de memorie care este mapată ca mod supravegheat doar în GDT pentru toate procesele. Da, știu că fiecare proces are propriul spațiu de adrese, dar kernelul taie acel spațiu de adrese (de obicei jumătatea superioară, depinde de sistemul de operare) pentru uz personal, nu numai că taie spațiul de adrese, ci și la aceeași adresă pentru toate procesele. Acest lucru este important deoarece codul nucleului este fix și fiecare referință la variabile și structuri trebuie să fie la aceeași locație pentru toate procesele. Puteți privi nucleul ca pe o bibliotecă specială încărcată pentru fiecare proces în aceeași locație.

Mai adânc în întreruperi

Știm că IDT-ul conține adrese de funcții, aceste funcții numite ISR (Interrupt Service Routine), unele se execută atunci când apare un eveniment hardware (apăsarea unei taste pe tastatură) și altele atunci când software-ul inițiază întreruperea, de exemplu pentru a trece în modul kernel.

Windows are un concept interesant despre întreruperi și prioritizarea lor: O întrerupere deosebit de importantă este cea a ticăitului ceasului. La fiecare tic-tac al ceasului există o întrerupere care este gestionată de ISR-ul său. Planificatorul sistemului de operare folosește acest eveniment al ceasului pentru a controla cât timp rulează fiecare proces și dacă este sau nu rândul altuia. După cum puteți înțelege, această întrerupere este foarte importantă și trebuie să fie servită imediat ce are loc, dar nu toate ISR-urile au aceeași importanță și aici intervine prioritatea între întreruperi. Să luăm ca exemplu apăsarea unei taste de pe tastatură și să presupunem că aceasta are prioritatea 1. Tocmai am apăsat o tastă de pe tastatură și ISR-ul acesteia se execută, în timp ce se execută ISR-ul tastaturii, toate întreruperile cu aceeași prioritate sau mai mici sunt ignorate. În timp ce se execută ISR-ul, ISR-ul ceasului este declanșat cu prioritatea 2 (motiv pentru care nu a fost dezactivat), are loc o comutare imediată către ISR-ul ceasului, iar odată ce ceasul se termină, acesta returnează controlul către ISR-ul tastaturii de unde s-a oprit. Prioritățile acestor întreruperi se numesc IRQL (Interrupt ReQuest Level), pe măsură ce IRQL-ul întreruperii crește, prioritatea acesteia este mai mare. Întreruperile cu cea mai mare prioritate nu sunt niciodată întreruperi la mijloc, ele rulează până la sfârșit, întotdeauna. IRQLs este specific Windows – IRQL este un număr între 0-31, pentru Linux, pe de altă parte, nu există, Linux gestionează fiecare întrerupere cu aceeași prioritate și pur și simplu dezactivează toate întreruperile atunci când are cu adevărat nevoie ca acea rutină specifică să nu fie deranjată. După cum puteți vedea, totul este o chestiune de design și preferințe.

Să conectăm totul la iubitul nostru mod utilizator . ISR-ul acelui eveniment de ceas se va executa indiferent de firul care rulează în prezent și ar putea chiar să întrerupă la un alt ISR pentru o sarcină fără legătură. acesta este un exemplu perfect pentru motivul pentru care nucleul este la aceeași adresă pentru toate procesele nu vrem să schimbăm GDT și Page Directory (în C3) de fiecare dată când executăm o întrerupere, deoarece se întâmplă de MULTE ori chiar și în timpul unei singure funcții a oricărui proces dat din modul utilizator. Multe se întâmplă între acele linii de cod pe care le scrieți atunci când vă dezvoltați aplicația în modul utilizator (;)).

O altă modalitate de a privi întreruperile este ca intrări externe și independente pentru sistemul nostru de operare, această definiție nu este exactă (nu toate întreruperile sunt externe sau independente), dar este bine de subliniat, o mare parte din sarcina nucleului este de a da sens evenimentelor care apar tot timpul din orice locație (dispozitive de intrare) și dintr-o parte să servească aceste evenimente și din cealaltă să se asigure că totul este corelat corect.

Așa că pentru a da un sens la toate acestea, să începem cu o aplicație simplă în modul utilizator care execută următoarea instrucțiune:

0x0000051d push ebp;

Pentru fiecare instrucțiune pe care o execută CPU examinează mai întâi adresa acelei instrucțiuni (în acest caz ‘0x0000051d’) în raport cu GDT/LDT folosind registrul segmentului de cod (‘cs’ pentru că este vorba de o instrucțiune de executat) pentru a ști indexul pe care trebuie să-l caute în tabel (amintiți-vă că registrul segmentului îi spune CPU-ului exact unde să caute). Odată ce procesorul știe că instrucțiunea se află în locația executabilă și că ne aflăm în inelul corect (modul utilizator/modul kernel), acesta continuă să execute instrucțiunea. În acest caz, instrucțiunea „push ebp” nu afectează doar registrul, ci și stiva programului (împinge stiva cu conținutul ebp), astfel încât CPU verifică, de asemenea, în GDT/LDT adresa din registrul esp (adresa locației curente din stivă și, deoarece este vorba de locația stivei, CPU știe că trebuie să utilizeze registrul de segment al stivei pentru a face acest lucru), pentru a se asigura că se poate scrie în acel inel specific. Rețineți că dacă această instrucțiune ar fi fost și de citire din memorie, CPU ar fi trebuit să verifice și adresa respectivă pentru accesul la citire.

Aceasta nu este tot, după ce CPU a verificat toate aspectele de securitate este nevoie acum să acceseze și să manipuleze memoria, după cum vă amintiți adresele sunt în formatul lor virtual. MMU traduce acum fiecare memorie virtuală specificată de instrucțiune într-o adresă de memorie fizică folosind registrul CR3 care indică directorul de pagini (care indică tabelul de pagini) care ne permite să traducem în cele din urmă adresa în una fizică. Rețineți că adresa ar putea să nu se afle în memorie în momentul în care aveți nevoie de ea; în acest caz, sistemul de operare va genera o eroare de pagină (o excepție care generează o întrerupere) și va aduce datele în memoria fizică pentru noi, după care va continua execuția (acest lucru este transparent pentru aplicația din modul utilizator).

De la utilizator la kernel

Care schimb între modul utilizator și modul kernel are loc cu ajutorul IDT. Din aplicația în modul utilizator, instrucțiunea „int <num>” transferă execuția către funcția din IDT la indexul num. Când execuția este în modul kernel se schimbă o mulțime de reguli, fiecare fir de execuție are stive diferite pentru modul utilizator și modul kernel, verificările accesului la memorie sunt mult mai complicate și obligatorii, în modul kernel sunt foarte puține lucruri pe care nu le poți face și foarte multe pe care le poți încălca.

ASLR și KASLR

de cele mai multe ori este „doar” lipsa de cunoștințe care ne împiedică să realizăm imposibilul.

ASLR (Address Space Layout Randomization) este un concept care se implementează diferit în cadrul fiecărui sistem de operare, conceptul este de a randomiza adresele virtuale ale proceselor și ale bibliotecilor încărcate de acestea.

Înainte de a intra în subiect am vrut să menționez că am decis să includ ASLR în această postare pentru că este o modalitate frumoasă de a vedea cum modul protejat și structurile sale au permis acest tip de capabilitate, chiar dacă nu este cel care l-a implementat sau responsabil pentru el.

De ce ASLR?

De ce este simplu, pentru a preveni atacurile. Când cineva reușește să injecteze cod într-un proces în execuție, necunoașterea adreselor unor funcții benefice este ceea ce poate face ca atacul să eșueze.

Avem deja spații de adrese diferite pentru fiecare proces, asta înseamnă că fără ASLR toate procesele ar avea aceleași adrese de bază, asta pentru că atunci când fiecare proces în propriul său spațiu virtual de adrese nu trebuie să ne ferim de coliziuni între procese. Atunci când legăm programul, linkerul alege o adresă de bază fixă la care să lege executabilul. Pe hârtie, toate fișierele executabile legate de același linker cu parametrii impliciți (adresa de bază poate fi configurată dacă este necesar) vor avea aceeași adresă de bază. Pentru a da un exemplu, am scris două aplicații, una numită „1.exe” și a doua „2.exe”.exe”, ambele sunt proiecte diferite în Visual Studio și totuși ambele au aceeași adresă de bază (am folosit exeinfo PE pentru a verifica adresa de bază din fișierul PE):

Nu numai că aceste două executabile au aceeași adresă de bază, dar ambele nu suportă ASLR (l-am dezactivat):

Puteți vedea, de asemenea, că este inclus în formatul PE la rubrica File Characteristics:

Acum haideți să rulăm ambele executabile în același timp și ambele împart aceeași adresă de bază (voi folosi vmmap de la Sysinternals pentru a vizualiza imaginea de bază):

Potem vedea că ambele procese nu folosesc ASLR și au aceeași adresă de bază de 0x00400000. Dacă am fi fost atacatori și am fi avut acces la acest executabil, am fi putut ști exact ce adrese vor fi disponibile pentru acest proces odată ce am găsit de unde să ne injectăm în execuția lui. să activăm ASLR în executabilul nostru 1.exe și să vedem magia lui:

S-a schimbat!

KASLR (Kernel ASLR) este la fel ca ASLR, doar că funcționează la nivelul kernelului, ceea ce înseamnă că odată ce un atacator a reușit să se injecteze în contextul kernelului, el (sperăm) nu va mai putea ști ce adrese conțin ce structuri (de exemplu unde se află GDT-ul în memorie). Un lucru care trebuie menționat aici este că ASLR își face magia la fiecare lansare a unui nou proces (care suportă ASLR, bineînțeles), în timp ce KASLR o face la fiecare repornire, deoarece acesta este momentul în care nucleul este „lansat”.

Cum funcționează ASLR?

Deci, cum funcționează și ce legătură are cu modul protejat? Cel care este responsabil să implementeze ASLR este încărcătorul. Atunci când un proces este lansat, încărcătorul este cel care trebuie să îl pună în memorie, să creeze structurile relevante și să pornească firul de execuție al acestuia. Încărcătorul verifică mai întâi dacă executabilul suportă ASLR și, în caz afirmativ, randomizează o adresă de bază în intervalul de adrese disponibile (spațiul kernel, de exemplu, nu este disponibil, evident). Pe baza acestei adrese, încărcătorul inițializează acum directorul de pagini pentru procesul respectiv pentru a orienta spațiul de adrese aleatorizat către cel fizic. Flexibilitatea LDT vine, de asemenea, în ajutorul nostru, deoarece încărcătorul creează pur și simplu LDT care corespunde adresei randomizate cu permisiunile relevante. Frumusețea aici este că modul protejat nici măcar nu este conștient de faptul că se folosește ASLR, este suficient de flexibil pentru a nu-i păsa.

Un detaliu interesant de implementare este că în Windows adresa randomizată pentru un executabil specific este fixată din motive de eficiență. Ceea ce vreau să spun prin asta este că dacă am randomizat adresa pentru, să zicem, calc.exe, a doua oară când este executat, adresa de bază va fi aceeași. Așadar, dacă deschid 2 calculatoare în același timp – acestea vor avea aceeași adresă de bază. După ce voi închide ambele calculatoare și le voi deschide din nou, ambele vor avea din nou aceeași adresă, numai că aceasta va fi diferită de adresa calculatoarelor anterioare. De ce nu este eficient acest lucru, vă întrebați? gândiți-vă la DLL-urile utilizate în mod obișnuit. Multe procese le folosesc și dacă adresele lor de bază ar fi diferite pentru fiecare proces în parte, codul lor ar fi, de asemenea, diferit (codul face referire la date folosind această adresă de bază), iar dacă codul este diferit, DLL-ul va trebui încărcat în memorie pentru fiecare proces. În realitate, sistemul de operare încarcă imaginile o singură dată pentru toate procesele care utilizează această imagine. Se economisește spațiu – mult spațiu!

Concluzie

Până acum ar trebui să vă puteți imagina nucleul la lucru și să înțelegeți cum toate structurile cheie ale arhitecturii x86 joacă împreună pentru o imagine mai mare și ne permit să rulăm aplicații posibil periculoase în modul utilizator fără (sau cu puțină) teamă.

Articles

Lasă un răspuns

Adresa ta de email nu va fi publicată.