Zajęcia 9: exploit

Data: 09.05.2019

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ęci
  • gcc: 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ę stos
  • netcat (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