Vytvoření vlastního Shellcodu – část 2
Dnešní článek přímo naváže na konec předchozího, kde jsme si nastínili, jak vytvořit vlastní shellcode, respektive jeho základ. Pro použití v praxi (exploitu) je však nezbytné vyhnout se některým aspektům např. použití hodnoty NULL nebo závislosti na pozici v paměti, což si názorně předvedeme. Aby námi vytvořený shellcode nezůstal pouze jako nic nedělající „kostra“, vytvoříme ten obecně nejrozšířenější – reverse shell.
Prerekvizity:
- Základní znalost tvorby exploitů
- Základní znalost stacku, registrů, assembler instrukcí a opcodes
- Základní znalost práce s nástrojem WinDbg
- PoC z předchozího článku (Vytvoření vlastního Shellcodu)
Vyhnutí se NULL bajtům
Nejprve pustíme PoC shellcodu z předchozího článku v debuggeru, po softwarovém breakpointu můžeme vidět, že byť shellcode funguje správně, obsahuje NULL bajty:
V případě, kdy spouštíme shellcode pomocí Python skriptu, tak to problém není, avšak použití v reálném exploitu s největší pravděpodobností nebude fungovat, neboť NULL bajt je zpravidla „bad character“.
Když se podíváme na instrukce, které generují NULL bajt, zjistíme, že první je instrukce sub esp,200h. Namísto odčítání kladného offsetu, přičteme offset záporný čímž docílíme úplně stejného výsledku a vyhneme se použití NULL bajtu:
Upravíme stávající shellcode a zkontrolujeme, že se již NULL bajt na původní pozici neobjevuje:
Obdobný způsob můžeme využít pro většinu instrukcí generující NULL bajt kromě instrukcí CALL a JMP, na které se blíže zaměříme v textu níže.
Pozičně nezávislý shellcode
Instrukce CALL generuje NULL bajt, protože kód volá funkci napřímo. Každé přímé volání funkce, v závislosti na jejím umístění, vyvolá buď „blízké volání“ obsahující relativní offset, nebo „vzdálené volání“ obsahující absolutní adresu.
Existují dva způsoby jak se NULL bajtu vyhnout. První možností je přesunout všechny funkce v kódu nad instrukci CALL, čímž vznikne negativní relativní offset. Druhá možnost spočívá v dynamickém zjištění absolutní adresy funkce, kterou chceme volat, a uložení její hodnoty do registru. Výhoda druhé možnosti je její flexibilita a zároveň tím vznikne „NULL-free“ a pozičně nezávislý shellcode (může být uložen na jakékoliv pozici v paměti).
V našem shellcodu využijeme obou technik, nejprve skutečnost, že volání funkce umístěné na nižší adrese bude používat záporný offset, a proto má vysokou šanci, že nebude obsahovat NULL bajt. Navíc při provádění instrukce CALL bude návratová adresa vložena na zásobník. Tato adresa může být poté přenesena ze zásobníku do registru a použita k dynamickému výpočtu absolutní adresy funkce, kterou budeme chtít provolat.
Nejprve upravíme funkci start z které odebereme CALL instrukce a běh programu přímo vstoupí do funkce find_kernel32. Po zjištění base adresy knihovny kernerl32.dll přidáme nové funkce k zjištění pozice shellcodu v paměti. Nejprve, funkce find_function_short obsahuje pouze jednu instrukci, krátký skok do funkce find_function_short_c, a jelikož se funkce vyskytují blízko sebe, opcode instrukce JMP nebude obsahovat NULL bajt. Druhá zmíněná funkce též obsahuje pouze jednu instrukci, tentokrát CALL s voláním třetí nové funkce find_function_ret. Jelikož se volaná funkce nachází nad instrukcí CALL bude její offset negativní, a tudíž se opět vyhneme NULL bajtu. Po zavolání instrukce CALL se uloží návratová adresa na zásobník – zásobník bude ukazovat na první instrukci funkce find_function. Ve funkci find_function_ret nejprve pomocí instrukce POP uložíme právě na zásobník vloženou adresu do registru, což nám umožní provést nepřímé volání (indirect call). Na závěr je adresa uložena na pozici EBP registru s offsetem 0x04 pro budoucí použití.
Na konec kódu přesuneme instrukce pro vložení hashe, zjištění funkce a její spuštění (dříve ve funkci start):
V debuggeru nyní zkontrolujeme, že vše funguje jak má – CALL instrukce neobsahuje NULL bajt (díky negativnímu offsetu) a návratová hodnota na zásobníku ukazuje na první instrukci ve funkci find_function:
Dále ověříme, že správně zafungovalo i nepřímé volání (indirect call) z funkce resolve_symbols_kernel32 – též neobsahuje NULL bajt:
Reverse Shell
V tuto chvíli jsme schopni vytvořit plně funkční shellcode, pro ukázku zvolíme ten nejrozšířenější, tedy reverse shell. Prozkoumáním volně dostupných reverse shellů napsaných v programovacím jazyku C zjistíme, že většina potřebných API volání pochází z knihovny ws2_32.dll. Nejprve je potřeba inicializovat Winsock knihovnu pomocí WSAStartup, následně provolat WSASocketA k vytvoření soketu a nakonec funkci WSAConnect k navázání spojení. Poslední volané API je pak CreateProcessA z knihovny kernel32.dll – spuštění procesu cmd.exe.
ws2_32.dll
Již nyní je náš shellcode schopný získat potřebné exportované symboly z knihovny kernel32.dll – nejprve nalezneme a uložíme (pro pozdější použití) adresu CreateProcessA API. Následně je potřeba načíst knihovnu ws2_32.dll do vyhrazené paměti shellcodu a zjistit její base adresu, k čemuž můžeme využít funkci LoadLibraryA (součást knihovny kernel32.dll).
K získání symbolů z ws2_32.dll lze využít GetProcessAddress API (též z kernel32.dll), nebo znovu použít k tomuto účelu námi již vytvořenou funkci (find_function) – jediným požadavkem je, aby base adresa byla uložena v EBX registru, aby mohla být relativní virtuální adresa přeložena na „Virtual Memory Address“.
Nejprve do shellcodu přidáme načtení zbývajících symbolů z knihovny kernel32.dll:
Dále si připravíme volání pro funkci LoadLibraryA. Vynulujeme registr EAX a provedeme posun konce řetězce „ws2_32.dll“ do AX registru, čímž zajistíme, že bude řetězec „NULL terminated“ bez využití NULL bajtu v shellcodu. Zbylé dvě PUSH instrukce vloží zbývající části řetězce na zásobník a následně i samotný ukazatel na hledaný řetězec (PUSH ESP) – LoadLibraryA vyžaduje jako parametr ukazatel na řetězec, který se již na zásobníku nachází. Na závěr zavoláme samotnou funkci LoadLibraryA:
K získání symbolů z knihovny w2_32.dll využijeme naší funkci find_function. Nejprve, jak již bylo zmíněno, přesuneme base adresu knihovny do registru EBX – návratová hodnota z LoadLibraryA uložena v registru EAX. Se znalostí base adresy w2_32.dll můžeme vložit na zásobník hash pro daný symbol, v našem případě tedy hash WSAStartup, a zavolat funkci find_functon:
Obdobným způsobem lze nalézt též zbývající API z knihovny ws2_32.dll, tedy WSASocketA a WSAConnect.
Abychom mohli provolat WSAStartup funkci, musíme nejprve zjistit očekávané parametry. Z oficiální dokumentace:
První parametr je specifikace verze Windows Socket, hodnotu nastavíme na „2.2“ – nejnovější verze. Druhý parametr je ukazatel na datovou strukturu WSDATA, která obdrží podrobnosti o Windows Socket implementaci. Stačí tedy pro strukturu vyhradit dostatečně velký prostor projitím jednotlivých prvků a zjištěním jejich velikostí:
Z dokumentace zjistíme, že některé prvky již nejsou používány od verze 2.0 a vyšší. Většina zbývajících polí struktury má přesně definovanou délku až na szDescription a szSystemStatus. Maximální délka szDescription je 257 (WSADESCRIPTION_LEN, která je 256 / 0x100 + NULL bajt). Délku szSystemStatus dokumentace neuvádí, nicméně, z jiných online zdrojů můžeme zjistit, že maximální délka je 129 (WSASYS_STATUS_LEN, která je 128 / 0x80 + NULL bajt). I když neznáme přesnou velikost celé struktury, jsme schopni vypočítat kolik místa maximálně zabere:
Jelikož velikost struktury je větší než námi vyhrazený prostor na zásobníku, je potřeba shellcode upravit a ve funkci start odečíst od registru ESP větší hodnotu. Dále přidáme novou funkci call_wsastartup k provolání WSAStartup API. Nejprve je nutné vyhradit dostatečný prostor pro návratovou strukturu WSDATA – aby další instrukce shellcodu strukturu nepřepsaly. Ukazatel na zásobník uložíme do EAX registru a následně pomocí CX registru odečteme libovolnou hodnotu (dostatečně velkou), tím se zároveň v instrukcích vyhneme NULL bajtům. Dále přes AX registr nastavíme hodnotu požadované verze („2.2“ reprezentované jako 0x0202) a na závěr provoláme funkci WSAStartup:
Kontrolou v debuggeru můžeme ověřit, že se parametry správně předaly a volání bylo úspěšné – návratová hodnota v registru EAX je 0:
Následné API, které je potřeba provolat je WSASocket čímž se samotný soket vytvoří. Opět ověříme v dokumentaci potřebné parametry:
Většina datových typů jsou dobře známé, jako INT nebo DWORD, kromě parametrů lpProtocolInfo a g. Níže si přiblížíme význam jednotlivých parametrů:
- af – specifikace tvaru adresy, AF_INET (2) odpovídá IPv4
- type – typ spojení (soketu), pro běžnou TCP komunikaci zvolíme SOCK_STREAM (1)
- protocol – tento parametr je odvozen od dvou předchozích, zvolíme tedy IPPROTO_TCP (6)
- lpProtocolInfo – očekávanou hodnotou je ukazatel na strukturu WSAPROTOCOL_INFO (vlastnosti soketu), nicméně hodnota může být i NULL (0x0)
- g – specifikace skupiny soketů, jelikož vytváříme jeden soket, hodnota bude NULL
- dwFlags – specifikace dalších atributů soketu, pro účely shellcodu není potřeba definice dalších atributů, nastavíme hodnotu opět na NULL
V shellcodu vytvoříme novou funkci volající WSASocket API. Nejprve vynulujeme EAX registr a tři krát ho vložíme na zásobník – poslední tři NULL parametry. Následně posunem AL registru vložíme na zásobník hodnotu 6 reprezentující IPPROTO_TCP protokol. V dalším kroku odečteme od AL registru 5, abychom získali hodnotu 1 – SOCK_STREAM. Jako poslední se provede inkrementace registru EAX (AF_INET) a provolaní WSASocketA API:
Po zavolání instrukce CALL WSASocketA zkontrolujeme v debuggeru návratovou hodnotu – registr EAX:
Oficiální dokumentace uvádí, že pokud je volání neúspěšné, návratová hodnota je INVALID_SOCKET250 (0xFFFF). Jinak funkce vrátí deskriptor odkazující na soket, vytvoření soketu tedy proběhlo úspěšně. Když je soket již vytvořený, můžeme provolat API WSAConnect, které naváže spojení mezi dvěma aplikacemi (sokety). Vyžadované parametry z dokumentace:
První parametr je typu SOCKET a očekává deskriptor k nepřipojenému soketu, tedy návratová hodnota z předchozího API WSASocketA. V tuto chvíli je hodnota uložena v registru EAX. Druhý parametr je ukazatel na sockaddr strukturu, která se liší podle zvoleného protokolu. Pro IPv4 použijeme strukturu sockaddr_in:
První pole struktury (sin_family) je tvar adresy, a dle oficiální dokumentace má mít vždy hodnotu AF_INT. Druhé pole, jak název napovídá, specifikuje port. Následuje vnořená struktura IN_ADDR, která obsahuje ke spojení použitou IP adresu. Definice struktury se liší v závislosti na způsobu předávání IP adresy. V paměti však struktury vypadají naprosto identicky, což znamená, že můžeme uložit IP adresu jako typu DWORD. Posledním polem v sockaddr_in struktuře je pole 8 znaků. Podle oficiální dokumentace je toto pole vyhrazeno pro systémové použití a jeho obsah by měl být nastaven na 0.
Nyní, když jsme probrali strukturu sockaddr_in a vnořenou strukturu IN_ADDR, podívejme se na další parametry WSAConnect – namelen. Jedná se o velikost struktury sockaddr_in, která podle datových typů z definic struktury je 0x10 bajtů. Další dva parametry lpCallerData a lpCalleeData očekávají ukazatel na uživatelská data, která budou soketem přenášena. Podle dokumentace jsou tyto parametry používány staršími protokoly a nejsou podporovány pro TCP/IP. Obě hodnoty tedy můžeme nastavit na hodnotu NULL. Parametr lpSQOS vyžaduje ukazatel na strukturu FLOWSPEC255. Tato struktura se používá v aplikacích, které podporují parametry kvality služby (QoS), což není případ našeho shellcodu, takže jej můžeme nastavit na hodnotu NULL. Poslední lpGQOS parametr je vyhrazen a měl by být nastaven na NULL.
Před změnou shellcodu je potřeba převést IP adresu a port stroje, který přijme spojení z našeho shellcodu, do správného formátu. Pro ukázku bude použita IP adresa 192.168.154.188 a port 4444 (Kali stroj):
Vytvoříme novou funkci call_wsaconnect k provolání WSAConnectA API. Nejprve uložíme deskriptor z předchozího kroku (funkce call_wsasocketa) do registru ESI. Následně nastavíme hodnotu NULL v registru EAX a vložíme ji 2x na zásobník. Poté na zásobník vložíme DWORD představující hexadecimální reprezentaci IP adresu stroje Kali – nutné vložit v opačném pořadí kvůli little endian řazení. To samé platí i pro další instrukci, která do registru AX vloží typ WORD s hodnotou reprezentující příslušný port (4444) v hexadecimální podobě. Pomocí instrukce SHL se provede posun doleva o 0x10 bajtů a následně se připočte 0x02 (sin_port a sin_family jsou definováni jako USHORT, což znamená, že jsou dva bajty dlouhé). Výsledný DWORD vložíme na zásobník, čímž dokončíme sockaddr_in strukturu. Následně uložíme ukazatel na strukturu v registru EDI. Další instrukce opět vynuluje registr EAX, který 4x vložíme na zásobník. Poté se k registru AL připočte hodnota 0x10 (velikost struktury) a vloží na zásobník společně s registrem EDI, který obsahuje ukazatel na strukturu sockaddr_in. Jako poslední se na zásobník vloží deskriptor soketu (ESI registr) a provolá WSASocketA API:
Nastavení listeneru v Kali, ověření v debuggeru, že se všechny parametry nastavily správně a spojení bylo navázáno (návratová hodnota v registru EAX je 0):
CreateProcessA – cmd.exe
Nyní, když jsme úspěšně iniciovali připojení, musíme najít způsob, jak spustit proces cmd.exe a přesměrovat jeho vstup a výstup přes námi vytvořené připojení. K tomuto účelu využijeme CreateProcessA API. Nejprve se podíváme na požadované parametry z dokumentace:
První parametr musí obsahovat ukazatel na řetězec s názvem aplikace, která má být spuštěna. Pokud je hodnota nastavena na NULL, druhý parametr již být NULL nesmí a obráceně. Tento parametr očekává ukazatel na řetězec obsahující „příkazovou řádku“, která má být spuštěna. V našem shellcodu použijeme zmíněný parametr ke spuštění cmd.exe. Parametry lpProcessAttributes a lpThreadAttributes očekávají ukazatel na strukturu SECURITY_ATTRIBUTES. V našem případě mohou obsahovat hodnotu NULL. Následující parametr bInheritHandles očekává hodnotu TRUE (1) nebo FALSE (0) a určuje zda nový proces „zdědí handle“ (Handle Inheritance) od volajícího procesu. Pro účely reverse shellu nastavíme hodnotu na TRUE. Parametr dwCreationFlags očekává tzv. Process Creation Flags, pokud je hodnota NULL, nový proces použije stejné příznaky jako má volající proces. lpEnvironment parametr je ukazatel na blok prostředí, dle dokumentace, pokud je hodnota NULL, využije se prostředí volajícího procesu. Parametr lpCurrentDirectory umožňuje zadat úplnou cestu k adresáři s požadovaným procesem (cmd.exe). Jelikož je cmd.exe v proměnné PATH, není potřeba parametr definovat. Poslední dva parametry vyžadují ukazatel na struktury STARTUPINFOA a PROCESS_INFORMATION. Jelikož struktura PROCESS_INFORMATION bude naplněna samotným API, stačí pouze znát její velikost. Na druhou stranu struktura STARTUPINFOA musí být předána API v rámci našeho shellcodu:
Oficiální dokumentace zmiňuje, že většinu atributů je možné nastavit na hodnotu NULL a je tedy potřeba zjistit příslušné hodnoty jen pro některé:
- cb – velikost struktury, která je 0x44 (lze zjistit z veřejně dostupných symbolů)
- dwFlags – bitové pole určující zda členové struktury STARTUPINFOA mají být použity při vytvoření nového okna. Pro účely reverse shellu nastavíme na hodnotu STARTF_USESTDHANDLES – povolení hStdInput, hStdOutput, and hStdError
Jelikož jsme nastavili STARTF_USESTDHANDLES příznak, je potřeba jednotlivým členům struktury STARTUPINFOA pro vstup (hStdInput), výstup (hStdOutput) a chybu (hStdError) předat „handle“. Pro interakci s procesem cmd.exe prostřednictvím vytvořeného soketu můžeme zadat deskriptor soketu získaný z volání WSASocketA jako „handle“.
Protože CreateProcessA API vyžaduje velký počet argumentů a celkem velkou strukturu, rozdělíme shellcode do několika separátních funkcí. Začneme s create_startupinfoa pro vytvoření struktury STARTUPINFOA. Nejprve na zásobník vložíme registr ESI obsahující deskriptor soketu, který zároveň využijeme jako „handle“ pro hStdInput, hStdOutput, a hStdError. Vynulujeme registr EAX a 2x ho vložíme na zásobník, což bude reprezentovat následující tři členy struktury (lpReserved2, cbReserved2 a wShowWindow). Dále do registrů AL a CX nastavíme hodnotu 0x80 a následně vzájemně sečteme, čímž získáme požadovanou hodnotu 0x100 (tím se opět vyhneme NULL bajtu). Poslední nenulový člen struktury je cb – velikost struktury, tedy 0x44. Na závěr do registru EDI uložíme ukazatel na právě vytvořenou strukturu:
Druhá nově přidaná funkce create_cmd_string vloží řetězec „cmd.exe“ na zásobník a uloží ukazatel na něj v registru pro budoucí použití. Nejprve do registru EAX vložíme zápornou hodnotu, kterou následně znegujeme, čímž získáme poslední část řetězce („NULL terminated“) a zároveň se vyhneme použití NULL bajtu v našem shellcodu. Následně se na zásobník vloží zbývající část řetězce a ukazatel se uloží v EBX registru.
Jelikož již máme připravenou STARTUPINFOA strukturu a řetězec „cmd.exe“, přidáme poslední funkci pro nastavení veškerých argumentů a provolání CreateProcessA API. Začneme přesunutím hodnoty ESP registru do registru EAX a za pomocí ECX odečteme 0x390, aby nedošlo k přepsání struktury PROCESS_INFORMATION. Následně se vloží ukazatel na strukturu STARTUPINFOA, kterou jsme dříve uložili do EDI registru. Následují tři NULL argumenty a poté inkrementace EAX registru k nastavení argumentu bInheritHandles na hodnotu 0x01 (TRUE). Provede se znovu vynulování registru EAX a dvě vložení na zásobník. Pomocí registru EBX, který uchovává ukazatel na řetězec „cmd.exe“ se nastaví parametr lpCommandLine. Na závěr se parametr lpApplicationName nastaví na hodnotu NULL a provolá se funkce CreateProcessA:
Nyní znovu nastavíme listener v Kali stroji a ověříme v debuggeru, že vše proběhlo v pořádku, tedy že návratová hodnota není NULL (registr EAX):
Jak jsme právě ověřili, po doběhnutí našeho shellcodu je navázáno spojení a získáme plně interaktivní reverse shell. V dnešním článku jsme si ukázali možný způsob jak vytvořit vlastní shellcode za pomoci systémových knihoven, který neobsahuje NULL bajt a je pozičně nezávislý.