Autor: BabCA SjEs | 1.5.2007 |
Tento článek by měl být úvod do (snad) vícedílného seriálu o exploitování. V tomto díle se budeme zabývat vytvářením shellcodu pod systém Windows. Jinak omluvte mé pravopisné chyby a počeštění slovíčka shellcode.
Základní znalostí by měl být určitě assembler (aspoň základy) a práce s Windows API. To by mělo stačit pro pochopení vytváření shellcodu.
Určitě bude potřeba nějaký assembler, já osobně doporučuji MASM (Macro Assembler), na který jsou vytvářeny i veškeré ukázky. Hodí se nám také nějaký kompilátor jazyka C (Dev-Cpp) a překladač Pythonu (až později). Dále se bude hodit utilita find, kterou sem napsal a je součástí tohoto balíku, který obsahuje i zdrojové kódy všech ukázek.
První věc, kterou si při vytváření shellcodu musíme uvědomit, je určení pro jaký systém má být psán. Jelikož je velký rozdíl mezi shellcodem pro Windows a pro Linux. Hlavní rozdíl je ve volání funkcí systému. Systém Linux provádí volání funkcí pomocí tzv. systémového přerušení:
Tento způsob se používal v systému MS-DOS. V tomto případě se nevolají funkce uložené na nějaké adrese v paměti, ale používá se přerušení práce jádra. Tyto přerušení mají hodnoty 00h až někam kolem 7Ah na architektuře x86 (DOS je 21h), v Linuxu je 80h kernel, tak to bude o něco víc. Parametry se předávají pomocí 16-b registrů (DOS) a 32-b (Linux). Microsoft už tuto myšlenku opustil a začal využívat tzv. dll knihoven o kterých bude řeč dále, zatímco systémy Linux se této konvence stále drží. Proto je psaní shellkodu pro systém Linux o něco snazší než pro Windows, jak sami uvidíte.
Vraťme se zpět k Windows. Jak jsme si už řekli, win používá některé knihovny. Nyní se na používaní knihoven z hlediska assembleru podíváme blíže. Každá funkce uložena v nějaké knihovně má své jedinečné jméno, díky kterému se lépe identifikuje. Vezměme si například funkci MessageBoxA(hWnd, Msg, Title, Type). Jak je vidět funkce se jmenuje MessageBoxA a přebírá čtyři parametry. V jazyce c se tato funkce použije takto:
int ret; |
ret = MessageBoxA(hWnd,_T("Ahoj svete"), _T("Pozdrav"), MB_OK); |
V ret bude uložen návratová hodnota. Jak ale provést takovéto volaní pomocí assembleru? Velice jednoduše za použití zásobníku. Všechny funkce ve win přebírají své argumenty pomocí zásobníku, tyto argumenty se vkládají v opačném pořadí (kvůli uspořádání zásobníku). To je první velice důležitá věc, kterou si máme pamatovat!. Zase si ukažme příklad:
push 0 ; MB_OK |
push OFFSET nadpis |
push OFFSET text |
push hWnd (0 - pro plochu) |
call MessageBoxA@16 ; to @16 značí počet 4b instrukcí v zásobníku (16/4 = 4 argumenty), kvůli instrukci ret |
Kam nám ale zmizela návratová hodnota? Určitě se nevypařila, ale uložila se do registru EAX (další důležitá poznámka). Pamatujte, že všechny funkce vrací výsledky do registru eax, tak aby jste před zavoláním nějaké funkce neměli v eax důležitá data.
Co je to vlastně shellcod? Oproti klasickému programu je to pouze jeho nepatrná část, která dokáže tzv. "samostatně žít", tzn. že pokud se tento kód zkopíruje do paměti nebo jakkoliv spustí, musí plně fungovat. Při vytváření nemůžeme plně využívat všech prostředků jazyka, jako např. uložení dat do sekce .data, používat příkaz EXTERN atd... Kód musí být psán v čistém assembleru! Všeobecně se traduje, že nesmí obsahovat NULL znaky, je tomu proto, že tyto instrukce se kopírují do paměti jako řetězec a jak je známo, řetězec končí ve chvíli, kdy je nalezen NULL znak. Tím pádem by se náš kód nezkopíroval celý do paměti, ale jen jeho část. Jinak v paměti už NULL znaky být můžou (a musí). Jak ale napsat kód bez NULL znaků?? Vždyť řetězce se musí přeci ukončit! Tak jak na to? Jednoduše:
Prvně si ukážeme jak nulovat registry bez použití příkazu mov eax, 0. Existuje několik způsobů. My si ukážeme ten nejpoužívanější a to je instrukce xor (exclusive or). Pokud použijeme příkaz 123 xor 123 vypíše nám to nulu. V assembleru se toto zapíše takto:
; V EAX je hodnota 123h
xor eax, eax
; Použije xor a výsledek (0) uloží do prvního argumentu, tady eax
Už víme jak vynulovat registry, tak teď se můžeme jak používat řetězce, aniž bychom je umístili do sekce .data (automaticky se doplňuje nulami do násobku 32b). Za tímto účelem se používá kombinace instrukcí jmp a call. Halvní je zde ta instrukce call. Má podobnou úlohu jako jmp s tím rozdílem, že si na zásobník ukládá adresu následující instrukce z EIP a až poté skočí na zadanou adresu (přepíše EIP na adresu na kterou má skočit). Provede instrukce a příkazem ret se vrací zpět (vezme návratovou adresu ze zásobníku a zapíše ji do EIP). Co se ale stane, pokud nenarazí na instrukci ret? Nic. Už se prostě nevrátí, ale v zásobníku tato návratová adresa bude uložena. To je to co chceme. Pokud skočíme na navěsti, kde je nějaký řetězec a před tímto řetězcem je umístěna instrukce call, která směřuje na adresu za jmp získáme v zásobníku adresu prvního znaku v řetězci (pointer), což je to co potřebujeme. Zase ukázka:
jmp string ; Skoc na navesti string
mam_string:
pop eax ; Uloz do eax
ukazatel na retezec
...
...
...
string:
call mam_string ; Odskoc na navesti mam_string
db 'Ahoj SveteN'
Komentáře snad mluví za vše. Nyní se zaměřme na další problém. Pokud se podíváme na řetězec, tak poslední znak je N, nikoliv NULL (řetězce musí končit vždy znakem NULL). Proč jsme to tak udělali? Ten znak N je pro nás pouze informační, abychom věděli kam umístit nulový znak. Díky tomu, že známe ukazatel na řetězec, můžemem nyní přejít na libovolnej znak a přepsat ho. V našem případě potřebujeme přepsat znak na adrese eax+10 nulou. Toho docíleme pomocí vynulovaného 8b registru, tedy:
xor edx, edx ;nuluj edx
mov [eax+10], dl ; za znak N dosat 0
Teď už víme hodně věcí o vytváření kódu, ale pořád nám chybí ta nejdůležitější a to je hledání a volání funkcí. Pro dnešek si ukážeme tu jednoduší možnost hledání funkcí a v dalším díle se tomu podíváme více pod pokličku. Tak tedy, v tomto odstavečku je naším úkolem najít a zavolat určitou funkci zahrnutou ve Windows API. Při načítání systému do paměti a jeho zpuštění se automaticky zavede knihovna kernel32.dll (jádro), která obsahuje ty nejdůležitější funkce. Díky tomuto máme jistotu, že tam někde v paměti bude. Teď ji nebudeme hledat (až v dalším díle), ale ukážeme si, jak ji využít. Tato knihovna obsahuje dvě velice užitečné funkce. První z nich je LoadLibraryA(LibFileName), která nahraje do paměti libovolnou knihovnu. Vrací handle této knihovny. Druhá se jmenuje GetProcAddress(hModule, ProcName), která přebírá handle knihovny a název funkce. Vrací adresu, kde se daná funkce nachází. díky těmto dvěma funkcím může používat cokoliv z Windows API. My máme teď menší problém. Jelikož neznáme adresu kde je umístěn kernel32.dll, nemůžeme přímo zjistit, kde se tyto funkce v paměti nachází. K tomuto nám teď poslouží utilita find, kterou jsem napsal právě k tomuto účelu. Přebírá dva parametry, první je knihovna, kde je funkce umístěna, druhý je název funkce. Pokud bylo vše dobře zadáno, tak vrací adresu, kde se v paměti nachází. Což je pro nás zatím schůdné řešení. Tak teď už víme jak najít adresu funkce, sice je to prozatímní řešení, ale ZATÍM postačuje. Když známe adresu je věc ji použít. Využijeme instrukci call. A zase je tu problém. Tato instrukce přebírá buďto návěstí nebo registr, nemůžeme mu tedy tuto adresu zadat přímo, ale pomocí registru. Ukažme si vše zase na příkladu (LoadLibraryA):
Nejdříve si najdeme adresu této funkce pomocí přikazu: "find
kernel32.dll LoadLibraryA". Tuto adresu si opíšeme.
; ted ziskame handle knihovny user32.dll
xor eax, eax
xor edx, edx
jmp user
mam_user:
pop edx
mov [edx+10], al
mov eax, 7c801d77h ; Sem prijde vase adresa
push edx
call eax
; Zavolej funkci
; EAX bude obsahovat handle knihovny
user:
call mam_user
db 'user32.dllN'
Teď už máme dostatečné znalosti na vytvoření našeho kódu. Chceme aby nám pouze zobrazil nějakou zprávu, třeba Ahoj. Nejede o nic složitého. Prohlídněte si zdrojový kód. Je dobře zdokumentovaný a není tam nic, co neznáte. Přidám zase jen pár komentářů. První se týká zkompilování. Nejdříve vytváříme *.obj pomocí příkazu
"ml /c /coff /Cp shellcode01.asm". Poté vytvoříme spustitelnou verzi pomocí
"link /subsystem:windows /section:.text,w shellcode01.obj". První parametr určuje platformu a druhý nastavuje počáteční bod programu. Díky tomuto se ihned přesuneme na náš kód, takže to bude vypadat stejně jako kdybychom ho nahráli do paměti sami. Dále sem si označil začátek a konec kódu, aby se mi lépe hledal. Teď ještě jedna výtka ke kódu. Jistě jste si všimli, že se odkazuji zbytečně na tři adresy, přitom by bohatě stačily jen dvě. Tak se tento kód lehce poupraví. Nový zdrojový kód. Zase je to slušně okomentováno, takže by s tím neměl být problém. Zde je obrázek kódu v hex. podobě. A tady máte přepis do c i s možností spuštění.
To by mělo být zatím vše. V dalším díle si ukážeme jak najít kernel v paměti a jak šifrovat a dešifrovat kód.
<$] Babča Sjes [$>