Chyba każda osoba interesująca się sieciami komputerowymi i ich bezpieczeństwem zetknęła się z atakiem typu „Buffer overflow”. Idea działania większości też jest znana, jednak kojarzy się z jakimiś niesamowitymi sztuczkami. W tym artykule chcę przybliżyć, jak w szczegółach wygląda podatny kod oraz jak trzeba spreparować specjalny ciąg wejściowy, aby doprowadzić to ataku.
Rozpatrzmy prostą funkcję zaprezentowaną poniżej:
void VulnFunc(char *ptr) { char buffer[32]; int i; for(i=0;(*ptr)!='&';i++) buffer[i]=*(ptr++); //dalsze instrukcje działające na buforze, nie zmieniające jego //zawartości, przykładowo liczące wartość skrótu MD5 z zawartości //bufora, nieistotne jeśli chodzi o atak buffer overflow }
Możemy łatwo wyobrazić sobie i podać zadanie tej funkcji, która pobiera pewne dane, kopiuje je do bufora oraz wykonuje z jego pomocą pewne operacje. Łatwo możemy wskazać widoczny od razu błąd: dane kopiujemy do bufora bez sprawdzania, czy go nie przepełnimy. Warunkiem zakończenia kopiowania jest natrafienie na znak &. Właśnie taki błąd, o ile możemy podać dowolne dane wejściowe, może zostać wykorzystany.
Nim przejdziemy do preparowania odpowiedniego ciągu znaków będących exploitem obejrzyjmy stos podczas normalnego wywołania funkcji, zaprezentowany na rysunku nr 1.
W lewej części rysunku przedstawiona jest zawartość pamięci wykorzystywanej na stos. Wyróżnione na czerwono obszary to zmienna i oraz bufor, z przepisaną zawartością, w tym przypadku tekstem „Ala”. Wyróżniony na niebiesko fragment to wskaźnik ptr przekazywany jako parametr. Wszystkie te informacje można skonfrontować z zawartością zmiennych podczas wykonania programu zaprezentowanych po prawej stronie rysunku.
Wróćmy na chwilę do wyróżnionego na niebiesko adresu, będącego parametrem. W takim przypadku nasze zainteresowanie powinny dotyczyć 32 bitowy adres znajdujący się bezpośrednio przed nim. Adres ten, jest adresem powrotu z funkcji. Jeśli uda nam się go zmienić, w momencie powrotu z funkcji … wykonamy inny kod, nieprzewidziany przez programistę.
Aby prześledzić taki przypadek, rozpatrzmy sytuację, kiedy do funkcji zostanie podany ciąg znaków postaci (liczby hexadecymalne):
0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x90,0x83,0xec,0x80,0xbe,
0xe6,0x10,0x41,0x00,0x68,0x41,0x6c,0x61,0x00,0x8b,0xdc,0x6a,0x40,0x53,
0x53,0x33,0xc0,0x50,0xff,0xd6,0xff,0x15,0xe6,0x6c,0x41,0x00,0x68,
0xfd,0x12,0x00,0x26
... i podobnie jak w poprzednim wywołaniu funkcji prześledźmy zawartość stosu.
Na zaprezentowanym rysunku możemy zaobserwować, że do bufora zostało przepisane dużo więcej danych niż poprzednio. Dokładnie 44 bajty (wartość hexadecymalna 2c w obszarze pamięci zmiennej i). Wartość ta jest większa niż zadeklarowany na 32 bajty bufor. Jednak co najważniejsze, czy - powinniśmy powiedzieć - najgroźniejsze, został nadpisany adres powrotu z funkcji (fragment wyróżniony na niebiesko), gdzie teraz znajdują się instrukcje, które zostaną wykonane przez program. Pamiętając, iż zmienne na maszynach z procesorem Intel są przechowywane „od końca”, możemy odkodować adres powrotu. Skok nastąpi do instrukcji znajdującej się pod adresem 0x0012fd68. Jeśli przyjrzymy się dokładnie obszarowi pamięci stosu, możemy zauważyć, że skoczymy w zaprezentowany na rysunku obszar pamięci. Dokładniej wykonanie nowego kodu zaczniemy od instrukcji o kodzie 0x90. Dalej możemy zaobserwować jeszcze kilka instrukcji o tym samym kodzie. Taka sekwencja jest często spotykana w exploitach. Kod 0x90 ma instrukcja NOP – no operation. Instrukcja, która nic nie robi, ale ma długość jednego bajta. Taka sekwencją zwana poduszeczką NOP ( jest przydatna, jeśli okazałoby się, iż nie trafimy dokładnie w początek kodu exploita). Dalszą analizę tego kodu zostawmy na później … i zobaczmy co się stanie, jak wykonamy ten kod. Wyniki przedstawia rysunek.
Co robi kod na stosie ... trochę assemblera
Znaczenie instrukcji o kodzie 0x90 zostało już wyjaśnione wcześniej, teraz przyjrzyjmy się reszcie kodu. . . .
0x90 | nop |
0x83,0xec, 0x80, | sub esp,0x80//zrobienie miejsca na stosie |
0xbe,0xe6, 0x10, 0x41, 0x00 | mov esi, <adres> |
0x68,0x41, 0x6c, 0x61, 0x00 | push <dane> |
0x8b,0xdc | mov ebx,esp |
0x6a,0x40 | push 0x40 |
0x53 | push ebx |
0x53 | push ebx |
0x33,0xc0 | xor eax, eax |
0x50 | push eax |
0xff,0xd6 | call esi |
0xff,0x15, 0xe6, 0x6c, 0x41, 0x00 | call <adres> |
0x68,0xfd, 0x12,0x00 | adres nadpisujacy adres powrotu |
0x26 | znak & |
Oczywiście zaprezentowany w tym przykładzie kod nie robi nic groźnego, jedynie wyświetla okienko. Jednak mając więcej miejsca na stosie, można spreparować naprawdę groźną funkcję. Ale to już temat na kolejny wpis ... ;)
Kilka wyjaśnień
Wykorzystywane w przykładowym kodzie exploita adresy zostały wcześniej odkryte poprzez analizę kodu działającego programu za pomocą debuggera. Architektura systemu Windows (no może z wyjątkiem Visty i dalszych) powoduje, że w tym samym środowisku (te same zainstalowane programy) wykonujący się program zawsze będzie posiadał te same adresy funkcji oraz stosu. Dzięki temu w środowisku testowym można je poznać … i wykorzystywać do ataku na inne identyczne lub podobne (patrz poduszeczkę NOP) systemy. Analogicznie ma się sprawa z adresem wykorzystywanej funkcji MessageBoxA.