Stack based buffer overflow
Stack based buffer overflow - Wstęp teoretyczny i (nie do końca) praktyczna demonstracja
Wstęp
Buffer overflow to powszechnie znana podatność wśród ludzi zainteresowanych cyberbezpieczeństwem oraz programistów. Błędy, takie jak “Segmentation fault” lub “Access violation” są skutkiem złego zarządzania pamięcią, a także świadczą o poważnych lukach bezpieczeństwa. W bazach podatności CVE co jakiś czas pojawiają się nowe, krytyczne luki z hasłem “buffer overflow” w opisie (CVE-2023-41028, CVE-2023-6888, CVE-2023-6314, CVE-2023-7024).
Co to jest?
Czym tak właściwie jest atak wykorzystujący buffer overflow? Mówiąc ogólnie, jest to technika ataku, która polega na wprowadzeniu do bufora większej ilości danych, niż jest on w stanie pomieścić. Można przez to zapisać dane do pamięci, do której nie powinniśmy mieć dostępu. Brzmi to jak coś, co jedynie może doprowadzić do błędu i zakończenia działania programu, nie mając większego wpływu na bezpieczeństwo (może poza wpływem na dostępność aplikacji). Jeśli jednak użyje się tego mechanizmu z odrobiną wiedzy, można znacząco wpłynąć na bezpieczeństwo całego systemu.
Możliwe skutki ataku buffer overflow są dość zróżnicowane, ale zwykle są to awarie aplikacji lub całego systemu operacyjnego, zmiana kolejności wykonywania programu (np. poprzez wywołanie żądanej funkcji w programie) lub doprowadzenie do wycieku danych z pamięci. Możliwe staje się również wykonanie własnego kodu poprzez shellcode, a to umożliwia m.in. przejęcie kontroli nad systemem (np. uzyskanie uprawnień superużytkownika lub wstrzyknięcie złośliwego oprogramowania).
Niestety, podczas omawiania buffer overflow nie da się uciec od podstawowej wiedzy programistycznej i dość technicznych aspektów, dotyczących struktury pamięci podczas wykonywania programu.
Struktura pamięci
System uruchomi najpierw funkcję main, ale cały program (proces) będzie znajdować się w pamięci i będzie miał określoną strukturę składającą się z kilku określonych obszarów, zwykle takich samych dla różnych procesów.
Zaalokowaną pamięć dla procesu możemy sobie wyobrazić jako blok adresów o pewnym rozmiarze - większym lub mniejszym w zależności od potrzeb różnych programów. W tej zaalokowanej pamięci, patrząc od najwyższych adresów, mamy obszary:
- text - obszar zawiera kod maszynowy. Ten obszar pamięci jest zazwyczaj ustawiony w trybie tylko do odczytu i wykonania,
- data - przechowuje zainicjowane zmienne statyczne i globalne,
- bss - przechowuje niezainicjowane zmienne statyczne i globalne,
- heap (sterta) - zawiera dynamicznie tworzone zmienne. Zwykle, w miarę dodawania nowych danych, ich adresy rosną, jednak nie jest to regułą. W rzeczywistości zależy to od implementacji środowiska uruchomieniowego lub od zastosowania recyklingu pamięci,
- stack (stos) - zawiera zmienne lokalne oraz dane związane z wywoływaniem funkcji. Wraz z dodawaniem nowych danych adresowane są malejąco,
- kernel - obszar wykorzystywany przez jądro systemu operacyjnego i oddzielony od przestrzeni użytkownika.
Stos
Ponieważ większość ataków przepełnienia bufora występuje w stosie, przyjrzyjmy mu się z bliska. Mówiąc ogólnie, stos to struktura danych oparta na zasadzie LIFO (Last-In-First-Out), co oznacza, że dane, które umieściliśmy jako ostatnie, zostaną z niego zwolnione jako pierwsze. Pamięć na stosie jest alokowana liniowo, przechowując dane w jednej, uporządkowanej sekwencji. To właśnie między innymi dlatego stos jest wykorzystywany do zarządzania wywołaniami funkcji w programie.
Podczas uruchomienia programu wywoływana jest funkcja main. W miarę wywoływania nowych funkcji tworzone są ich ramki (stack frame), które trafiają na stos, a ich adresy są coraz niższe. W ramkach znajdują się m.in.:
- zmienne lokalne - zmienne istniejące tylko podczas wykonywania funkcji i usuwane po jej zakończeniu,
- Poprzedni EBP/RBP - przechowuje wartość rejestru EBP/RBP z poprzedniej ramki stosu,
- adres powrotny - adres instrukcji, jaką należy wykonać po zakończeniu funkcji (zazwyczaj jest to powrót do funkcji wywołującej),
- parametry przekazywane do funkcji.
Ważną kwestią związaną z atakami buffer overflow są rejestry procesora. Przyjrzymy się kilku rejestrom procesora w architekturze x86. W zależności od tego, czy system jest 64, czy 32-bitowy, rejestry będą się inaczej nazywać. W 64-bitowym systemie rejestry będą zaczynać się od litery R, natomiast te w systemach 32-bitowych będą na literę E. W naszym przypadku najważniejsze są:
- EIP/RIP - rejestr przechowujący adres następnej wykonywanej instrukcji,
- ESP/RSP - rejestr wskazuje wierzchołek stosu,
- EBP/RBP - rejestr wskazujący na początek aktualnej ramki.
Stack based buffer overflow
Jeśli udało Ci się przebrnąć przez część teoretyczną, gratulacje! Po tym krótkim wstępie możemy wrócić do głównego bohatera tego artykułu, czyli buffer overflow.
Przykładowy scenariusz ataku wygląda następująco: mamy pewien program zawierający bufor, który przechowuje dane wpisane przez użytkownika. Program używa niezabezpieczonej funkcji scanf, aby pobrać dane i umieścić je w buforze. Funkcja scanf nie ma żadnych zabezpieczeń sprawdzających, czy doszło do przekroczenia bufora podczas wpisywania danych (poprzez ustawienie parametru %s odczyt znaków będzie trwał aż do napotkania znaku białego).
#include <stdio.h>
int foo()
{
char buffer[10];
printf("Enter data: ");
scanf("%s", buffer);
printf("Your data: %s", buffer);
return 0;
}
void mal_fun()
{
printf("\nExecution of a non-called function!\n");
}
int help_fun()
{
asm("jmp *%esp");
}
int main()
{
foo();
return 0;
}Podczas kompilacji należy ustawić odpowiednie opcje, aby wyłączyć poniższe zabezpieczenia:
- Stack canary - podstawowe zabezpieczenie przed przepełnieniem bufora. Jest to dodatkowa wartość dodawana do stosu i zmieniana z każdym uruchomieniem programu. Przed powrotem z funkcji wartość jest sprawdzana i jeśli doszło do modyfikacji, program jest przerywany,
- NX (No eXecute) lub DEP (Data Execution Prevention) - zabezpieczenie pozwala oznaczyć obszary pamięci jako przeznaczone tylko na dane, bez możliwość ich wykonania,
- PIE (Position Independent Executables) - umożliwia randomizację adresów w pamięci przy każdym uruchomieniu programu.
Poniższy screen pokazuje, jak wygląda polecenie kompilacji kodu z flagami, umożliwiającymi wyłączenie zabezpieczeń. W ramach uproszczenia, kod został skompilowany w wersji 32-bitowej.
W realnym scenariuszu takie ustawienia pliku wykonywalnego raczej się nie zdarzają i trzeba je obchodzić na różne sposoby, na przykład przy użyciu ROP (Return Oriented Programming), Return-to-libc lub Stack Canary Leaking, ale celem zrozumienia podstaw możemy trochę uprościć nasz przykład.
W sytuacji, kiedy do bufora wprowadzimy więcej danych, niż jest przewidziane, istnieje ryzyko awarii programu, szczególnie jeśli te dane zastąpią ważne informacje. Jeżeli na przykład nadpiszemy adres powrotny danymi, których nie uda się zinterpretować jako nowy istniejący adres, w pamięci nastąpi awaria. Jeśli jednak dobrze podmienimy adres powrotny, możemy zmienić przebieg programu, ponieważ zamiast powrócić do funkcji nadrzędnej (w tym przypadku main), program wykona instrukcje we wskazanym przez nas miejscu.
Pierwszym problemem w naszym przykładzie jest to, że dokładne adresy bufora i adresu powrotnego nie są nam znane, ponieważ są dynamicznie przydzielane w zależności od dostępnego miejsca w pamięci. Dużym ułatwieniem jest jednak to, że za każdym razem oba adresy będą oddalone o tą samą liczbę bajtów względem siebie (offset). Dlatego, żeby zwiększyć uniwersalność naszego ataku, pierwszym etapem powinno być ustalenie offsetu, który posłuży do zbudowania payloadu. W tym celu możemy użyć gdb-pwndbg i polecenia cyclic, żeby wygenerować cykliczny wzór znaków.
W naszym przypadku offset wynosi 22 bajty, a to oznacza, że aby nadpisać adres powrotny, trzeba umieścić w buforze 22 znaki plus nowy adres, do którego chcemy się przenieść po zakończeniu funkcji.
Przykład 1: Zmiana przebiegu programu poprzez wywołanie niezamierzonej funkcji
W pierwszym przypadku naszym celem jest przepełnienie bufora w taki sposób, żeby zmienić przebieg wykonywania programu. Skupimy się na tym, żeby wywołać funkcję mal_fun(), która jak wynika z kodu podanego powyżej, nie jest wywoływana nigdzie w kodzie - ani w funkcji main, ani w foo. Żeby to zrobić, adres powrotny trzeba zastąpić adresem funkcji, a żeby uzyskać adres funkcji mal_fun, użyjemy jeszcze raz programu gdb-pwndbg.
Adres funkcji to 0x080491FC. Mając adres i offset, możemy stworzyć payload, używając krótkiego skryptu w pythonie:
from pwn import *
from pathlib import Path
padding = 22
fun_addr=0x080491fc
payload = flat
(
b"A" * padding,
fun_addr
)
Path("payload").write_bytes(payload)Payload składa się z 22 znaków A (bo taki jest offset) i z adresu funkcji, do której chcemy przeskoczyć. Używam funkcji flat z biblioteki pwntools, która ułatwia pisanie exploitów. Funkcja łączy dane w jeden ciąg bajtów, a adres funkcji automatycznie podaje w formacie little endian (najmniej znaczący bajt umieszczony jest jako pierwszy, np. 0x080491fc to w little endian 0xfc910408).
Payload składa się ze znaków niedrukowalnych (non printable characters), więc przekazujemy je bezpośrednio z pliku. Jak widać na powyższym screenie, funkcja mal_fun została wykonana, co potwierdza pomyślne wykonanie ataku. Zmiana przebiegu programu jest bardzo niebezpieczna, ponieważ umożliwia atakującemu m.in. ominięcie zabezpieczeń (na przykład poprzez ominięcie logowania) lub wyciek danych. Błąd, który występuje po wykonaniu funkcji, związany jest z tym, że funkcja mal_fun nie została wywołana przez instrukcję call, więc nie ma ustawionego adresu powrotnego.
Przykład 2: Wykonanie własnego kodu
Drugi przypadek ataku ma na celu nadpisanie bufora tak, aby uruchomić własny shellcode, czyli prosty, niskopoziomowy kod programu w postaci kodu maszynowego, odpowiedzialny za wywołanie powłoki systemowej. Sam atak jest bardzo podobny do pierwszego. Jedynymi różnicami są inaczej skonstruowany payload i ustawienie adresu powrotnego tam, gdzie umieścimy własny kod. Celem będzie odczyt zawartości pliku secret.txt, do którego nie mamy uprawnień.
Shellcode bardzo często będzie dłuższy niż bufor. Dlatego możemy kod umieścić za adresem powrotnym. Nie wiadomo jednak, do jakiego adresu się później zwrócić. Żeby atak był bardziej uniwersalny, możemy zastosować wspomniany wcześniej ROP, a mówiąc bardziej szczegółowo, instrukcję JMP ESP. ROP (Return Oriented Programming) to sposób łączenia ze sobą małych fragmentów istniejącego kodu (tzw. gadżetów), żeby zbudować zestaw bardziej zaawansowanych instrukcji wykonujących złośliwy kod. Jeśli chodzi o JMP ESP, to jest to instrukcja, która przenosi nas do wierzchołka stosu, gdzie zapisujemy shellcode. Dodatkową zaletą jest to, że atak staje się bardziej uniwersalny (nie hardkodujemy adresu z payloadem). Funkcja help_fun została dodana w celu udostępnienia instrukcji jmp esp, żeby skupić się na wyjaśnieniu podstaw i żeby uprościć przykład. W normalnym scenariuszu jmp esp może być już wcześniej zawarta podczas wykonywania jakichś operacji w programie lub w bibliotece współdzielonej (DLL), z której program korzysta. Co, jeśli instrukcji nie będzie? Wtedy można poszukać JMP EBP i shellcode umieścić w buforze lub zastosować inne gadżety.
Użyjemy prostego narzędzia ropper, żeby przeanalizować nasz program i znaleźć adres instrukcji przenoszącej nas do ESP.
Następnym krokiem jest stworzenie shellcodu, którego użyjemy do ataku. Narzędzie MSFvenom oferuje szybkie i proste tworzenie payloadu. Określamy jaki rodzaj ataku chcemy przeprowadzić - w naszym przypadku jest to odczyt z pliku na systemie Linux - podajemy ścieżkę do pliku, ustalamy jakich znaków chcemy uniknąć w payloadzie oraz wybieramy format danych wyjściowych. Pewne znaki mogą spowodować problemy z działaniem shellcodu (tzw. bad characters). Są to m.in.:
- 00 - (null)
- FF - nowy wiersz (\n)
- 0A - cofnięcie kursora do początku aktualnej linii tekstu bez przechodzenia do nowej linii (\r - carriage return)
- 0D - zaznacza, że strona tekstu została zakończona i należy przejść do nowej (\f)
Mają specjalne funkcje i przez to sprawiają, że shellcode przestaje działać. Należy ich unikać, dlatego użyjemy MSFvenom, żeby zakodował te znaki.
from pwn import *
from pathlib import Path
offset = 22
jmp_esp=0x08049234
buf = b""
buf += b"\xb8\xf1\x7b\xf2\x50\xd9\xe8\xd9\x74\x24\xf4\x5a"
buf += b"\x33\xc9\xb1\x15\x31\x42\x14\x83\xea\xfc\x03\x42"
buf += b"\x10\x13\x8e\x19\x66\x6b\x74\xde\x87\x8b\x2c\xef"
buf += b"\x4e\x46\x52\x86\x92\xe0\x50\x99\x14\x10\xde\x7e"
buf += b"\x9d\xe9\x5a\x80\x8e\x09\x9b\x4c\x2e\x80\x59\xf6"
buf += b"\x2b\x92\x5d\x07\x8f\x93\x5d\x07\xef\x5e\xdd\xbf"
buf += b"\xee\x60\xde\xbf\x4b\x60\xde\xbf\xab\xad\x5e\x57"
buf += b"\x6e\xd2\xa0\x57\x35\x43\x3c\xdd\xdb\xfe\xac\x69"
buf += b"\x57\x2f\x42\xf7\xf4\x5d\xc1\x83\xd4\xd5\x71\x18"
buf += b"\x29"
payload = flat
(
asm('nop') * offset,
jmp_esp,
asm('nop') * 16,
buf
)
Path('payload').write_bytes(payload)Skrypt tworzący payload jest dość podobny do tego w poprzednim przykładzie. Składa się z 22 znaków NOP, adresu gdzie znajduje się instrukcja jmp esp, z 16 znaków NOP i shellcodu wygenerowanego przez MSFvenom. NOP (NO Operation) to instrukcja (w asemblerze jest reprezentowana przez wartość \x90), która jedyne co robi, to przenosi do następnej instrukcji. Jest często używana podczas tworzenia exploitów, ponieważ pozwala na większy margines błędu przy ustawianiu adresu powrotu i odporność na awarię programu.
Uruchomienie programu i przekazanie zawartości pliku z payloadem pozwoliło na odczyt pliku, do którego nie mamy uprawnień. W ten sposób możemy spowodować wiele szkód. Reverse shell, wykonanie programów, stworzenie stagerów, czy wykorzystanie meterpretera to tylko kilka z wielu zagrożeń, które oferuje wykonanie kodu poprzez przepełnienie bufora.
Ochrona przed przepełnieniem bufora
Dlaczego nadal dochodzi do ataków buffer overflow? Powodów jest kilka: istnienie starszych programów (legacy code), które używają starych, niezabezpieczonych funkcji (m.in. strcpy, gets, strcat, scanf), pomimo że istnieją nowsze, bezpieczniejsze alternatywy. Niestety, nawet te świeższe funkcje nie są całkowicie odporne na ataki. Problematyczna jest również złożoność nowych programów oraz dość zróżnicowane sposoby implementacji samego ataku.
Skoro już dowiedzieliśmy się, jak w uproszczeniu przebiegają takie ataki pora, żeby poznać mechanizmy, które przed nimi chronią. Podczas oceny ryzyka zawsze warto najpierw zadać sobie pytanie, czy nasz system jest narażony na tego typu ataki. Programy pisane przy użyciu języków niskopoziomowych, takich jak: C, C++, Assembly czy Fortran są najbardziej podatne ze względu na to, że pozwalają na bezpośrednią manipulację pamięcią i niewystarczającą kontrolę nad wprowadzanymi danymi. Programy pisane przy użyciu języków wysokopoziomowych, automatycznie zarządzających pamięcią (Python, Ruby lub Java) są mniej narażone, co nie znaczy, że są całkowicie odporne. Warto śledzić doniesienia branżowe i aktualizować podatne systemy, biblioteki. Podczas pisania kodu należy testować program, aby upewnić się, czy zbyt duże dane wejściowe są poprawnie obsługiwane.
Zastosowanie zabezpieczeń jak wcześniej wspomniane Stack Canary, NX czy PIE jest proste w implementacji i znacząco zwiększa odporność systemu. W ostatnich latach na popularności zyskuje koncepcja shadow stacks. Jest to mechanizm, który umieszcza adresy powrotne na specjalnym, osobnym stosie. Warto wspomnieć również o Relocation Read-Only (RELRO), które umożliwia ustawienie pewnych sekcji programu w pamięci tylko do odczytu. Ceną obu tych rozwiązań jest zmniejszona wydajność.
Podsumowanie
Podsumowując, ataki buffer overflow są niezwykle niebezpieczne, jednak podatność jest trudna do wykrycia i do wykorzystania ze względu na wymaganą wiedzę oraz wiele mechanizmów zabezpieczających. Warto wziąć je pod uwagę podczas projektowania, testowania i oceny ryzyka. Choć nie zdarzają się często, nadal stanowią duże zagrożenie i nie widać, żeby miało to ulec zmianie w najbliższym czasie.
Źródła
- https://en.wikipedia.org/wiki/Stack-based_memory_allocation
- https://owasp.org/www-community/vulnerabilities/Buffer_Overflow
- https://cwe.mitre.org/data/definitions/121.html
- https://vilya.pl/comptia-buffer-overflows/
- https://www.geeksforgeeks.org/memory-layout-of-c-program/
- https://owasp.org/www-chapter-pune/meetups/2019/August/Buffer_overflow_by_Renuka_Sharma.pdf
- https://ctf101.org/binary-exploitation/stack-canaries/
- https://www.varonis.com/blog/stack-memory-3
- https://ir0nstone.gitbook.io/notes/types/stack
- https://www.hacksplaining.com/prevention/buffer-overflows
- https://avinetworks.com/glossary/buffer-overflow/
- https://www.geeksforgeeks.org/stack-vs-heap-memory-allocation/