Linuxové noviny | Březen 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.
Obrázek 6: Paměť při startu programu Práce se zásobníkemZá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. Jeden typický příklad je na výpisu Příklad example1.c.
Výpis 7: Příklad example1.c 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. Obsah zásobníku je znázorněn na obrázku Obsah zásobníku.
Obrázek 8: Obsah zásobníku 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
Výpis 9: Příklad example2.c 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. Jak vypadá zásobník před a po volání funkce strcpy() je na obrázku Zásobník.
Obrázek 10: Zásobník 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.
Výpis 11: Příklad example3.c
$ 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.
Výpis 12: Příklad example5.c
Výpis 13: Příklad example4.c Z programu na výpisu Příklad example4.c 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 Příklad example5.c. 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 (viz výpis Příklad example6.c).
Výpis 14: Příklad example6.c 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 $ A nyní na původním programu: $ ./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).
Použitá literatura:
|