Skocz do treści

Buffer Overflow, część 1.

Buffer Overflow – klasyczny błąd, klasycyzm wpojony tak głęboko w trzewia IT, że kernel już sam się broni przed sukcesywną eksploitacją w większości przypadków.

Dzisiaj się nim zajmiemy, oj tak. Nim jednak przejdziemy stricte w zasieki specyfikacji błędu oraz jego wykorzystania – porozmawiajmy o buforze, z którego to się przelewa, kiedy jest przepełniony. Dlaczego się przelewa, co się przelewa?
A co to stos? A co to adresacja pamięci? No bo jak bufor to i jego stos, prawda? Jak stos to i coś co w nim jest, a jak się do tego odnieść? Wyciągnąć, coś wrzucić tam?

Od początku…

Całość, jeżeli chodzi o schemat pamięci aplikacji, prezentuje się następująco:

       PAMIĘĆ APLIKACJI

    ---------------------
    |                   |
    |       HEAP        |   DYNAMICZNA PAMIĘĆ
    |                   |
    ---------------------
    |                   |
    |       STACK       |   ZAPYTANIA FUNKCJI/LOKALNE ZMIENNE
    |                   |
    ---------------------
    |                   |
    |   STATIC/GLOBAL   |   GLOBALNE ZMIENNE
    |                   |
    ---------------------
    |                   |
    |       CODE        |   PODSTAWOWY KOD (INSTRUKCJE)
    |                   |
    ---------------------

Bufor jest miejscem w pamięci znajdującym się w hierarchii pamięci aplikacji zaraz po kodzie. Jest tym miejscem, które trzyma nasze globalne zmienne.

hint —————–
| Różnicą między lokalną zmienną, a globalną jest fakt iż zmienna lokalna jest ograniczona tylko do
| konkretnej funkcji. Jest zdefiniowana w tej funkcji i może być wywoływana tylko w tej funkcji.
| Globalna zmienna jest zdefiniowana w głównej funkcji (main) bądź poza funkcją i ten typ zmiennej
| może być wywołany w każdym momencie działania programu.
———————–

Następnie występuje stack, który jest po części bohaterem naszego przedstawienia. To tutaj dzieje się buffer overflow. To jest miejsce, gdzie lokalne zmienne i wywołania funkcji są składowane.

Heap to magazyn dynamicznie lokowanej pamięci. Ok, a gdzie te adresy pamięci?
Generalnie, gdy program jest kompilowany oraz wykonywany, wszystkie instrukcje programu znajdują swoje miejsce w pamięci aplikacji i właśnie wtedy adresy w pamięci są im przydzielane.
Jeżeli zdisasemblujemy program i popatrzymy na to co nam disasembler wypluje na ekran, zobaczymy coś w takiej postaci:

    0x0000000008001145 <+0>:     push   rbp
0x0000000008001146 <+1>: mov rbp,rsp
0x0000000008001149 <+4>: sub rsp,0x10
0x000000000800114d <+8>: lea rdi,[rip+0xeb0]
0x0000000008001154 <+15>: mov eax,0x0
0x0000000008001159 <+20>: call 0x8001030
0x000000000800115e <+25>: lea rax,[rbp-0xf]
0x0000000008001162 <+29>: mov rsi,rax
0x0000000008001165 <+32>: lea rdi,[rip+0xea3]
0x000000000800116c <+39>: mov eax,0x0
0x0000000008001171 <+44>: call 0x8001040
0x0000000008001176 <+49>: lea rax,[rbp-0xf]
0x000000000800117a <+53>: mov rsi,rax
0x000000000800117d <+56>: lea rdi,[rip+0xe8e]
0x0000000008001184 <+63>: mov eax,0x0
0x0000000008001189 <+68>: call 0x8001030
0x000000000800118e <+73>: mov eax,0x0
0x0000000008001193 <+78>: leave
0x0000000008001194 <+79>: ret

Ok, a dlaczego dzieje się buffer overflow?

Ano dlatego, że użytkownik bądź źle zaprojektowana funkcja zaalokowała daną większą niż przewiduje to wartość zdefiniowana przy zmiennej, co powoduje „przelanie” się danej poza za alokowany bufor i może nadpisać niektóre części pamięci, które były użyte do przechowywania danych użytych przez program, co powoduje ich niedostępność oraz zepsucie się programu.

Przykładowo jeżeli pragniemy, aby nasz program zapytał nas o imię, a prościej się nie da, niż:

#include <stdio.h>

int main() {
    char imie[15];  

    printf("Twe imie: ");
    scanf("%s", imie);

    printf("No cze %s!\n", imie);
    printf("Program zakonczony sukcesem!")
    return(0);
}

to w powyższym przykładzie mamy definicję głównej funkcji – int main() – tu się dzieje główna (main) akcja.
Mamy też prośbę o imię – printf(„Twe imie: „) – kawałek kodu programu, który sugeruje, czego program chce od użytkownika.
Instrukcja znajdująca się zaraz pod nią to – scanf(„%s”, imie). Dzięki niej wszystko co wpiszemy, po ukazaniu się naszym oczom napisu „Twe imie: ” będzie zapisane do zmiennej „imie”.
Wracając do początków naszego programu, owa zmienna została zadeklarowana odrobinę wyżej – char imie[15] – tutaj zaczyna się intro do naszego artykułu.
char imie[15] wyznacza nam obszar pamięci (bufor), ten obszar to 15 znaków (characters (char)). Jest to dobre rozwiązanie, na tyle na ile jesteśmy pewni, że nikt nie wpisze imienia dłuższego niż 15 znaków…
Linia, gdzie jesteśmy informowani o sukcesywnym zakończeniu programu istnieje tylko w celach testowych, na potrzeby tego artykułu. Jeżeli się wyświetli tuż przed planowanym zakończeniem programu – oznacza iż zakończył on się poprawnie, bez błędów.
Zaczynając:

/> ./imie
Twe imie: ABCDEFGHIJKLMNO
No cze ABCDEFGHIJKLMNO
Program zakonczony sukcesem!

W przypadku podania większej ilości znaków do bufora zadeklarowanej zmiennej imie:

/> ./imie
Twe imie: ABCDEFGHIJKLMNOPRSTUWXY
No cze ABCDEFGHIJKLMNOPRSTUWXY
[1] 47 segmentation fault (core dumped) ./imie

Jak widać na powyższym przykładzie – program nie zakończył się sukcesem, a wręcz przeciwnie – wysłał nam komunikat o błędzie segmentacji ze względu, że zamiast 15 zadeklarowanych znaków przesłaliśmy mu ich 24. 9 z nich przeskoczyło do innego zakresu adresacji pamięci, czego skutkiem było przerwaniem programu i wystrzelenie błędu.

Przypatrzmy się problemowi przy użyciu GNU Debuggera (gdb), w tym przypadku skorzystajmy z podatności strcpy, a więc napiszmy coś nowego wykorzystującego tą funkcje:

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

int main(int argc, char **argv) {
    char buffer[10];

    strcpy(buffer, argv[1])

    return(0);
}

Jak w pierwszym przykładzie, tak i tutaj definiujemy główną (main) funkcję i jej argumenty.
char buffer[10] tworzy zmienna o nazwie buffer i zapewnia jej wielkość buforu o pojemności 10 znaków (char).
strcpy(buffer, argv[1]) kopiuje nasz input i wkłada go do zmiennej „buffer”, a return(0) to nasz adres zwrotny.

Postarajmy się prześledzić ścieżkę działania programu w gdb:

/> gdb ./strcpy_buff

(gdb) run ABCDEF
Starting program: /home/user/Dev/C/BinExploitation/strcpy_buff aaaaaaaaaa
[Inferior 1 (process 61) exited normally]

Wychodzi na to, że po podaniu programowi jako parametr uruchomieniowy 6 znaków wszystko gra jak należy i program zakończył się normalnie.

Teraz wrzućmy w parametrze 19 znaków:

(gdb) run ABCDEFGHOJKLMNOPRUWXZ
Starting program: /home/user/Dev/C/BinExploitation/strcpy_buff ABCDEFGHOJKLMNOPRUWXZ

Program received signal SIGSEGV, Segmentation fault.
0x00007fff005a5857 in ?? ()

Ha! Mamy segmentation fault ze względu na to, że nasz adres zwrotny został nadpisany i program nie mógł kontynuować swojej pracy.

Żeby pokazać, jak adresacje pamięci są nadpisywane, wrzucmy wartości hexadecymalne, coś w stylu \xFF jakieś 50 razy:

(gdb) run $(python -c „print(‚\xFF’ * 50)”)
Starting program: /home/user/Dev/C/BinExploitation/strcpy_buff $(python -c „print(‚\xFF’ * 50)”)

Program received signal SIGSEGV, Segmentation fault.
0xffffffffffffffff in ?? ()

Ok, mamy błąd – teraz rzućmy okiem na rejestry:

(gdb) info registers
 rax            0x0      0
 rbx            0x0      0
  ----------------------------------
|rcx            0x7ffffffde220     |        140737488216608
|rdx            0x7ffffffdded6     |        140737488215766
 ----------------------------------
 rsi            0x6      6
 ----------------------------------
|rdi            0x7ffffffddea6     |        140737488215718
|rbp            0xffffffffffffffff |        0xffffffffffffffff
|rsp            0x7ffffffddec0     |        0x7ffffffddec0
|r8             0x7fffff798d80     |        140737479544192
|r9             0xffffffffffffffff |        -1
 ----------------------------------
 r10            0x5f     95
 r11            0x7fffff727c90              140737479081104
 r12            0x8001050                   134221904
 r13            0x7ffffffddf90              140737488215952
 r14            0x0      0
 r15            0x0      0
 ----------------------------------
|rip            0xffffffffffffffff |      0xffffffffffffffff
 ----------------------------------

Jak widać na powyższym obrazku – większość adresów pamięci zostało nadpisane FF.

Co teraz?

Dlaczego więc to przepełnienie bufora jest takie „ciekawe” (czyt. groźne)?

Buffer overflow jest bardzo niebezpieczny, kiedy podatna binarka bądź program jest w zakresie setuid, który pozwala uruchamiać programy na uprawnieniach innych użytkowników, przewaznie super-użytkowników (root). Kiedy tylko jesteśmy w stanie przepełnić bufor danymi przez nas kontrolowanymi, możemy wywołać funkcje systemowe odpowiednio stworzonym skryptem (shellcodem) na prawach użytkownika z grupy setuid, do której należy podatny program.

Kolejny wpis będzie w całości poświęcony ambitnej exploitacji podatnych na buffer overflow programów.
Poznamy rejestry, sposoby odnajdowania adresów pamięci, wykorzystywanie ich do niecnych celów oraz napiszemy kilka shellcodów.

Do zobaczenia!

Opublikowany wknowledge.split(delimiter)