Az x86 architektúra egyik legérdekesebb és leggyakrabban használt fogalma a Protected mode és annak támogatása 4 módban(aka gyűrűk):

Ez egy kihívást jelentő ötlet, amit nehéz volt megérteni, és megpróbálom a lehető legvilágosabban elmagyarázni ebben a bejegyzésben. A következő fogalmakkal fogunk foglalkozni:

  • GDT, LDT, IDT.
  • Virtuális memória fordítás.
  • ASLR és Kernel ASLR (KASLR).

Kezdjük az alapokkal, minden számítógép rendelkezik legalább (remélhetőleg) a következő összetevőkkel: CPU, lemez és RAM. Mindegyik komponens kulcsszerepet tölt be a rendszer működésében. A CPU végrehajtja a parancsokat és műveleteket a memórián (RAM), a RAM tárolja az általunk használt adatokat, és lehetővé teszi a gyors és megbízható hozzáférést, a lemez tartós adatokat tárol, amelyekre szükségünk van az újraindítás vagy leállítás után is. Ebből indulok ki, mert bár ez a nagyon alapvető, fontos, hogy ezt szem előtt tartsuk, és ahogy végigolvassuk ezt a cikket, kérdezzük meg magunktól, hogy melyik komponensről beszélünk abban a pillanatban.

Az operációs rendszer az a szoftver, amely mindezt megszervezi, és az is, amely lehetővé teszi a gyors, kényelmes, konzisztens és hatékony felületet az összes képesség eléréséhez – amelyek egy része az adott hardverhez való hozzáférés, más része pedig a kényelem és a teljesítmény növelése.

Mint minden jó szoftver, az operációs rendszer is rétegekben működik, a kernel az első réteg, és – véleményem szerint – a legfontosabb. Ahhoz, hogy megértsük a kernel fontosságát, először is meg kell értenünk a tevékenységét és a kihívásokat, amelyekkel szembe kell néznünk, ezért nézzük meg néhány feladatát:

  • A rendszerhívások kezelése (ugyanaz a felület, amelyről beszéltünk).
  • Az erőforrások (RAM, CPU és még sok más) kiosztása a kézben lévő folyamatok/szálak számára.
  • A végrehajtott műveletek biztosítása.
  • Közvetít a hardver és a szoftver között.

Ezek közül sok műveletet a processzor nagylelkű segítségével végez, x86 esetén a Protected mode az a mód, amely lehetővé teszi számunkra, hogy korlátozzuk az éppen futó végrehajtási kontextus teljesítményét (utasításkészletét).

Tegyük fel, hogy két világunk van – a felhasználó világa és a felügyelő világa. Bármikor csak az egyik világban lehetünk. Amikor a felhasználó világában vagy, akkor úgy látod a világot, ahogy a felügyelő szeretné, hogy lásd. Lássuk, mit értek ez alatt:

Tegyük fel, hogy te egy folyamat vagy. Egy folyamat egy vagy több szál konténere. Egy szál egy végrehajtási kontextus, ez az a logikai egység, amelynek a gépi utasításokat végrehajtják. Ez azt jelenti, hogy amikor a szál végrehajtja, mondjuk, a 0x8080808080-as memóriacímről olvas, akkor valójában az aktuális folyamat 0x8080808080-as virtuális címére hivatkozik. Mint sejthető, a cím tartalma két folyamat között eltérő lesz. A virtuális címtartomány folyamatszinten van, ami azt jelenti, hogy ugyanazon folyamat minden szála ugyanahhoz a címtartományhoz tartozik, és ugyanahhoz a virtuális memóriához fér hozzá. Hogy példát adjunk a szálak szintjén lévő erőforrásra, használjuk a híres veremet.

Szóval van egy szálam, amely a következő kódot hajtja végre:

A szálunk végrehajtja a main függvényt, amely meghívja a “func” függvényünket. Tegyük fel, hogy a 9. sorban megszakítjuk a szálat. a verem elrendezése a következő lesz:

  1. változó_a.
  2. paraméter.
  3. visszatérési cím – a 20. sor címe.
  4. változó_b.

Az illusztrációhoz:

A megadott kódban 3 szálat hozunk létre a folyamatunkhoz, és mindegyiknek kiírjuk az azonosítóját, stack szegmensét és stack mutatóját.

Egy lehetséges kimenete ennek a programnak:

Amint látható, minden szálnak ugyanaz volt a veremszegmense, mivel azonos virtuális címterük van. a veremmutató mindegyiknél más, mert mindegyiknek saját veremmel rendelkezik, ahol tárolja az értékeit.

Side note about the stack segment – I will explain about segment registers more in the GDT/LDT section – for now take my word for it.

So why is this important? A processzor bármikor befagyaszthatja a szálat, és átadhatja a vezérlést bármelyik másik szálnak, amit akar. A kernel részeként az ütemező az, amelyik kiosztja a CPU-t az éppen létező (és “kész”) szálaknak. Ahhoz, hogy a szálak megbízhatóan és hatékonyan tudjanak futni, elengedhetetlen, hogy mindegyiknek saját veremmel rendelkezzen, amelybe el tudja menteni a számára fontos értékeket (például a helyi változókat és a visszatérési címeket).

A szálak kezeléséhez az operációs rendszer minden szálhoz külön struktúrát tart fenn, amelyet TCB-nek (Thread Control Block) hívnak, és amelyben – többek között – az adott szál kontextusát és állapotát (futó / kész / stb…) tárolja. A kontextus tartalmazza – ismét – többek között a CPU regiszterek értékeit:

  • EBP -> A verem alapcíme, minden függvény ezt a címet használja alapcímként, ahonnan eltolja a helyi változók és paraméterek eléréséhez.
  • ESP -> Az aktuális mutató a verem utolsó értékére (first to pop).
  • Általános célú regiszterek -> EAX, EBX, stb…
  • Flags regiszter.
  • C3 -> tartalmazza a laptár helyét (később tárgyaljuk).
  • EIP – A következő végrehajtandó utasítás.

A szálakon kívül az operációs rendszernek sok más dolog után is nyomon kell követnie a folyamatokat. A folyamatokhoz az operációs rendszer elmenti a PCB (Process Control Block) struktúrát, azt mondtuk, hogy minden folyamatnak van egy elkülönített címtartománya. Most tegyük fel, hogy van egy táblázat, amely minden virtuális címet leképez egy fizikai címhez, és ez a táblázat a PCB-ben van elmentve, az operációs rendszer felelős a táblázat frissítéséért és a fizikai memória megfelelő állapotának fenntartásáért. Minden alkalommal, amikor az ütemező átkapcsolja a végrehajtást egy adott szálra, az adott szálhoz tartozó folyamathoz elmentett táblázatot alkalmazza a CPU, így képes lesz helyesen lefordítani a virtuális címeket.

Ez elég a fogalmakhoz, értsük meg, hogyan történik ez valójában. Ehhez nézzük meg a világot a processzor szemszögéből:

Global Descriptor Table

Mindannyian tudjuk, hogy a processzornak vannak regiszterei, amelyek segítenek neki a számítások elvégzésében, egyes regiszterek többet, mint mások (;)). Az x86 többféle üzemmódot támogat, de a legfontosabbak a felhasználói és a felügyelt üzemmód, a CPU rendelkezik egy speciális gdtr (Global Descriptor Table Register) nevű regiszterrel, amely egy nagyon fontos táblázat címét tartalmazza. ez a táblázat minden virtuális címet a megfelelő processzormódhoz rendel, és tartalmazza az adott címhez tartozó jogosultságokat is (READ | WRITE | EXECUTE). nyilván ezt a regisztert csak felügyelő módból lehet módosítani. A processzor a végrehajtás részeként ellenőrzi, hogy melyik utasítást kell legközelebb végrehajtani (és milyen címen van), ezt a címet összeveti a GDT-vel, és így tudja, hogy érvényes utasításról van-e szó a kívánt mód (a CPU aktuális módjának és a GDT-ben szereplő módnak való megfelelés) és az engedélyek (ha nem végrehajtható – érvénytelen) alapján. Egy példa erre az ‘lgdtr’ utasítás, amely értéket tölt a gdtr regiszterbe, és csak felügyelt üzemmódból hajtható végre, ahogyan az szerepel. Itt azt kell hangsúlyozni, hogy a memória műveletek (utasítás végrehajtása / érvénytelen helyre való írás / érvénytelen helyről való olvasás) bármilyen védelmét a GDT és az LDT (következik) végzi a processzor szintjén, az operációs rendszer által felépített struktúrák segítségével.

Így néz ki egy GDT / LDT bejegyzés tartalma:

http://wiki.osdev.org/Global_Descriptor_Table

Mint látható, megvan a címtartomány, amelyre a bejegyzés vonatkozik, és az attribútumai (jogosultságai), ahogyan az elvárható.

Local Descriptor Table

Minden, amit a GDT-ről mondtunk, igaz az LDT-re is, kis (de nagy) különbséggel. Ahogy a neve is sugallja a GDT-t globálisan alkalmazzák a rendszerben, míg az LDT-t lokálisan, mit értek globálisan és lokálisan alatt? A GDT nyomon követi az összes folyamat, minden szál engedélyeit, és ez nem változik a kontextusváltások között, az LDT viszont igen. Ennek csak akkor van értelme, ha minden folyamatnak saját címtartománya van, lehetséges, hogy az egyik folyamat számára a 0x10000000 cím futtatható, a másik számára pedig csak olvasható/írható. Ez különösen igaz, ha az ASLR be van kapcsolva (erről később lesz szó). Az LDT felelős az egyes folyamatokat megkülönböztető jogosultságok megtartásáért.

Egy dolog, amit meg kell jegyezni, hogy mindaz, amit mondtunk, a struktúra célja, de a valóságban néhány operációs rendszer a struktúra egy részét egyáltalán nem használja, de lehet, hogy nem is használja. például lehetséges, hogy csak a GDT-t használja, és a kontextusváltások között változtatja, és soha nem használja az LDT-t. Az LDT-t soha nem használja. Ez mind része az OS tervezésének és a kompromisszumoknak. Ennek a táblázatnak a bejegyzései hasonlóan néznek ki, mint a GDT-é.

Selectors

Hogyan tudja a processzor, hogy hol keresse a GDT-ben vagy az LDT-ben, amikor egy adott utasítást végrehajt? A processzornak vannak speciális regiszterei, amelyeket szegmensregisztereknek neveznek:

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').

Minden regiszter 16 bit hosszú, és felépítése a következő:

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

Megvan tehát a GDT/LDT indexe, megvan az a bit is, ami megmondja, hogy LDT vagy GDT, és hogy milyen üzemmódban kell lennie (RPL 0 a supervisor, 4 a user).

Interrupt Descriptor Table

A GDT és az LDT mellett van még az IDT (Interrupt Descriptor Table), az IDT egyszerűen egy olyan táblázat, amely egy nagyon fontos funkció címét tartalmazza, ezek közül néhány az operációs rendszerhez tartozik, mások a meghajtókhoz és a PC-hez csatlakoztatott fizikai eszközökhöz. A gdtr-hez hasonlóan nekünk is van idtr-ünk, ami, ahogy valószínűleg kitaláltad, az IDT címét tartalmazó regiszter. Mitől olyan különleges az IDT? Amikor megszakítást kezdeményezünk, a CPU automatikusan felügyelt üzemmódba kapcsol, ami azt jelenti, hogy az IDT-n belül minden funkció felügyelt üzemmódban fut. Minden szál minden üzemmódból képes megszakítást kiváltani az ‘int’ utasítás kiadásával, amelyet egy szám követ, amely megmondja a CPU-nak, hogy a célfüggvény milyen indexen található. Ezzel együtt most már nyilvánvaló, hogy az IDT-n belül minden függvény egy potenciális átjáró a felügyelt üzemmódba.

Szóval tudjuk, hogy van a GDT/LDT, amely megmondja a CPU-nak az egyes virtuális címekhez tartozó engedélyeket, és van az IDT, amely az “átjáró” függvényeket a mi szeretett kernelünkre irányítja (amely nyilvánvalóan a memória felügyelt részén belül található). Hogyan viselkednek ezek a struktúrák egy futó rendszerben?

Virtuális memória

Mielőtt megértenénk, hogyan játszanak mindezek együtt, még egy fogalommal kell foglalkoznunk – a virtuális memóriával. Emlékszel, amikor azt mondtam, hogy van egy táblázat, amely minden virtuális memóriacímet hozzárendel a fizikai címéhez? Valójában ez egy kicsit bonyolultabb ennél. Először is nem tudunk egyszerűen minden virtuális címet leképezni, mivel ez több helyet foglalna el, mint amennyink valójában van, és a hatékonyság szükségességét félretéve, az operációs rendszer is képes memóriaoldalakat cserélni a lemezre (a hatékonyság és a teljesítmény érdekében), lehetséges, hogy a szükséges virtuális cím memóriaoldala éppen nincs a memóriában, így a virtuális cím fizikai címre való lefordítása mellett azt is el kell mentenünk, hogy a memória a RAM-ban van-e, és ha nem, akkor hol van (több mint egy page file is lehet). Az MMU (Memory Management Unit) az a komponens, amely a virtuális memória fizikaira fordításáért felelős.

Egy dolgot nagyon fontos megérteni, hogy minden utasítás minden üzemmódban átesik a virtuális címfordítás folyamatán, még a felügyelt üzemmódban lévő kód is. Amint a CPU védett módban van, minden végrehajtott utasítás virtuális címet használ – soha nem fizikai címet (van néhány trükk, hogy a tényleges virtuális cím mindig pontosan ugyanabba a virtuális memóriába kerüljön lefordításra, de ez nem tartozik ennek a bejegyzésnek a tárgykörébe).

Hogyan tudja a CPU védett módban, hogy hova kell néznie, amikor virtuális címet kell lefordítania? a válasz a CR3 regiszter, ez a regiszter tartalmazza a szükséges információt tartalmazó struktúra címét – Page directory. Ennek értéke az éppen futó folyamattal változik (ismét más virtuális címtartomány).

Hogyan néz ki ez a Page Directory? Ha a hatékonyságról van szó, akkor ezt a “táblázatot” a lehető leggyorsabban kell lekérdeznünk, és arra is szükségünk van, hogy a lehető legkisebb legyen, mert ez a táblázat minden egyes folyamathoz létre fog jönni. A megoldás erre a problémára nem kevesebb, mint zseniális. A legjobb kép, amit a fordítási folyamat illusztrálására találtam, ez (a wikipédiából):

Az MMU-nak 2 bemenete van, a fordítandó virtuális cím és a CR3 (az aktuálisan releváns oldal könyvtárának címe). Az x86 specifikáció a virtuális címet 3 darabra darabolja:

  • 10 bites szám – index a laptárhoz.
  • 10 bites szám – index a laptáblához.
  • 12 bites szám – offset magához a fizikai címhez.

A processzor tehát az első 10 bites számot veszi, és indexként használja a laptárhoz, a laptár minden egyes bejegyzéséhez van laptáblánk, amit aztán a processzor a következő 10 bites számot használja indexként. Minden egyes címtábla bejegyzés 4K határmemóriaoldalra mutat, amely a virtuális cím utolsó 12 bites eltolódását használja a fizikai cím pontos helyének kijelölésére. Ennek a megoldásnak a zsenialitása:

  • A rugalmasság, hogy minden virtuális cím teljesen független fizikai címre mutat.
  • A résztvevő struktúrák térbeli hatékonysága elképesztő.
  • Nem minden táblázat minden bejegyzését használjuk, csak a folyamat által ténylegesen használt és leképezett virtuális címek vannak a táblázatokban.

Igazán sajnálom, hogy nem magyarázom el részletesebben ezt a folyamatot, ez egy jól dokumentált folyamat, amit sokan keményen dolgoztak, hogy jobban elmagyarázzák, mint ahogy én valaha is tudnám – google-olj rá.

Kernel vs. felhasználó

Itt kezd érdekes lenni (és varázslatos, ha szabad).

A cikket azzal kezdtük, hogy az OS szervezi az egészet, ezt a kernel segítségével teszi. Mint már említettük, a kernel egy olyan memóriarészben fut, amely csak felügyelt módban van leképezve a GDT-ben az összes folyamat számára. Igen, tudom, hogy minden folyamatnak saját címtartománya van, de a kernel ezt a címtartományt (általában a felső felét, az operációs rendszertől függően) saját használatra vágja, nem csak a címtartományt vágja, hanem az összes folyamat számára ugyanazon a címen. Ez azért fontos, mert a kernel kódja fix, és a változókra és struktúrákra való minden hivatkozásnak az összes folyamat számára ugyanazon a helyen kell lennie. A kernelre úgy is tekinthetünk, mint egy speciális könyvtárra, amelyet minden egyes folyamat ugyanarra a helyre tölt be.

Mélyebben a megszakításokba

Tudjuk, hogy az IDT funkciók címeit tartalmazza, ezeket a funkciókat ISR-nek (Interrupt Service Routine) nevezik, néhányuk akkor hajtódik végre, amikor hardveres esemény történik (billentyű lenyomása a billentyűzeten), mások pedig akkor, amikor a szoftver kezdeményezi a megszakítást, például a kernel üzemmódba való váltáshoz.

A Windowsnak van egy jó elképzelése a megszakításokról és azok priorizálásáról: Az egyik különösen fontos megszakítás az óra ketyegése. Minden óra ketyegésénél van egy megszakítás, amit az ISR kezel. Az operációs rendszer ütemezője ezt az órajeles eseményt használja arra, hogy ellenőrizze, mennyi ideig futnak az egyes folyamatok, és hogy sorra kerül-e egy másik vagy sem. Mint érthető, ez a megszakítás szuper fontos, és ki kell szolgálni, amint megtörténik, nem minden ISR-nek van ugyanolyan fontossága, és itt lépnek be a megszakítások közötti prioritások. Vegyük például a billentyűzet billentyű lenyomását, és tegyük fel, hogy az 1-es prioritású, most nyomtam meg egy billentyűt a billentyűzeten, és az ISR végrehajtódik, a billentyűzet ISR-jének végrehajtása közben az összes azonos vagy alacsonyabb prioritású megszakítás figyelmen kívül marad. Az ISR végrehajtása közben az óra ISR-je 2 prioritással lép működésbe (ezért nem tiltotta le), azonnali váltás történik az óra ISR-ére, amint az óra befejezi, visszaadja a vezérlést a billentyűzet ISR-jének, ahonnan megállt. ezek a megszakítások prioritásai IRQL (Interrupt ReQuest Level), ahogy a megszakítás IRQL-je felfelé megy, a prioritás magasabb. A legmagasabb prioritású megszakítások soha nem megszakítások a közepén, mindig a végéig futnak, mindig. Az IRQLs windows specifikus – az IRQL egy 0-31 közötti szám, a Linux esetében viszont nem létezik, a Linux minden megszakítást azonos prioritással kezel, és egyszerűen letiltja az összes megszakítást, ha tényleg szüksége van arra az adott rutinra, hogy ne zavarják. Mint láthatod, az egész tervezés és preferenciák kérdése.

Kapcsoljuk mindezt szeretett User módunkhoz . Az ISR, hogy az óra esemény fog végrehajtani, függetlenül attól, hogy milyen szál jelenleg fut, és lehet, hogy még megszakítja egy másik ISR nem kapcsolódó feladat. ez egy tökéletes példa arra, hogy miért a kernel ugyanazon a címen van minden folyamat nem akarjuk megváltoztatni a GDT és a Page Directory (C3-ban) minden alkalommal, amikor megszakítást hajtunk végre, mivel ez sokszor történik akár egy adott felhasználói módú folyamat egyetlen funkciója során is. Sok minden történik azok között a kódsorok között, amelyeket a felhasználói módú alkalmazás fejlesztése során írsz (;)).

A megszakításokra egy másik módon úgy tekinthetünk, mint külső és független bemenetekre az operációs rendszerünk számára, ez a definíció nem pontos (nem minden megszakítás külső vagy független), de arra jó, hogy rámutasson, a kernel feladatának nagy része az, hogy értelmet adjon a minden helyről (bemeneti eszközök) folyamatosan előforduló eseményeknek, és az egyik oldalról kiszolgálja ezeket az eseményeket, a másikról pedig gondoskodjon arról, hogy minden megfelelően korreláljon.

Azért, hogy mindennek értelme legyen, kezdjük egy egyszerű felhasználói módú alkalmazással, amely a következő utasítást hajtja végre:

0x0000051d push ebp;

Minden egyes utasításhoz, amelyet a CPU végrehajt, először megvizsgálja az adott utasítás címét (ebben az esetben ‘0x0000051d’) a GDT/LDT-vel szemben a kódszegmens regiszter (‘cs’, mert ez a végrehajtandó utasítás) segítségével, hogy tudja, milyen indexet kell keresnie a táblázatban (ne feledjük, a szegmensregiszter pontosan megmondja a CPU-nak, hogy hol keresse). Ha a CPU tudja, hogy az utasítás a végrehajtható helyen van, és a megfelelő gyűrűben vagyunk (felhasználói mód/mag mód), akkor folytatja az utasítás végrehajtását. Ebben az esetben a “push ebp” utasítás nem csak a regisztert érinti, hanem a program veremét is (a verembe tolja az ebp tartalmát), így a CPU a GDT/LDT-vel is ellenőrzi az esp regiszteren belüli címet (a verem aktuális helyének címe, és mivel ez a verem helye, a CPU tudja, hogy ehhez a verem szegmensregisztert kell használni), hogy megbizonyosodjon arról, hogy az adott gyűrűben írható. Kérjük, vegye figyelembe, hogy ha ez az utasítás a memóriából is olvasna, a CPU-nak ellenőriznie kellett volna a megfelelő címet az olvasási hozzáféréshez is.

Ez még nem minden, miután a CPU ellenőrizte az összes biztonsági szempontot, most már a memóriához kell hozzáférnie és manipulálnia kell a memóriát, mint emlékszik, a címek virtuális formátumukban vannak. Az MMU most minden egyes, az utasítás által megadott virtuális memóriát fizikai memóriacímre fordít le a CR3 regiszter segítségével, amely a laptárra mutat (amely a laptáblára mutat), amely lehetővé teszi számunkra, hogy végül a címet fizikai címre fordítsuk. Megjegyzendő, hogy a cím lehet, hogy nem lesz a memóriában a szükség pillanatában, ebben az esetben az operációs rendszer oldalhibát generál (egy megszakítást generáló kivétel) és elhozza nekünk az adatokat a fizikai memóriába, majd folytatja a végrehajtást (ez átlátható a felhasználói módú alkalmazás számára).

A felhasználótól a kernelig

Minden csere a felhasználói mód és a kernel mód között az IDT segítségével történik. A felhasználói módú alkalmazásból az ‘int <num>’ utasítás átadja a végrehajtást az IDT-ben a num indexen lévő függvénynek. Amikor a végrehajtás kernel módban van, sok szabály megváltozik, minden szálnak más stackje van felhasználói és kernel módban, a memória hozzáférés ellenőrzése sokkal bonyolultabb és kötelezőbb, kernel módban nagyon keveset nem lehet megtenni és nagyon sokat meg lehet szegni.

ASLR és KASLR

többször “csak” a tudás hiánya akadályoz meg minket a lehetetlen elérésében.

AzASLR (Address Space Layout Randomization) egy olyan koncepció, amelyet minden operációs rendszeren belül másképp valósítanak meg, a koncepció lényege a folyamatok és a betöltött könyvtárak virtuális címeinek véletlenszerűvé tétele.

Mielőtt belemerülnénk, szeretném megjegyezni, hogy azért döntöttem az ASLR szerepeltetése mellett ebben a bejegyzésben, mert szépen megmutatja, hogy a védett mód és annak struktúrái hogyan tették lehetővé ezt a fajta képességet, még akkor is, ha nem ez az, amelyik megvalósítja vagy felelős érte.

Miért ASLR?

A miért egyszerű, a támadások megelőzése. Ha valaki képes kódot bejuttatni egy futó folyamatba, az, hogy nem ismeri néhány hasznos függvény címét, az okozhatja a támadás sikertelenségét.

Egy folyamatnak már most is különböző címtartománya van, ez azt jelenti, hogy ASLR nélkül minden folyamatnak ugyanaz lenne az alapcíme, ez azért van, mert ha minden folyamat a saját virtuális címtartományában van, nem kell vigyáznunk a folyamatok közötti ütközésekre. Amikor a programot linkeljük, a linkelő kiválaszt egy fix báziscímet, amelyhez a végrehajtható programot linkeli. Papíron minden olyan végrehajtható fájl, amelyet ugyanaz a linkelő az alapértelmezett paraméterekkel linkel (az alapcím szükség esetén konfigurálható), azonos alapcímmel fog rendelkezni. Példaként két alkalmazást írtam, az egyiket “1.exe”, a másikat “2.exe”, mindkettő különböző projekt a Visual Studio-ban, és mégis mindkettő ugyanazt az alapcímet kapta (az exeinfo PE-t használtam a PE fájlban lévő alapcím megtekintéséhez):

Nem csak ez a két futtatható fájl rendelkezik ugyanazzal az alapcímmel, mindkettő nem támogatja az ASLR-t (letiltottam):

A PE formátumban a File Characteristics alatt is látható:

Futtassuk most mindkét futtatható programot egyszerre, és a kettő ugyanazt az alapcímet használja (a Sysinternalsból származó vmmap-ot fogom használni az alapkép megtekintéséhez):

Láthatjuk, hogy mindkét folyamat nem használ ASLR-t és ugyanaz a 0x00400000-es alapcímük. Ha támadók lennénk, és hozzáférnénk ehhez a futtatható állományhoz, pontosan tudnánk, hogy milyen címek lesznek elérhetőek ennek a folyamatnak, ha egyszer eljutunk odáig, hogy bejuttatjuk magunkat a végrehajtásába. engedélyezzük az ASLR-t a futtatható 1.exe állományunkban, és nézzük meg, milyen varázslatos:

Változott!

A KASLR (Kernel ASLR) ugyanaz, mint az ASLR, csak a kernel szintjén működik, ami azt jelenti, hogy ha egyszer egy támadó képes volt bejutni a kernel kontextusába, akkor (remélhetőleg) nem fogja tudni, hogy milyen címeken milyen struktúrák vannak (például hol helyezkedik el a GDT a memóriában). Egy dolgot kell itt megemlíteni, hogy az ASLR minden egyes új folyamat (amelyik támogatja az ASLR-t természetesen) indításakor végzi a varázslatot, míg a KASLR minden egyes újraindításkor, mivel ekkor a kernel “spawnol”.

Hogyan működik az ASLR?

Hogyan működik és hogyan kapcsolódik a védett módhoz? Az ASLR megvalósításáért felelős a betöltő. Amikor egy folyamat elindul, a betöltő az, akinek el kell helyeznie a memóriába, létre kell hoznia a megfelelő struktúrákat és be kell indítania a szálát. A betöltő először ellenőrzi, hogy a futtatható program támogatja-e az ASLR-t, és ha igen, akkor véletlenszerűen kiválaszt egy alapcímet a rendelkezésre álló címek tartományán belül (a kernelterület például nyilvánvalóan nem áll rendelkezésre). E cím alapján a betöltő most inicializálja a Page Directory-t az adott folyamat számára, hogy a véletlenszerű címtartományt a fizikai címtartományra irányítsa. Az LDT rugalmassága szintén a segítségünkre siet, mivel a betöltő egyszerűen létrehozza a randomizált címnek megfelelő LDT-t a megfelelő jogosultságokkal. A szépség itt az, hogy a védett mód még csak nem is tud az ASLR használatáról, elég rugalmas ahhoz, hogy ne törődjön vele.

Egy érdekes végrehajtási részlet az, hogy a Windowsban az adott futtathatóhoz tartozó randomizált cím hatékonysági okokból rögzített. Ez alatt azt értem, hogy ha randomizáltuk mondjuk a calc.exe címét, akkor a második végrehajtáskor az alapcím ugyanaz lesz. Tehát ha egyszerre 2 számológépet nyitok meg – ugyanaz lesz az alapcímük. Ha egyszer bezárom mindkét számológépet, és újra megnyitom őket, mindkettőnek ugyanaz a címe lesz, csak ez a cím különbözni fog az előző számológépek címétől. Miért nem hatékony ez, kérdezi? gondoljon az általánosan használt DLL-ekre. Sok folyamat használja őket, és ha az alapcímük minden egyes folyamatpéldánynál más lenne, akkor a kódjuk is más lenne (a kód ezen az alapcímen hivatkozik az adatokra), és ha a kód más, akkor a DLL-t minden egyes folyamathoz be kell tölteni a memóriába. A valóságban az operációs rendszer csak egyszer tölti be a képeket az összes olyan folyamat számára, amely ezt a képet használja. Ez helyet takarít meg – nagyon sok helyet!

Következtetés

Most már el kell tudnod képzelni a kernelt munka közben, és meg kell értened, hogy az x86 architektúra összes kulcsfontosságú struktúrája hogyan játszik össze egy nagyobb képet, és hogyan teszi lehetővé számunkra, hogy esetleg veszélyes alkalmazásokat futtassunk felhasználói módban félelem nélkül (vagy kevéssé).

Articles

Vélemény, hozzászólás?

Az e-mail-címet nem tesszük közzé.