Vytvoření vlastního Shellcodu
V minulých článcích jsme se zabývali limitací prostoru a velmi omezenou dostupnou sadou znaků. Zmíněná omezení, na první pohled, celkem efektivně bránily k použití volně dostupných shellcodů – vygenerovaných např. pomocí nástroje msfvenom. V dnešním článku se (opět) nebudeme zaměřovat na obcházení bezpečnostních ochran, ale popíšeme si způsob, jak vytvořit vlastní shellcode.
Prerekvizity:
- Základní znalost tvorby exploitů
- Základní znalost stacku, registrů, assembler instrukcí a opcodes
- Základní znalost práce s nástrojem WinDbg
Systémové volání
Prakticky v každém shellcodu se setkáme s nějakým druhem systémového volání (syscalls). Jedná se o sadu funkcí, které poskytují „rozhraní“ mezi kernelem operačního systému a uživatelským prostorem – rozhraní tedy umožňuje přístup k funkcím operačního systému používané např. pro synchronizaci vláken, čtení / zápis souboru, správu síťových spojení atd., aniž by byl kompromitován / ohrožen samotný operační systém.
Účel shellcodu je ve své podstatě provést libovolné operace (nejčastěji navázat zpětné spojení), které nejsou součástí původní logiky aplikace – po úspěšné exploitaci zranitelnosti, typicky typu Buffer Overflow. K provolání syscalls se využívají tzv. Windows Native API skrz knihovnu ntdll.dll, bohužel však velmi často chybí jakákoliv dokumentace. Další možností, jak provolávat „funkce kernelu“ je prostřednictvím Windows API, které jsou exportovány v rámci načtených DLL knihoven v paměti daného procesu – nejprve potřebnou funkci lokalizujeme a následně ji můžeme v shellcodu využít.
Knihovna kernel32.dll, která je prakticky vždy v paměti procesu načtena (exportuje základní funkce vyžadované většinou procesů), obsahuje funkce LoadLibraryA – umožňuje načíst libovolnou knihovnu, a GetProcessAddress – vrátí adresu exportované knihovní funkce. Zmíněná knihovna je tedy velmi vhodný kandidát při tvorbě shellcodu. Nejprve však musíme zjistit její „base adresu“ – běžně používaná metoda, kterou si v článku ukážeme, spoléhá na Process Environmental Block (PEB) strukturu.
PEB Struktura
Datová struktura PEB je alokovaná operačním systémem pro každý proces a její ukazatel se nachází (v případě 32-bit) na offsetu 0x30 ve struktuře Thread Environment Block (TEB). Pointer adresy TEB struktury je uložen ve FS registru.
Struktura PEB obsahuje spoustu informací, nás nejvíce zajímá _PEB_LDR_DATA, neboť obsahuje tři spojové seznamy obsahující informaci o načtených modulech, které jsou namapovány v paměti procesu.
Pole Flink a Blink představují následující, respektive předchozí „záznam“ ve spojovém seznamu. Navíc, struktura _LIST_ENTRY v _PEB_LDR_DATA je součástí větší struktury _LDR_DATA_TABLE_ENTRY_, která již obsahuje informace, které potřebujeme – název modulu a „base adresu“ (začátek struktury _LDR_DATA_TABLE_ENTRY se nachází na zápornému offsetu 0x10):
Oficiální dokumentace uvádí, že _UNICODE_STRING struktura má proměnou Buffer obsahující ukazatel na příslušný řetězec znaků na offsetu 0x04, pro potřeby shellcodu název DLL knihovny tedy začíná na offsetu 0x30 (0x2c + 0x04).
Shellcode Assembly
Pro vytvoření shellcodu použijeme Keystone Framework a knihovnu CTypes, díky čemuž se spustí kód přímo v paměti procesu python.exe prostřednictvím Windows API callů. Python skript využije framework a knihovnu k:
- Přeměně ASM kódu do opcodes
- Alokaci paměti pro shellcode
- Zkopírování shellcodu do paměti
- Spuštění shellcodu v paměti
V následujících řádcích si ukážeme jak realizovat výše popsaný způsob s PEB strukturou k nalezení „base adresy“ knihovny kernel32.dll. Nejprve vytvoříme kostru skriptu:
Následně do proměnné ASMCODE začneme přidávat kód shellcodu. Na začátek vložíme instrukci int3 (softwarový breakpoint), který nám usnadní hledání shellcodu v paměti – není potřeba vypisovat adresu alokované paměti a nastavovat breakpoint ručně při každém spuštění skriptu. Poté uložíme do registru EBP hodnotu registru ESP a odečteme „libovolný“ offset, který „nerozbije“ zásobník.
Funkce find_kernel32 nejprve vynuluje registr ECX, který využije společně s offsetem 0x30 k uložení ukazatele na strukturu PEB do registru ESI. Dále je hodnota registru ESI posunuta o offset 0x0C (_PEB_LDR_DATA) a na závěr o dalších 0x1C, díky čemuž registr ESI obsahuje ukazatel na InInitializationOrderModuleList.
Druhá funkce next_module slouží k nalezení knihovny kernel32.dll. Nejprve se do registru EBX uloží „base adresa“ modulu, do registru EDI jméno modulu a do registru ESI ukazatel na další záznam v seznamu. Instrukce CMP porovnává, zda na 24. pozici jména je ukončovací NULL (porovnání délky řetězce). Délka názvu hledané knihovny kernel32.dll je 12 znaků, ale řetězec je uložen v UNICODE formátu (každý znak bude reprezentován typu WORD) a tudíž délka řetězce v UNICODE je 24. Pokud není nalezen hledaný řetězec, podmíněný skok znovu provolá funkci next_module a provede porovnání na následujícím záznamu. Při vyhledávání knihovny kernel32.dll využíváme faktu, že seznam InInitializationOrderModuleList uchovává záznamy v pořadí, v kterém byly načteny, tudíž první název se správnou délkou bude vždy knihovna kernel32.dll (načítá se mezi prvními).
Nalezení „base adresy“ knihovny kernel32.dll je dobrý začátek, dalším krokem je vyhledání a použití API, které modul exportuje. Pro ukázku zvolíme TerminateProcess symbol („symbol“ v tomto kontextu znamená jméno funkce a adresa v paměti), aby bylo možné náš shellcode legitimně ukončit, procházením Export Address Table (EAT) knihovny.
Každá knihovna, která exportuje funkce má tzv. Export Directory Table (EDT) obsahující základní informace o symbolech – Relative Virtual Address (RVA) funkcí, RVA jméno, atd. Pro rozlišení symbolů využijeme vztahu mezi následujícími třemi poli v EDT struktuře: AddressOfFunctions, AddressOfNames, a AddressOfNameOrdinals. K nalezení symbolu podle jména využijeme pole AddressOfNames. Každý symbol má unikátní jméno a index, pokud nalezneme požadovaný symbol na indexu i, můžeme stejný index i použít i v poli AddressOfNameOrdinals. Záznam na indexu i v poli AddressOfNameOrdinals obsahuje hodnotu, která bude sloužit jako nový index v poli AddressOfFunctions, díky čemuž nalezneme relativní adresu paměti hledané funkce. Připočtením „base adresy“ knihovny získáme plně funkční Virtual Memory Address (VMA).
Do shellcodu přidáme tři nové funkce, které vyhledají potřebný symbol: find_function, find_function_loop a find_function_finished. Po nalezení „base adresy“ knihovny kernel32.dll instrukcí PUSHAD vložíme hodnoty registrů na zásobník. Do registru EAX vložíme „base adresu“ knihovny, ke které přičteme offset 0x3C (offset k PE header). Abychom získali RVA z Export Directory Table přičteme k předchozí hodnotě další offset 0x78 a uložíme do registru EDI, následně přičteme „base adresu“, a tím získáme VMA.
Do registru ECX uložíme hodnotu NumberOfNames, která udává počet exportovaných symbolů přidáním offsetu 0x18 (registr ECX bude sloužit jako počitadlo). Do registru EAX nejprve uložíme pole AddressOfNames (offset 0x20) a následně přičteme „base adresu“. Na závěr uložíme nalezenou funkci na zásobník s využitím registru EBP (na začátku jsme do tohoto registru uložili ukazatel na zásobník).
Jak již bylo řečeno, registr ECX slouží jako počítadlo, pokud dosáhne hodnoty NULL (požadovaný symbol nebyl nalezen), provede se podmíněný skok do funkce find_function_finished. Zároveň se registr ECX využije jako index v poli AddressOfNames – jelikož každá položka v poli je typu DWORD, je potřeba index vynásobit 4. Následně do registru ESI uložíme VMA symbolu připočtením „base adresy“.
Hashovací funkce
K nalezení požadovaného symbolu – v našem případě TerminateProcess – nebudeme využívat délku řetězce (jako v případě nalezení knihovny kernel32.dll), nýbrž osvědčenou jednoduchou hashovací funkci. Hashovací funkce převede řetězec (název symbolu) na unikátní DWORD. Do shellcodu přidáme další tři funkce: compute_hash, compute_hash_again a compute_hash_finished.
Ve funkci compute_hash nejprve vynulujeme registr EAX a EDX (instrukcí CDQ), na závěr provoláním instrukce CLD dojde k vymazání příznaku DF v registru EFLAGS. Provedení této instrukce způsobí, že všechny operace s řetězci zvýší indexové registry, což jsou ESI (kde je uložen název symbolu) a/nebo EDI.
Následuje funkce compute_hash_again, kde se nejprve provolá instrukce LODSB – do registru AL se načte jeden bajt z paměti, na kterou ukazuje registr ESI, a poté automaticky inkrementuje nebo dekrementuje registr podle příznaku DF. Další instrukce TEST ověří, zda jsme již na konci řetězce (název symbolu) a případně se provede podmíněny skok do funkce compute_hash_finished. Tato funkce neobsahuje žádné instrukce, slouží pouze k ukončení hashování. Pokud není proveden podmíněný skok následuje instrukce ROR, která posune bity registru EDX o 13 bitů doprava. Na závěr připočteme hodnotu registru EAX (obsahuje bajt názvu daného symbolu) do registru EDX a provedeme skok do funkce comute_hash_again. Tato funkce představuje smyčku, která projde každý bajt názvu symbolu a přidá jej do „akumulátoru“ (registr EDX) po provedení rotace bitů doprava. Jakmile narazíme nakonec jména symbolu, registr EDX bude obsahovat unikátní čtyř bajtový hash.
Nejjednodušší způsob zjištění, jakou hodnotu hash bude vlastně mít, je naprogramovat stejnou hashovací funkci a spustit ji z příkazové řádky, případně vyčtením hodnoty v debuggeru za běhu programu (není součástí článku).
K porovnání, zda se hash v registru EDX shoduje s požadovanou hodnotou přidáme další funkci find_function_compare. Pokud se hash bude shodovat, můžeme též rovnou využít index uložený v registru ECX v poli AddressOfNameOrdinals a následně získat RVA, respektive VMA dané funkce.
Terminate Process
Nejprve se podíváme na parametry, které funkce TerminateProcess očekává:
Parametr uExitCode je výstupní kód, kdy hodnota 0 značí úspěšné ukončení běhu a parametr hProcess je „handler“ na proces, který má být ukončen. U druhého zmíněno parametru využijeme „pseudo-handleru“, kdy hodnota -1 reprezentuje aktuální proces.
Nejdříve je potřeba upravit funkci start. Hodnota hashe názvu symbolu TerminateProcess je 0x78b5b983. Před zavoláním funkce find_function vložíme zmíněnou hodnotu na zásobník, aby bylo snadné ji později dohledat a porovnávat s vypočítaným hashem názvu symbolu (ve funkci compute_hash_again). Následně na zásobník vložíme oba parametry funkce TerminateProcess, tedy 0 a -1. Poslední přidaná instrukce provede nepřímé volání TerminateProcess prostřednictvím registru EAX, který bude obsahovat VMA dané funkce.
Funkce find_function_compare nejprve provede porovnání zda aktuálně vypočítaný hash názvu symbolu se shoduje s dříve vloženou hodnotou na zásobník (0 x78b5b983). Offset 0x24 se bude lišit podle počtu provedených PUSH / POP instrukcí – přesnou hodnotu lze získat za běhu programu z debuggeru. Pokud se hodnota neshoduje, provede se podmíněný skok do funkce find_function_loop (další záznam v poli AddressOfNames). Jakmile najdeme správný záznam, nejprve z Export Directory Table na offsetu 0x24 zjistíme VMA pole AddressOfNameOrdinals a připočteme „base adresu“. Následně využijeme stejný index (registr ECX) vynásobený 2 – neboť každá položka je typu WORD a uložíme výsledek do registru CX, čímž získáme daný záznam z pole AddressOfNameOrdinals. Následně do registru EDX vložíme nejprve RVA pole AddressOfFunctions (offset 0x1c z Export Directory Table), po přičtení „base adresy“ získáme VMA. Posledním krokem je získaní adresy hledané funkce (v našem případě TerminateProcess) s využitím nového indexu (stále registry ECX) v poli AddressOfFunctions a přepsání hodnoty registru EAX na zásobníku (původně zazálohovaný pomocí instrukce PUSHAD).
Nyní připojíme skript s shellcodem k debuggeru, abychom zkontrolovali, zda vše funguje správně.
Nalezení knihovny kernel32.dll a ověření, že se adresa shoduje se systémovou:
Ověření, zda registr EAX odkazuje na symbol TerminateProcess:
Zavolání symbolu TerminateProcess s předanými parametry:
Jak jsme právě ověřili, po doběhnutí našeho shellcodu je proces řádně ukončen. V dnešním článku jsme si ukázali základy, jak vytvářet shellcode, metodu PEB struktury k nalezení libovolného načteného modulu (DLL knihovny), nalezení adresy paměti exportované funkce a její provolání včetně předání potřebných parametrů.