Zajęcia 8: exploit¶
Data: 24.04.2018
Contents
Stos programu¶
Każdy proces ma, dostarczany przez system operacyjny, stos. Nie jest on niczym innym jak zamapowanym, ciągłym fragmentem przestrzeni adresowej procesu oraz ustawionym rejestrem stosu (na potrzeby scenariusza - SP). Na stosie odkładane są zmienne lokalne, adresy powrotu wykonywanych w danej chwili funkcji oraz częściowe wyniki obliczeń.
Implementacja operacji jest trywialna:
- położenie danych na stos to zapis do pamięci wskazywanej przez rejestr SP i modyfikacja tego rejestru o rozmiar zapisanych danych
- zdjęcie danych ze stosu to odczyt spod SP i modyfikacja SP o rozmiar zdejmowanych danych
System operacyjny mapuje programom z góry ustaloną ilość pamięci na stos (rzędu 2 stron) a kolejne strony pamięci alokuje dopiero, gdy zachodzi taka potrzeba.
i386¶
Na potrzeby tego scenariusza skupimy się na architekturach x86:
- SP wskazuje na bajt który jest na czubku stosu
- stos rośnie w dół, czyli położenie danych na stos oznacza zmniejszenie SP
- na Linuksie i BSD wołający funkcję powinien (w klasycznym przypadku, więcej w [1]):
- zachować zawartość rejestrów innych niż BP, SI, DI, BX (jeżeli będzie chciał ich potem użyć)
- położyć na stos argumenty w odwrotnej kolejności niż w deklaracji funkcji
- skoczyć do treści wołanej funkcji kładąc na stos aktualne miejsce w kodzie programu (adres powrotu)
- po powrocie wołanej funkcji - zdjąć ze stosu argumenty (wynik jest w AX)
- wołany powinien
- zachować rejestry BP, SI, DI, BX, jeżeli zamierza je zmienić
- opcjonalnie ustawić rejestr BP na zawartość SP (potrzebne do uzyskania
stack-trace’ów, upraszcza kod w asemblerze, ale nie jest obowiązkowe, w
gcc można wyłączyć generowanie takiego kodu za pomocą flagi
-fomit-frame-pointer
) - zarezerwować miejsce na stosie na zmienne lokalne (przesuwając SP)
- wykonać swoją treść
- odtworzyć rejestry
- do AX wpisać wynik
- skoczyć pod adres z czubka stosu - adres powrotu, ten położony przez wołającego
x86_64¶
W architekturze x86_64 wywoływanie funkcji wygląda bardzo podobnie, z tą różnicą że pierwsze 6 argumentów jest przekazywanych przez rejestry RDI, RSI, RDX, RCX, R8, R9 (lub XMM0-7 dla typów zmiennoprzecinkowych, odpowiednio). Jeśli funkcja ma więcej argumentów, są one przekazywane przez stos - tak jak w i386. Oczywiście wszystkie adresy mają 8 bajtów.
Wady i zalety stosu¶
Jak widać z tego scenariusza alokacja zmiennych lokalnych jest bardzo szybka, ponieważ sprowadza się do modyfikacji jednego rejestru i o ile zmienne lokalne są wielokrotnościami rozmiaru słowa procesora, to nie istnieje problem fragmentacji. Te dwie cechy dają alokacji na stosie dużą przewagę nad alokacją na stercie - malloc musi mieć złożoną logikę zarządzania pamięcią i do tego trudno uniknąć fragmentacji. Niemniej, stos nie nadaje się do trzymania zmiennych, których cykl życia przekracza jedno wywołanie funkcji.
Inną cechą stosu jest łatwość przewidzenia ułożenia danych programu na nim i w przypadku błędnie napisanego programu wykorzystania tej wiedzy do przejęcia kontroli nad programem.
Buffer overflow¶
Takim mianem określa się sytuację, kiedy z jakichś powodów program “zapomina” o faktycznym rozmiarze jakiegoś bufora i przekracza jego granice dostając się do pamięci do niego nienależącej. Przykład:
char c[5];
strcpy(c, "12345");
strcpy()
kopiuje napis do bufora przekazanego jako pierwszy argument razem z
kończącym zerem, zatem w tym przypadku 6 znaków do 5-elementowego bufora.
Specyfikacja języka na ogół nic o tym nie mówi, ale kompilatory zachowują się przewidywalnie i można łatwo odgadnąć jak zmienne leżą względem siebie na stosie (na ogół po kolei). Taka sytuacja powoduje, że szczególny przypadek przepełnienia bufora - stack buffer overflow, można łatwo wykorzystać, ponieważ przekraczając granicę bufora wystarczająco, można nadpisać adres powrotu z funkcji. Jeżeli zrobi się to umiejętnie, to można przejąć kontrolę nad programem, ponieważ mnemonik ret w kodzie atakowanego programu doprowadzi do skoku w miejsce wpisane w adres powrotu.
inetd¶
inetd
jest standardowym uniksowym demonem, tzw. superserwerem. W pliku
konfiguracyjnym inetd.conf
przypisuje się programy do różnych usług
sieciowych, np. proftpd do usługi ftp. inetd
nasłuchuje na odpowiednich
portach i w momencie nawiązania połączenia uruchamia program właściwy usłudze
z deskryptorami 0 i 1 ustawionymi tak, że program wysyła dane przez sieć
pisząc na standardowe wyjście a odczytuje dane czytając ze standardowego
wejścia. Niegdyś to podejście było zdecydowanie bardziej popularne niż dziś,
choć wciąż demon ten jest powszechny. Nowsza wersja demona nazywa się
xinetd
i różni się głównie sposobem konfiguracji - zamiast jednego
wspólnego pliku inetd.conf
, każda usługa ma osobny plik w katalogu
xinetd.d
.
Piszemy exploita¶
exploit - potoczna nazwa na program który wykorzysta wadę innego programu.
Jeżeli jesteśmy w stanie wytropić sytuację, która pozwala na dowolne przekroczenie rozmiaru bufora, to możemy pozwolić sobie na takie nadpisanie adresu powrotu, żeby program zrobił to, czego chce atakujący.
Dla uproszczenia przyjmijmy, że mamy do czynienia z takim kodem (server.c
):
int test()
{
char buf[128];
if (scanf("%s", buf) == EOF)
return 0;
printf(buf);
return 1;
}
int main()
{
while (test()) {
fflush(stdout);
}
return 0;
}
Przyjmujemy, że kod ten jest uruchomiony poprzez inetd. Naszym celem będzie uzyskanie wszystkiego tego, co może użytkownik, w którego imieniu działa ten program.
Szkic tego, co napiszemy:
- prześlemy kod do wykonania (tzw. payload)
- oprócz payload-a prześlemy takie dane, żeby zamiast powrotu z funkcji
test()
wykonany został payload
Problemy:
- sprawdzić jak dużo śmieci trzeba wstawić przed sfingowany adres powrotu w trefnym komunikacie żeby trafić w odpowiednie miejsce na stosie
- dowiedzieć się, czym nadpisać adres powrotu, żeby skoczyć do payload’a
- napisać payload który zrobi coś sensownego
Dowiedzieć się gdzie jest adres powrotu, mając kod źródłowy, jest łatwo - wystarczy policzyć mając na uwadze jak wygląda wołanie funkcji na x86. Gdy kodu nie ma, można to zrobić robiąc zrzut pamięci (stosu) i od końca stosu (jest znany, bo znany jest rejestr SP) poszukać wyrównanych do 4/8 bajtów liczb, które wskazują na obszar pamięci w którym odwzorowany jest kod programu.
Większy problem stanowi dowiedzenie się gdzie skoczyć, ponieważ:
- adres stosu przyznawany jest przez system operacyjny
- na pierwszy rzut oka nie bardzo wiadomo, co jest na tym stosie poniżej
funkcji
main
- nawet jeżeli czytając kod libc i jądra dowiemy się co i ile tego jest na
stosie poniżej funkcji main, to okaże się, że nie jest to stała liczba, bo
zależy np. od:
- rozmiaru argumentów przekazanych do programu
- środowiska
Szczęśliwie, duża część systemów ma adres początku stosu zawsze taki sam - bliski końca przestrzeni wirtualnej procesu, więc wahania położenia tego bufora nie mogą być bardzo duże - zależą głównie od środowiska i argumentów programu. Stąd też wahania adresu bufora da się sensownie oszacować - wystarczy uruchomić program z pustym środowiskiem w katalogu /, bez argumentów i sprawdzić adres dowolnej zmiennej lokalnej. Prawie na pewno nie zdarzy się sytuacja, że stos pod funkcją main będzie większy. Mamy więc jedno ograniczenie, a jako drugie należy przyjąć sensowne ograniczenie na środowisko i argumenty.
Dla uproszczenia, na chwilę ignorujemy fakt że w większości systemów obecnie adres stosu jest losowy (w pewnym przedziale).
Jeżeli stworzymy zatem taką sytuację na stosie:
Przed atakiem | Po ataku |
---|---|
? | payload |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
? | NOP |
adres powrotu | trefny adres powrotu |
opcjonalnie zachowane rejestry | śmieci |
bufor | śmieci |
(NOP odpowiada jakiejś operacji która nic nie robi)
to wystarczy, że trefny adres powrotu wskaże dowolną instrukcję NOP. Jeżeli będzie ich wystarczająco dużo, to możemy w ten sposób zniwelować wahania adresu bufora. Ta technika, czyli wstawianie w bufor dużej liczby NOP-ów nazywa się nop-slide, ponieważ mówiąc obrazowo, ześlizgujemy się po NOP-ach do payload’a.
Technikalia¶
Narzędzia:
objdump
: program deasemblujący skompilowane programy - przyda się do analizy, zwłaszcza z flagą-d
ulimit
: program zarządający limitami użytkownika - tylko po to aby poprosić jądro o robienie zrzutów pamięci (core dump) w przypadku kiedy program się wysypie - przydatne w debugowaniu (flaga-c
)gdb
: debugowanie z możliwością wczytania zrzutu pamięcigcc
: do kompilacji programów w tym payloada/proc/self/maps
: ten plik w linuksie pokazuje mapowania pamięci procesu, co pozwala przekonać się gdzie zaczyna się stosnetcat
(nc
): program który posłuży nam do przesłania exploita przez sieć
Implementacja krok po kroku¶
Najprostszym i najbardziej efektownym payloadem będzie uruchomienie powłoki,
czyli wykonanie execve
. Ponieważ uruchamiany program dziedziczy deskryptory,
a program uruchomiony w inetd używa deskryptorów 0, 1 i 2 do komunikacji
przez sieć, to na skutek zawołania execve
osiągniemy efekt podobny do zdalnej
powłoki.
Jako że nie jest jasne gdzie znajduje się PLT (bo zależy to od kompilatora i bardzo drobnych zmian w kodzie) a tym bardziej gdzie zamapowany jest kod biblioteki libc, lepiej wywoływać syscalle ręcznie - z pominięciem opakowań dostarczanych przez bibliotekę standardową. Szczegóły były omówione na drugich zajęciach.
Wiedząc już jak wykonać syscall’a należy napisać program w asemblerze,
który wykona execve
na /bin/sh
.
Przykład jest w pliku payload.s
.
Kod wykonywalny tego programu będzie naszym payloadem. Za pomocą make show-payload można zobaczyć ten kod w formie dogodnej do zapisania w C.
Plik demo.c
pokazuje jak można bezpośrednio przekazać sterowanie do tego kodu
(funkcja brute_force
) oraz jak nadpisać adres powrotu adresem payload’a.
Użyty tam adres jest wyłącznie skutkiem policzenia tego co jest na stosie
zgodnie z konwencją wołania podaną na początku.
Na tym etapie zakładamy że payload będzie umieszczony w pamięci wykonywalnej. W dalszej części omówimy jak ominąć to ograniczenie.
Ostatnią rzeczą jest dostarczenie payload’a do programu z zewnątrz razem z
trefnym adresem powrotu i NOP-slidem. Położenie adresu w buforze wynika
bezpośrednio z przykładu demo.c. Sam adres natomiast został sprawdzony
eksperymentalnie poprzez sprawdzenie adresu zmiennej lokalnej w funkcji
main()
.
Można obejrzeć to w pliku exploit.py
. Po wysłaniu trefnych danych
potrzebujemy jakoś móc wysyłać polecenia, można to zrobić tak:
(./exploit.py; cat) | ./server
Atak przez sieć¶
Aby osiągnąć ten sam efekt przez sieć (inetd) należy trochę poeksperymentować z rozmiarem bufora (wielkością NOP-slide’a) i adresem pod który skaczemy. Następnie wystarczy użyć netcata (polecenie nc) do interakcji ze zdalnie uruchomionym serwerem, czyli później powłoką. W tym celu należy odpalić:
nc -c "./exploit.py;socat STDIO OPEN:/dev/tty" adres_serwera port
Jeżeli wszystko dobrze pójdzie to efektem będzie wykorzystanie luki 20-linijkowego programu do przejęcia kontroli nad zdalnym komputerem.
Niewykonywaly stos (NX)¶
Jednym z mechanizmów które mają utrudnić tego typu ataki jest oznaczenie które strony pamięci nie mogą być wykonywane (flaga NX). W szczególności stos normalnie nie zawiera kodu programu, więc może być tak oznaczony. To jednak nie uniemożliwia ataku, a jedynie trochę go utrudnia - nie możemy skoczyć bezpośrednio do wysłanego payloadu, ale możemy skorzystać ze wszystkiego co już w pamięci jest załadowane i oznaczone jako wykonywalne - w szczególności biblioteki standardowej (bezpośrednio, lub przez PLT).
Na architekturze i386 można po prostu przygotować w miejscu adresu powrotu
skok do np. funkcji system()
z libc i kawałek dalej umieścić argumenty.
Potrzeba tutaj adresu napisu /bin/sh
lub innego równie użytecznego.
Ponieważ dokładny adres stosu nie jest znany, to nie można (wprost) samemu
podrzucić tego napisu. Ale chwila poszukiwania pokaże że w bibliotece
standardowej znajduje się potrzebny napis:
$ xxd /lib64/libc.so.6|grep -A 1 /bin
01841a0: 6974 7900 6e61 6e00 2d63 002f 6269 6e2f ity.nan.-c./bin/
01841b0: 7368 0065 7869 7420 3000 4d53 4756 4552 sh.exit 0.MSGVER
W przypadku architektury x86_64 jest to trochę bardziej skomplikowane, ponieważ początkowe parametry przekazuje się przez rejestry. Ale znów korzystając z tego co już w pamięci jest wykonywalne, można poszukać zestawów instrukcji ładujących dane ze stosu do rejestrów. Ta technika jest znana jako Return Oriented Programming, ponieważ poszukiwane zestawy instrukcji (tzw. gadżety) będą najczęściej postaci:
pop ...
ret
W ten sposób umieszczając na stosie na zmianę adres gadżetu i dane możemy
załadować pożądane dane. Czasem gadżety mogą być bardziej złożone, ale do
znalezienia najprostszych możemy przygotować plik źródłowy z poszukiwanymi
instrukcjami (gadgets.s
), skompilować, a następnie przeszukać
pamięć procesu przy pomocy funkcji searchmem
z dodatku peda do GDB:
$ objdump -d gadgets.o
(...)
0000000000000000 <main>:
0: 5f pop %rdi
1: c3 retq
2: 5e pop %rsi
3: c3 retq
gdb-peda$ searchmem "\x5f\xc3"
Searching for '_\xc3' in: None ranges
Found 543 results, display max 256 items:
server : 0x4007a3 (<__libc_csu_init+99>: pop rdi)
server : 0x6007a3 --> 0x841f0f2e6666c35f
libc : 0x7ffff7a3edd5 (<iconv+165>: pop rdi)
libc : 0x7ffff7a3ee02 (<iconv+210>: pop rdi)
(...)
gdb-peda$ searchmem "/bin/sh"
Searching for '/bin/sh' in: None ranges
Found 1 results, display max 1 items:
libc : 0x7ffff7ba21ab --> 0x68732f6e69622f ('/bin/sh')
Przykład wykorzystujący tą technikę znajduje się w pliku exploit-rop.py
.
Stack protector i randomizacja przestrzeni adresowej (ASLR)¶
GCC ma opcję -fstack-protector
, która powoduje, że gcc przy rozpoczynaniu
wykonywania każdej funkcji umieszcza na stosie “strażnika” czyli z góry
określony numerek, który jest sprawdzany przed wyjściem z funkcji czy nie
został zmieniony. Jednak jeśli w programie znajduje się błąd pozwalający wyciec
fragment pamięci zawierający “strażnika”, to ten mechanizm staje się bezużyteczny.
Tak się składa że testowy program server.c
zawiera taki błąd - mamąc
kontrolę nad napisem formatującym dla printf()
, można wypisać dowolny
fragment pamięci. Przykład znajduje się w exploit-rop-stack-protector.py
.
Kolejnym mechanizmem utrudniającym ataki, jest randomizacja przestrzeni
adresowej (ASLR). Tu z kolei utrudnia się poznanie adresów pod które można by
dogodnie skoczyć. I ponownie istnienie błędu pozwalającego odczytać fragment
pamięci wystarczy do obejścia tego mechanizmu - mając dowolny adres z
przestrzeni adresowej procesu można sprawdzić co tam powinno być i na tej
podstawie policzyć pozostałe adresy. Przykład znajduje się w
exploit-rop-stack-protector-aslr.py
.
Format string¶
Nie tylko przepełnienie bufora jest niebezpiecznym błędem. Również jak było
widać wcześniej danie kontroli nad napisem formatującym dla printf()
bardzo
ułatwia ataki. Ale taki błąd sam w sobie może posłużyć do przejęcia kontroli
nad programem. Po pierwsze w pamięci mogą znajdować się wrażliwe dane (np.
hasła), a po drugie, format %n
pozwala modyfikować pamięć. Wykorzystanie
tego ułatwia fakt że dodatkowo można pobierać szerokość pola z parametrów do
printf (np %*d
), oraz że można podawać numer parametru do użycia (np.
%4$d
, albo %*5$d
- można też łączyć: %4$*5$d
). W rezultacie można z
tego złożyć prymityw kopiujący dane na stosie:
- odczytujemy dane wypisując odpowiednio szerokie pole: %*15$d
- następnie tak “wczytaną” liczbę można zapisać przez %23$n
Jeśli tylko na stosie znajdują się interesujące adresy (lub da się je przy pomocy powyższego poskładać), można w praktyce dowolnie modyfikować pamięć.
Przykładowy program do eksperymentów znajduje się w login.c
. Jest to proste
narzędzie które sprawdza hasło z zaszytym na stałe skrótem i jeśli się zgadza,
to uruchamia powłokę. W zamyśle jest zainstalowane z ustawionym bitem SUID.
Dodatkowo żeby było przyjaźniejsze dla użytkownika, można podać własny tekst
pytania o hasło. Celem ataku jest oczywiście ominięcie hasła. Aby tego dokonać,
trzeba znaleźć na stosie adres zmiennej odpowiedzialnej za wpuszczenie
użytkownika, a następnie podać taki parametr, aby nadpisać tą zmienną.
Morał¶
Na powyższych przykładach widać jak bardzo niebezpiecznym jest niechlujne programowanie. Pomimo tego, że jest to przykład na potrzeby przedmiotu, to mechanizm jest powszechny, stosowany i wszechobecny. Raporty bezpieczeństwa z portali internetowych typu Secunia to potwierdzają.
Czy można jakoś się przed tym bronić? Jest wiele metod, niestety jedynie utrudniają one pracę hakerom a nie uniemożliwiają:
- randomizacja stosu (początek stosu jest w każdym procesie gdzie indziej)
- randomizacja całej przestrzeni adresowej - również miejsca mapowania bibliotek i miejsca ładowania programu
- NX bit (nowsze procesory pozwlają na oznaczenie fragmentu przestrzeni wirtualnej (stosu) jako Non-eXecutable, co spowoduje błąd przy próbie uruchomienia payload’a
- gcc ma opcję
-fstack-protector
, która powoduje, że gcc przy rozpoczynaniu wykonywania każdej funkcji umieszcza na stosie “strażnika” czyli z góry określony numerek, który jest sprawdzany przed wyjściem z funkcji czy nie został zmieniony - gcc i glibc ma ochronę przez atakami typu format string, aktywowaną przez
opcję kompilacji
-D_FORTIFY_SOURCE=2
- rozwiązywanie wszystkich relokacji wcześnie i przełączanie całego GOT i PLT tylko do odczytu
Dosyć dobrym sposobem unikania błędów związanych z zarządzaniem pamięcią (w tym buffer overflow) jest stosowanie języków które same o to dbają takich jak Java, Python, itp. Oczywiście nie uchroni to przed błędami w samej implementacji takiego kompilatora/interpretera.
Wskazówki¶
aby wyłączyć randomizację stosu należy zmienić ustawienia jądra:
sysctl -w kernel.randomize_va_space=0
aby dodać do inetd serwer udający ftp na porcie 21 należy dodać linijkę:
ftp stream tcp nowait root /root/server
dla wersji
xinetd
analogiczna konfiguracja będzie wymagać/etc/xinetd.d/ftp
:service ftp { disable = no id = ftp wait = yes socket_type = stream user = root group = root server = /root/server #server_args = }
przy pracy z gdb bardzo przydatny jest dodatek “peda” (Python Exploit Development Assistance for GDB) - https://github.com/longld/peda
w payload-dzie trzeba uważać na białe znaki -
scanf()
przestanie wczytywać dalszy ciąg znaków na pierwszym takim znaku