Taky vám přetéká?

27. 6. 2002
Doba čtení: 6 minut

Sdílet

"Jak velké udělat to pole? Dvě stě bajtů stačí, přece chci od uživatele, aby zadal dvuznakový parametr. Kdo bude tak blbý, aby tam mlátil text přes tři řádky?" -- Třeba takto, z pouhé nedbalosti, se v programu zrodí bezpečnostní díra, kterou jednou využijí "škaredí" chlapci a holky k útoku na systém.

V tomto článku se budeme zabývat problémem přetečení bufferu. Pokusím se i ne příliš zkušeným programátorům vysvětlit, o co jde a jak se tohoto nešvaru vyvarovat.

Přetečení bufferu (buffer-overflow) je poměrně častou technikou, jak se nabourat do systému. Jednotlivé přetékance se dají rozškatulkovat podle toho, v které části paměti k nim došlo: my se budeme zabývat buffer overflowy vznikajícími na zásobníku, protože jsou nejčastější. (To, že se v následujícím textu budeme bavit výhradně o architektuře Intel x86 osedlané Linuxem, neznamená, že ostatní systémy jsou nedotknutelné. Právě naopak, buffer-overflow je multiplatformní. Dá se provozovat i jinde.)

V rychlosti o zásobníku

Všechno, o čem bude na následujících řádcích řeč, se odehrává na zásobníku. Není tedy od věci si trochu připomenout, co to vlastně ten zásobník je a jak funguje. Začneme trochu zeširoka o struktuře procesu v paměti.

+-----------------------+ MAX
|                       |
|       zásobník        |
|                       |
+-----------------------+
|                       |
| neinicializovaná data |
|                       |
|  inicializovaná data  |
|                       |
+-----------------------+
|                       |
|     kód programu      |
|                       |
+-----------------------+ MIN

Každý spuštěný proces vypadá zhruba tak, jako na obrázku. Na nejnižších adresách je umístěn kód programu, nad ním inicializovaná a neinicializovaná data (globální proměnné) a úplně nahoře (v nejvyšších adresách) zásobník.

Zásobník je něco, co se používá takřka na všech počítačových architekturách. Umožňuje odkládat data, abychom se k nim mohli později vrátit. Začátek zásobníku je na nejvyšší adrese a s tím, jak do něj ukládáme data, roste směrem dolů. Na architekturách Intel x86 ukazuje vrchol zásobníku (u zásobníku je vše obráceně, vrcholem se tedy rozumí nejvyšší ještě volná buňka paměti – těsně pod posledně vložených datech) registr zvaný Stack Pointer (SP).

Do zásobníku se ukládají (PUSH) a z něj se vyzvedávají (POP) data. Ukládaná hodnota se kopíruje na adresu, na kterou ukazuje Stack Pointer, a poté se Stack Pointer dekrementuje, aby neustále ukazoval na první volné místo. Při vyzvedávání dat se postupuje obráceně.

Zásobník je důležitý zejména při volání funkcí. Před zavoláním podprogramu se na zásobník uloží parametry funkce, návratová adresa (adresa instrukce následující těsně za voláním funkce), starý Base Pointer (BP) a pak lokální proměnné dané funkce. Po návratu z funkce se všechny hodnoty obnoví, parametry odstraní a provede se skok na uloženou návratovou adresu, takže hlavní program může pokračovat přesně tam, kde skončil. Tímto mechanizmem je pochopitelně automaticky vyřešeno i vnořené nebo dokonce rekurzivní volání funkcí.

Zpátky k buffer-overflowům

Samotný princip přetečení bufferu je poměrně jednoduchý. V podstatě jde o to, že špatně napsaný program donutíme načíst do paměti více dat, než na kolik je stavěn. Veškeré útoky v tomto stylu vycházejí z nějaké chyby v programu a taky z toho, že počítač udělá PŘESNĚ to, co mu řekneme, ať už je to cokoliv.

Zpracovávané údaje se obvykle načítají do nějakého bufferu, čili pole bajtů, intů, charů a podobně. Pokud má program vstupy špatně ošetřené, můžeme mu předhodit více dat, než se do jeho bufferu vejde a přepsat tak paměť těsně za tímto bufferem. Toho se dá různými způsoby zneužít k napadení systému. Pokud budeme mít štěstí, oblast paměti za přetečeným bufferem bude patřit proměnné uchovávající heslo nebo název nějakého souboru, kterou takto přepíšeme na nějakou jinou, „naši“ hodnotu.

Celá věc se ovšem musí provést opatrně, abychom se nepokusili zapisovat do paměti, ke které nemáme přístup (tedy za hranice zásobníku), jinak se se zlou potážeme a jádro na nás zakřičí své ohrané  Segmentation fault.

Ukázka přetečení bufferu

Že je něco takového vůbec možné, si ukážeme na tomto příkladu:

/* soubor overflow.c */

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

int main(int argc, char* argv[]) {
  char a[8] = "aaaaaaa";
  char b[8] = "bbbbbbb";

  printf("Pred:\na = %s\nb = %s\n", a, b);
  memcpy(b, "BBBBBBBBBBBB", 12);
  printf("Po:\na = %s\nb = %s\n", a, b);
  return 0;
}

Výstupem programu bude:

$ gcc overflow.c -o overflow
$ ./overflow
Pred:
a = aaaaaaa
b = bbbbbbb
Po:
a = BBBBaaa
b = BBBBBBBBBBBBaaa
$

V ukázce definujeme dvě osmibajtová pole znaků, a a b, která jsme zároveň i inicializovali. Provede se kontrolní výstup, který ověří, že v polích jsou skutečně naše hodnoty. Poté se pokusíme do pole b zapsat 12 bajtů a opět provedeme kontrolní výstup.

Vidíme, že ačkoliv jsme příkazem memcpy přistupovali pouze k bufferu b, dosáhli jsme i změny obsahu bufferu a. Co přesně se stalo? Jak už víme, každý proces má vyhrazenu část volné paměti, tzv. zásobník. Na zásobník se ukládají lokální proměnné, návratové adresy pro vyskočení z podprogramu a podobné věci.
Po spuštění programu se na zásobník uloží argumenty příkazové řádky a proměnné prostředí a zavolá se funkce main(). Volání main u je voláním podprogramu, takže se na zásobník strčí návratová adresa z funkce main() a obsah registru BP (base-pointer). Samotná funkce main() si na zásobník uloží své lokální proměnné ab.

Stav paměti zásobníku před vykonáním prvního příkazu funkce main() ukazuje následující obrázek.

+-----------------------------------+ MAX
|                ...                |
|  proměnné prostředí a argumenty   |
|              programu             |
|                ...                |
+-----------------------------------+
| návratová adresa (ret) - 32 bitů  |
+-----------------------------------+
| starý base pointer (BP) - 32 bitů |
+-----------------------------------+
|        char a[8] - 8 bajtů        |
|                                   |
+-----------------------------------+
|        char b[8] - 8 bajtů        |
|                                   |
+-----------------------------------+
|                ...                | <-- Stack Pointer
|            volné místo            |
|                ...                |
+-----------------------------------+ MIN

Protože zásobník roste směrem dolů od nejvyšší adresy k nižším a lokální proměnné se na něj ukládají postupně, bude se buffer b nacházet těsně pod polem a.

Pochopitelně zápis dvanácti bajtů do osmibajtového pole nutně povede k „přetečení“ do paměti nad bufferem b, v našem případě do pole a. Jazyk C totiž se svou pointerovou aritmetikou už z principu nemůže v době překladu odhalit zápis mimo rozsah pole, a tak ačkoliv příkazem  memcpy()

evidentně přistupujeme za hranice bufferu b, nic nepovoleného se neděje, protože stále pracujeme s pamětí, která je programu přidělena.

Detail situace v místě uložení polí a a b:

Před přetečením:   Po přetečení:
     +---+             +---+
     | 0 |             | 0 |
     | a |             | a |
     | a |             | a |
     | a |  char a[8]  | a |
     | a |             | B |
     | a |             | B |
     | a |             | B |
     | a |             | B |
     +---+             +---+
     | 0 |             | B |
     | b |             | B |
     | b |             | B |
     | b |  char b[8]  | B |
     | b |             | B |
     | b |             | B |
     | b |             | B |
     | b |             | B |
     +---+             +---+

Toho se dá využít. Představme si, že máme následující (dost stupidní) program, který je chráněn jednoduchým systémem hesla. Díky fintě s přetečením bufferu param kontrolu hesla obejdeme.

/* soubor vulnerable1.c */

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


int main(int argc, char** argv) {
  char password[8] = "qwert"; /* pro jednoduchost takhle...*/
  char param[8] = "";
  char* user_pass;

  if (argc == 3) {
    /* dva argumenty */
    strcpy(param, argv[1]);
    user_pass = argv[2];
  } else if (argc == 2) {
    /* jeden argument */
    user_pass = argv[1];
  } else {
    /* zadny nebo vice nez dva argumenty */
    printf("Usage: %s [-X] <password>\n", basename(argv[0]));
    return 0;
  }

  if (strcmp(password, user_pass) == 0) {
    /* uzivatel zadal spravne heslo */
    puts("Welcome!");
  } else {
    /* heslo je spatne */
    puts("Wrong password!");
  }
}

Zranitelné místo programu je v okamžiku volání funkce strcpy(). Zkusme jej tedy využít:

bitcoin školení listopad 24

$ gcc vulnerable1.c -o vulnerable1
$ ./vulnerable1
Usage: vulnerable1 [-X] <password>
$ ./vulnerable1 -X qwert
Welcome!
$ ./vulnerable1 -X heslo
Wrong password!
$ ./vulnerable1 12345678heslo heslo
Welcome!
$

(Poznámka: to, že se buffery password a param nacházely opravdu těsně za sebou, je víceméně „náhoda“. Proměnné se na zásobníku kvůli efektivitě všelijak zarovnávají, takže se často stane, že je mezi nimi nějaká mezera. Na co se však můžeme vždy spolehnout, je pořadí jejich umístění – a to vlastně stačí.)

Myslím, že tohle by mohlo pro dnešek stačit. Příště se můžete těšit na mnohem zajímavější příklad: pokusíme se získat práva  root a.

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.