Jedním z nejzajímavějších a nejpoužívanějších pojmů v architektuře x86 je Chráněný režim a jeho podpora ve 4 režimech (tzv. kruzích):
Je to náročná myšlenka na pochopení a v tomto příspěvku se ji pokusím co nejsrozumitelněji vysvětlit. Budeme se zabývat následujícími pojmy:
- GDT, LDT, IDT.
- Překlad virtuální paměti.
- ASLR a Kernel ASLR (KASLR).
Začneme od základů, každý počítač má alespoň (snad) následující komponenty: CPU, disk a paměť RAM. Každá z těchto komponent zastává klíčovou roli v chodu systému. CPU vykonává příkazy a operace na paměti (RAM), RAM uchovává data, která používáme, a umožňuje k nim rychlý a spolehlivý přístup, disk uchovává trvalá data, která potřebujeme, aby existovala i po restartu nebo vypnutí. Vycházím z toho, protože i když je to úplný základ, je důležité mít to na paměti a při čtení tohoto článku si položit otázku, o které komponentě v danou chvíli mluvíme.
Operační systém je software, který to všechno organizuje, a také ten, který umožňuje rychlé, pohodlné, konzistentní a efektivní rozhraní pro přístup ke všem jeho schopnostem – některé z nich jsou přístupem k danému hardwaru a jiné slouží ke zvýšení pohodlí a výkonu.
Jako každý dobrý software pracuje operační systém ve vrstvách, jádro je první vrstvou a – podle mého názoru – tou nejdůležitější. Abychom pochopili význam jádra, musíme nejprve porozumět jeho činnostem a úkolům, kterým čelí, takže se podívejme na některé jeho povinnosti:
- Zpracovává systémová volání (právě to rozhraní, o kterém jsme mluvili).
- Alokuje prostředky (RAM, CPU a mnoho dalšího) procesům/vláknům, které má v ruce.
- Zabezpečuje prováděné operace.
- Prostředník mezi hardwarem a softwarem.
Mnoho z těchto činností provádíme za vydatné pomoci procesoru, v případě x86 je režim Protected režimem, který nám umožňuje omezit výkon (sadu instrukcí) aktuálně spuštěného kontextu provádění.
Předpokládejme, že máme dva světy – svět uživatele a svět supervizora. V každém okamžiku se můžete nacházet pouze v jednom z těchto světů. Když jste ve světě uživatele, vidíte svět tak, jak ho chce vidět supervizor. Podívejme se, co tím myslím:
Řekněme, že jste proces. Proces je kontejner jednoho nebo více vláken. Vlákno je kontext provádění, je to logická jednotka, jejíž strojové instrukce se provádějí. To znamená, že když vlákno provádí řekněme čtení z paměťové adresy 0x80808080, aktuálně odkazuje na virtuální adresu 0x80808080 aktuálního procesu. Jak jistě tušíte, obsah adresy se bude u dvou procesů lišit. Virtuální adresový prostor je nyní na úrovni procesu, což znamená, že všechna vlákna téhož procesu mají stejný adresový prostor a mohou přistupovat ke stejné virtuální paměti. Pro příklad prostředku, který je na úrovni vlákna, použijme známý zásobník.
Mám tedy vlákno, které vykonává následující kód:
Naše vlákno vykonává funkci main, která zavolá naši funkci „func“. Řekněme, že vlákno přerušíme na řádku 9. Rozložení zásobníku bude následující:
- proměnná_a.
- parametr.
- return adresa – adresa řádku 20.
- proměnná_b.
Pro ilustraci:
V uvedeném kódu vytvoříme pro náš proces 3 vlákna a každému z nich vypíšeme jeho id, segment zásobníku a ukazatel na zásobník.
Možný výstup tohoto programu je:
Jak vidíte, všechna vlákna měla stejný segment zásobníku, protože mají stejný virtuální adresový prostor. ukazatel na zásobník je pro každé z nich jiný, protože každé z nich má svůj vlastní zásobník, do kterého ukládá své hodnoty.
Poznámka na okraj k segmentu zásobníku – více o segmentových registrech vysvětlím v části GDT/LDT – zatím mě berte za slovo.
Tak proč je to důležité? Procesor může kdykoli zmrazit vlákno a předat řízení jakémukoli jinému vláknu, které chce. Součástí jádra je plánovač, který přiděluje procesor aktuálně existujícím (a „připraveným“) vláknům. Aby vlákna mohla spolehlivě a efektivně běžet, je nezbytné, aby každé z nich mělo svůj vlastní zásobník, do kterého může ukládat své relevantní hodnoty (například lokální proměnné a návratové adresy).
Pro správu svých vláken udržuje operační systém pro každé vlákno speciální strukturu nazvanou TCB (Thread Control Block), do které ukládá – mimo jiné – kontext daného vlákna a jeho stav (běží / je připraveno / atd…). Kontext obsahuje – opět – mimo jiné hodnoty registrů procesoru:
- EBP -> Základní adresa zásobníku, každá funkce používá tuto adresu jako základní adresu, ze které odsazuje pro přístup k lokálním proměnným a parametrům.
- ESP -> Aktuální ukazatel na poslední hodnotu (první na pop) na zásobníku.
- Registry pro všeobecné účely -> EAX, EBX atd…
- Registr příznaků.
- C3 -> obsahují umístění adresáře stránek (bude probráno později).
- EIP – Další instrukce, která se má provést.
Kromě vláken musí operační systém sledovat spoustu dalších věcí, včetně procesů. Pro procesy si operační systém ukládá strukturu PCB (Process Control Block), řekli jsme si, že pro každý proces existuje izolovaný adresní prostor. Prozatím předpokládejme, že existuje tabulka, která mapuje každou virtuální adresu na fyzickou adresu, a tato tabulka je uložena v PCB, OS je zodpovědný za aktualizaci této tabulky a udržuje ji aktualizovanou podle správného stavu fyzické paměti. Pokaždé, když plánovač přepne provádění na dané vlákno, použije se tabulka uložená pro proces, který toto vlákno vlastní, aby byl schopen správně přeložit virtuální adresy.
Tolik k pojmům, pojďme pochopit, jak se to vlastně dělá. Za tím účelem se podívejme na svět z pohledu procesoru:
Globální popisná tabulka
Všichni víme, že procesor má registry, které mu pomáhají provádět výpočty, některé registry více než jiné (;)). Podle návrhu podporuje x86 více režimů, ale nejdůležitější jsou uživatelský a dozorovaný, procesor má speciální registr nazvaný gdtr (Global Descriptor Table Register), který uchovává adresu velmi důležité tabulky. tato tabulka mapuje každou virtuální adresu na odpovídající režim procesoru, obsahuje také oprávnění pro danou adresu (READ | WRITE | EXECUTE). tento registr lze samozřejmě měnit pouze z režimu dohledu. V rámci vykonávání procesoru kontroluje, jakou instrukci má provést příště (a na jaké adrese se nachází), tuto adresu porovnává s GDT, a tak ví, zda se jedná o platnou instrukci na základě jejího hledaného režimu (shoda aktuálního režimu procesoru s režimem v GDT) a oprávnění (pokud není spustitelná – neplatná). Příkladem je instrukce ‚lgdtr‘, která načte hodnotu do registru gdtr a lze ji provést pouze z režimu pod dohledem, jak je uvedeno. Klíčové je zde zdůraznit, že jakoukoli ochranu nad paměťovými operacemi (provádění instrukcí / zápis na neplatné místo / čtení z neplatného místa) provádí GDT a LDT (bude následovat) na úrovni procesoru pomocí těchto struktur, které byly vytvořeny operačním systémem.
Takto vypadá obsah položky v GDT / LDT:
http://wiki.osdev.org/Global_Descriptor_Table
Jak vidíte, má rozsah adres, kterých se položka týká, a jeho atributy (oprávnění), jak byste očekávali.
Lokální deskriptorová tabulka
Vše, co jsme si řekli o GDT, platí s malým (ale velkým) rozdílem i pro LDT. Jak už název napovídá, GDT se v systému aplikuje globálně, zatímco LDT lokálně, co mám na mysli pod pojmem globálně a lokálně? GDT sleduje oprávnění pro všechny procesy, pro každé vlákno a nemění se mezi přepínáním kontextu, LDT naopak ano. Dává to smysl pouze v případě, že každý proces má svůj vlastní adresní prostor, je možné, že pro jeden proces je adresa 0x10000000 spustitelná a pro jiný je pouze pro čtení/zápis. To platí speciálně při zapnutém ASLR (bude popsáno později). LDT je zodpovědná za udržování oprávnění, která odlišují jednotlivé procesy.
Jediná věc, kterou je třeba poznamenat, je, že vše, co bylo řečeno, je účelem struktury, ale ve skutečnosti některé OS mohou, ale nemusí některé struktury vůbec používat. například je možné používat pouze GDT a měnit ji mezi přepínáním kontextu a nikdy nepoužívat LDT. To vše je součástí návrhu OS a kompromisů. Záznamy v této tabulce vypadají podobně jako v GDT.
Selektory
Jak procesor pozná, kam se má při provádění konkrétní instrukce podívat v GDT nebo LDT? Procesor má speciální registry, které se nazývají segmentové registry:
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').
Každý registr má 16 bitů a jeho struktura je následující:
http://www.c-jump.com/CIS77/ASM/Memory/M77_0290_segment_registers_protected.htm
Takže máme index k GDT/LDT, máme také bit, který říká, zda se jedná o LDT nebo GDT, a v jakém režimu to má být (RPL 0 je supervisor, 4 je uživatel).
Interrupt Descriptor Table
Kromě GDT a LDT máme také IDT (Interrupt Descriptor Table), IDT je jednoduše tabulka, která uchovává adresy velmi důležitých funkcí, některé z nich patří OS, jiné ovladačům a fyzickým zařízením připojeným k PC. Stejně jako gdtr máme idtr, což je, jak jste asi uhodli, registr uchovávající adresu IDT. Čím je IDT tak výjimečný? Když iniciujeme přerušení, procesor se automaticky přepne do kontrolovaného režimu, což znamená, že každá funkce uvnitř IDT běží v kontrolovaném režimu. Každé vlákno z každého režimu může vyvolat přerušení vydáním instrukce ‚int‘ následované číslem, které procesoru říká, na jakém indexu se cílová funkce nachází. Vzhledem k tomu je nyní zřejmé, že každá funkce uvnitř IDT je potenciální bránou do režimu pod dohledem.
Víme tedy, že máme GDT/LDT, který říká procesoru oprávnění pro každou virtuální adresu, a máme IDT, který odkazuje funkce ‚brány‘ na naše milované jádro (které se samozřejmě nachází uvnitř části paměti pod dohledem). Jak se tyto struktury chovají v běžícím systému?
Virtuální paměť
Než pochopíme, jak to všechno hraje dohromady, musíme se zabývat ještě jedním pojmem – virtuální pamětí. Pamatujete, jak jsem říkal, že existuje tabulka, která mapuje každou adresu virtuální paměti na její fyzickou adresu? Ve skutečnosti je to trochu složitější. Zaprvé nemůžeme jednoduše namapovat každou virtuální adresu, protože by to zabralo více místa, než ve skutečnosti máme, a když pomineme potřebu být efektivní, operační systém také může vyměňovat stránky paměti na disk (kvůli efektivitě a výkonu), je možné, že stránka paměti potřebné virtuální adresy v paměti momentálně není, takže kromě překladu virtuální adresy na fyzickou musíme také uložit, zda je paměť v RAM, a pokud ne, kde je (může existovat více než jeden soubor stránek). MMU (Memory Management Unit) je komponenta zodpovědná za překlad virtuální paměti na fyzickou.
Jednou z opravdu důležitých věcí je pochopit, že každá instrukce v každém režimu prochází procesem překladu virtuální adresy, dokonce i kód v režimu s dohledem. Jakmile je procesor v chráněném režimu, každá instrukce, kterou vykoná, používá virtuální adresu – nikdy ne fyzickou (existují určité triky, díky kterým se skutečná virtuální adresa vždy přeloží do přesně stejné virtuální paměti, ale to je mimo rámec tohoto příspěvku).
Jak tedy procesor, jakmile je v chráněném režimu, ví, kam se má podívat, když potřebuje přeložit virtuální adresu? odpovědí je registr CR3, tento registr drží adresu struktury, která obsahuje požadované informace – adresář stránek. Jeho hodnota se mění s aktuálně běžícím procesem (opět jiný virtuální adresní prostor).
Takže jak vypadá tento adresář stránek? Pokud jde o efektivitu, potřebujeme, aby bylo možné se na tuto „tabulku“ dotazovat co nejrychleji, také potřebujeme, aby byla co nejmenší, protože tato tabulka se bude vytvářet pro každý proces. Řešení tohoto problému není nic menšího než geniální. Nejlepší obrázek, který se mi podařilo najít pro ilustraci procesu překladu, je tento (z wikipedie):
MMU má 2 vstupy, virtuální adresu k překladu a CR3 (adresu do aktuálně příslušného adresáře stránky). Specifikace x86 štěpí virtuální adresu na 3 části:
- 10bitové číslo – index do adresáře stránek.
- 10bitové číslo – index do tabulky stránek.
- 12bitové číslo – offset na vlastní fyzickou adresu.
Takže procesor vezme první 10bitové číslo a použije ho jako index do adresáře stránek, pro každou položku v adresáři stránek máme tabulku stránek, kterou pak procesor použije jako index další 10bitové číslo. Každá položka adresářové tabulky ukazuje na stránku paměti o velikosti 4K, která je pak pomocí posledního 12bitového offsetu z virtuální adresy označena jako přesné místo ve fyzické paměti. Genialita tohoto řešení spočívá v:
- flexibilitě, že každá virtuální adresa lokalizuje na zcela nesouvisející fyzickou.
- Úžasná je prostorová efektivita zapojených struktur.
- Ne každá položka každé tabulky se používá, v tabulkách existují pouze virtuální adresy, které proces skutečně používá a mapuje.
Opravdu se omlouvám, že jsem tento proces nevysvětlil podrobněji, jedná se o dobře zdokumentovaný proces, na jehož vysvětlení pracovalo mnoho lidí lépe, než bych to kdy dokázal já – vygooglujte si to.
Jádro vs. uživatel
Tady to začíná být zajímavé (a kouzelné, pokud mohu).
Na začátku tohoto článku jsme uvedli, že operační systém to všechno organizuje, dělá to pomocí jádra. Jak již bylo řečeno, jádro běží v části paměti, která je v GDT pro všechny procesy namapována pouze jako režim pod dohledem. Ano, vím, že každý proces má svůj vlastní adresní prostor, ale jádro si tento adresní prostor (obvykle horní polovinu, záleží na operačním systému) ukrojí pro vlastní potřebu, a to nejen v adresním prostoru, ale také na stejné adrese pro všechny procesy. To je důležité, protože kód jádra je pevně daný a každý odkaz na proměnné a struktury musí být na stejném místě pro všechny procesy. Na jádro se můžete dívat jako na speciální knihovnu nahranou každému procesu na stejném místě.
Hlouběji do přerušení
Víme, že IDT obsahuje adresy funkcí, tyto funkce se nazývají ISR (Interrupt Service Routine), některé se spustí při výskytu hardwarové události (stisk klávesy na klávesnici) a jiné při softwarové iniciaci přerušení například pro přepnutí do režimu jádra.
Windows mají skvělou koncepci přerušení a jejich prioritizaci: Jedním z obzvláště důležitých přerušení je tikání hodin. S každým tiknutím hodin se objeví přerušení, které zpracovává jeho ISR. Plánovač operačního systému používá tuto událost hodin k řízení toho, jak dlouho který proces běží a zda je na řadě jiný. Jak jistě chápete, toto přerušení je mimořádně důležité a musí být obslouženo, jakmile k němu dojde, ne všechny ISR mají stejnou důležitost a právě zde nastupují priority mezi přerušeními. Vezměme si například stisk klávesy na klávesnici a předpokládejme, že má prioritu 1, právě jsem stiskl klávesu na klávesnici a její ISR se provádí, během provádění ISR klávesnice jsou všechna přerušení se stejnou a nižší prioritou ignorována. Během provádění ISR se spustí ISR hodin s prioritou 2 (proto se nezakázal), dojde k okamžitému přepnutí na ISR hodin, jakmile hodiny skončí, vrátí řízení ISR klávesnice, odkud se zastavil. tyto priority přerušení se nazývají IRQL (Interrupt ReQuest Level), jak IRQL přerušení stoupá, je jeho priorita vyšší. Přerušení s nejvyšší prioritou nejsou nikdy přerušeními uprostřed, běží až do konce, vždy. IRQL je specifické pro Windows – IRQL je číslo v rozmezí 0-31, pro Linux naopak neexistuje, Linux zpracovává každé přerušení se stejnou prioritou a jednoduše vypne všechna přerušení, když opravdu potřebuje, aby daná rutina nebyla rušena. Jak vidíte, je to všechno otázka návrhu a preferencí.
Pojďme si to všechno spojit s naším milovaným uživatelským režimem . ISR této události hodin se provede bez ohledu na to, jaké vlákno právě běží, a může se dokonce přerušit do jiného ISR pro nesouvisející úlohu. to je dokonalý příklad toho, proč je jádro na stejné adrese pro všechny procesy, které nechceme měnit GDT a adresář stránek (v C3) pokaždé, když provedeme přerušení, protože se to stane MNOHEMkrát během i jediné funkce daného procesu v uživatelském režimu. Mezi těmi řádky kódu, které píšete při vývoji aplikace v uživatelském režimu, se toho děje hodně (;)).
Jiný způsob pohledu na přerušení je jako na vnější a nezávislé vstupy do našeho OS, tato definice není přesná (ne všechna přerušení jsou vnější nebo nezávislá), ale je dobré ji uvést na pravou míru, velkou částí práce jádra je dávat smysl událostem, které se neustále vyskytují z každého místa (vstupních zařízení), a z jedné strany tyto události obsluhovat a z druhé zajistit, aby vše bylo správně korelováno.
Aby to všechno dávalo smysl, začněme s jednoduchou aplikací v uživatelském režimu, která vykonává následující instrukce:
0x0000051d push ebp;
Pro každou instrukci, kterou procesor vykonává, nejprve prozkoumá adresu této instrukce (v tomto případě ‚0x0000051d‘) vůči GDT/LDT pomocí registru segmentu kódu (‚cs‘, protože se jedná o instrukci k vykonání), aby věděl, jaký index má v tabulce hledat (nezapomeňte, že registr segmentu říká procesoru, kde přesně má hledat). Jakmile CPU ví, že instrukce je na spustitelném místě a my ve správném kruhu (uživatelský režim/režim jádra), pokračuje nyní ve vykonávání instrukce. V tomto případě instrukce ‚push ebp‘ neovlivňuje pouze registr, ale také zásobník programu (posouvá do zásobníku obsah ebp), takže CPU také kontroluje proti GDT/LDT adresu uvnitř registru esp (adresa aktuálního umístění na zásobníku, a protože se jedná o umístění na zásobníku, CPU ví, že k tomu má použít segmentový registr zásobníku), aby se ujistilo, že je v tomto konkrétním kruhu zapisovatelný. Všimněte si, že kdyby tato instrukce také četla z paměti, musel by procesor zkontrolovat i příslušnou adresu pro přístup ke čtení.
To není vše, poté, co procesor zkontroloval všechny bezpečnostní aspekty, je nyní třeba přistupovat k paměti a manipulovat s ní, jak si vzpomínáte, adresy jsou ve svém virtuálním formátu. MMU nyní překládá každou virtuální paměť určenou instrukcí na fyzickou adresu paměti pomocí registru CR3, který ukazuje na adresář stránek (ten ukazuje na tabulku stránek), která nám nakonec umožní převést adresu na fyzickou. Všimněte si, že adresa nemusí být v paměti v okamžiku potřeby, v takovém případě OS vygeneruje chybu stránky (výjimku, která generuje přerušení) a přenese data do fyzické paměti za nás a pak pokračuje ve vykonávání (to je transparentní pro aplikaci v uživatelském režimu).
Z uživatelského režimu do režimu jádra
Každá výměna mezi uživatelským režimem a režimem jádra probíhá pomocí IDT. Z aplikace v uživatelském režimu se instrukcí ‚int <num>‘ přenese provedení na funkci v IDT na indexu num. Při provádění v režimu jádra se spousta pravidel mění, každé vlákno má jiné zásobníky pro uživatelský a jaderný režim, kontroly přístupu do paměti jsou mnohem komplikovanější a povinnější, v režimu jádra je jen velmi málo věcí, které nemůžete udělat, a spousta věcí, které můžete porušit.
ASLR a KASLR
častěji je to „jen“ neznalost, která nám brání dosáhnout nemožného.
ASLR (Address Space Layout Randomization) je koncept, který je v každém operačním systému implementován jinak, jeho podstatou je randomizace virtuálních adres procesů a jejich načtených knihoven.
Než se do toho ponoříme, chtěl bych poznamenat, že jsem se rozhodl zařadit ASLR do tohoto příspěvku, protože je hezky vidět, jak chráněný režim a jeho struktury umožnily tento druh schopnosti, i když to není ten, kdo ji implementuje nebo je za ni zodpovědný.
Proč ASLR?
Proč je jednoduché, aby se zabránilo útokům. Když je někdo schopen injektovat kód do běžícího procesu, neznalost adres některých užitečných funkcí je to, co může způsobit neúspěch útoku.
Už teď máme pro každý proces jiný adresní prostor, to znamená, že bez ASLR by všechny procesy měly stejné základní adresy, to proto, že když má každý proces ve svém vlastním virtuálním adresním prostoru, nemusíme si dávat pozor na kolize mezi procesy. Když program linkujeme, linker zvolí pevnou základní adresu, na kterou spustitelný soubor nalinkuje. Na papíře budou mít všechny spustitelné soubory linkované stejným linkerem s výchozími parametry (základní adresu lze v případě potřeby nastavit) stejnou základní adresu. Jako příklad uvedu dvě aplikace, z nichž jedna se jmenuje „1.exe“ a druhá „2.exe“, obě jsou různé projekty ve Visual Studiu, a přesto mají obě stejnou základní adresu (použil jsem exeinfo PE, abych zjistil základní adresu v PE souboru):
Nejen že tyto dva spustitelné soubory mají stejnou základní adresu, ale oba nepodporují ASLR (zakázal jsem ho):