ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Linux rootkit源码解析:内核攻防与系统调用劫持实战

Linux rootkit源码解析:内核攻防与系统调用劫持实战

简介:这是一份面向Linux内核安全初学者与系统编程爱好者的rootkit源码学习资料,聚焦内核模块开发与系统底层机制探究。资源以C语言源码为核心,配合Makefile构建脚本、Python辅助工具及说明文档,完整呈现了一个小型rootkit从编译到加载的基本流程,可用于理解模块隐藏、进程隐藏等典型功能的实现思路。压缩包共46个文件,整体约36KB,包含C源码、头文件、Makefile、Python脚本、Markdown说明及少量LaTeX论文素材,结构紧凑,适合逐文件研读。目前已有369人学习下载,说明其在入门实践群体中具有一定参考价值。读者可借此熟悉内核模块的编译加载方式、系统调用挂钩的基本手法,以及隐藏技术背后的核心逻辑,为后续深入学习内核安全、反rootkit检测或系统加固打下基础,也可作为课程实验与安全研究的对照样本。

1. 一份 Linux rootkit 源码能让你看清内核攻防的底牌

很多人第一次听说 Linux rootkit 源码,脑子里蹦出来的是“黑客工具”“木马”“不敢碰”。但如果你在做内核开发、安全研究或者系统运维,这份源码其实是一份极好的内核机制教材。它把 Linux 内核里那些平时藏在文档背后的东西——系统调用表、VFS 层、内核模块加载、/proc 文件系统——全部拉到台面上,让你看到它们是怎么被“劫持”和“伪装”的。这份资源适合三类人:想深入理解 Linux 内核行为的安全研究者、需要做主机入侵检测的运维工程师、以及正在学习内核模块编程的开发者。它不是一个能直接跑起来搞破坏的成品,而是一套需要你读懂、编译、调试、观察的代码集合。你拿到的是一份“解剖图”,不是一把“万能钥匙”。

2. 先搞懂 rootkit 在内核里动了哪些手脚

2.1 用户态与内核态的分界线

Linux 把内存分成用户空间和内核空间,用户程序跑在低权限的 Ring 3,内核代码跑在高权限的 Ring 0。rootkit 源码的核心价值,就是展示一个攻击者拿到 root 之后,如何从用户态“钻进”内核态,并在里面长期潜伏。常见做法是编译一个内核模块(.ko 文件),用insmod加载进去。模块一旦加载,就和内核其他代码平起平坐,能读写内核数据结构、注册钩子函数、甚至修改系统调用表。你读这份源码时,第一遍不要纠结每一行汇编,先找到模块入口函数module_init和出口函数module_exit,看它在初始化时干了什么、退出时有没有清理干净。这是判断一份 rootkit 源码质量的基本功。

2.2 系统调用表劫持的原理

系统调用是用户程序进入内核的唯一正规通道。Linux 内核里有一张表sys_call_table,里面按编号存放了所有系统调用处理函数的地址。rootkit 最经典的招式,就是找到这张表,把某个表项替换成自己的函数地址。比如把sys_read换成hacked_read,用户执行cat /etc/passwd时,实际调用的是 rootkit 的函数,它可以在返回数据前把特定行过滤掉。源码里通常会看到kallsyms_lookup_name这个函数,用来动态获取sys_call_table的地址,因为从内核 2.6 之后,这张表不再导出给模块直接使用。你需要在源码里找到这个查找逻辑,理解它为什么依赖kallsyms,以及不同内核版本下这个接口的变化。

2.3 文件隐藏与进程隐藏的实现路径

隐藏是 rootkit 的看家本领。文件隐藏一般通过劫持sys_getdents或sys_getdents64实现,因为ls、find这些命令最终都靠这个系统调用来读取目录项。源码里会定义一个过滤规则,比如文件名包含某个前缀就跳过不返回。进程隐藏则更复杂一些,有的通过劫持sys_kill让kill -0失效,有的直接修改task_struct链表把进程从/proc里摘出去。你在读这部分时,重点看它怎么判断“要隐藏的目标”,是硬编码文件名、PID,还是通过某种通信机制动态下发。这决定了这份源码是教学演示还是具备实战能力。

2.4 内核模块编译环境搭建

要编译这份源码,你需要一台 Linux 机器,内核头文件必须和当前运行内核版本完全一致。用uname -r查看版本,然后安装对应的linux-headers-$(uname -r)包。如果是 Ubuntu/Debian 系,命令是sudo apt install linux-headers-$(uname -r);CentOS/RHEL 系则是sudo yum install kernel-devel-$(uname -r)。装完之后,进入源码目录,通常会有一个 Makefile,里面用obj-m指定要编译的模块名。直接执行make,如果一切顺利,会生成.ko文件。这里有个血泪经验:内核版本不匹配是编译失败的头号原因,报错信息里出现invalid module format或者找不到Module.symvers,基本都是头文件版本对不上。

# 查看当前内核版本 uname -r # 安装对应版本的内核头文件(Ubuntu/Debian) sudo apt update sudo apt install linux-headers-$(uname -r) # 进入源码目录并编译 cd linux-rootkit-src make # 加载模块(需要 root 权限) sudo insmod rootkit.ko # 查看模块是否加载成功 lsmod | grep rootkit # 卸载模块 sudo rmmod rootkit

上面这段命令是编译和加载的最小闭环。make依赖源码目录里的 Makefile,它通常长这样:obj-m += rootkit.o,然后通过make -C /lib/modules/$(shell uname -r)/build M=$(PWD) modules调用内核构建系统。insmod把模块插入内核,lsmod确认它出现在列表里。rmmod负责卸载,但很多 rootkit 源码在卸载时会故意不清理干净,或者直接让模块无法卸载,这是你需要警惕的地方。测试时建议在虚拟机里做快照,因为一旦模块出问题,轻则系统卡死,重则直接 panic。

3. 从源码到可运行模块:编译、加载与行为观察

3.1 源码目录结构与关键文件

拿到一份 rootkit 源码,先别急着make。用tree或者ls -R看一眼目录结构。典型的布局是:一个主.c文件(比如rootkit.c),一个Makefile,可能还有README和几个辅助头文件。主文件里会包含<linux/module.h>、<linux/kernel.h>、<linux/syscalls.h>这些内核头。你要找的第一个关键点是MODULE_LICENSE("GPL"),没有这行,内核会拒绝加载或者加载后报警告。第二个关键点是module_init和module_exit指定的函数名,顺着这两个函数往下读,就能理清整个模块的生命周期。有些源码会把功能拆成多个文件,比如hide_file.c、hide_proc.c,那就按功能模块逐个击破。

3.2 编译参数与 Makefile 解读

Makefile 是编译的指挥棒。除了obj-m,你还会看到KERNELDIR变量指向/lib/modules/$(shell uname -r)/build,这是内核源码树的路径。PWD是当前目录。make -C $(KERNELDIR) M=$(PWD) modules这行命令的意思是:切换到内核源码树目录,利用那里的构建系统,但只编译当前目录下的模块。如果你要交叉编译(比如给 ARM 开发板编译),需要指定ARCH和CROSS_COMPILE变量。编译过程中如果报error: implicit declaration of function ‘kallsyms_lookup_name’,说明你的内核版本已经不再导出这个符号,需要换用kprobes或者其他方式获取地址。这是新版内核上跑老 rootkit 源码最常见的翻车点。

// 典型的模块初始化函数片段 static int __init rootkit_init(void) { // 获取 sys_call_table 地址 sys_call_table = (unsigned long *)kallsyms_lookup_name("sys_call_table"); if (!sys_call_table) { printk(KERN_ERR "Failed to find sys_call_table\n"); return -1; } // 保存原始系统调用 original_read = (void *)sys_call_table[__NR_read]; // 关闭写保护,替换系统调用 write_cr0(read_cr0() & ~0x10000); sys_call_table[__NR_read] = (unsigned long)hacked_read; write_cr0(read_cr0() | 0x10000); printk(KERN_INFO "Rootkit loaded\n"); return 0; }

这段代码展示了系统调用劫持的核心步骤。kallsyms_lookup_name用来找到sys_call_table的内存地址。write_cr0操作的是控制寄存器 CR0 的写保护位(第 16 位),因为现代内核默认开启写保护,不关掉它直接改系统调用表会触发 fault。original_read保存原始函数指针,方便后续恢复。hacked_read是自定义的替换函数。注意printk的日志级别,KERN_INFO会输出到内核日志,用dmesg可以看到。这段代码在不同内核版本上可能需要调整,比如write_cr0在新内核里可能被native_write_cr0替代,或者干脆用set_memory_rw来修改页面属性。

3.3 加载后的行为验证

模块加载成功后,你需要验证它到底起没起作用。如果源码实现了文件隐藏,先创建一个测试文件,比如touch /tmp/hidden_test,然后ls /tmp看它还在不在。如果进程隐藏生效了,ps aux里应该看不到某个特定进程。但要注意,很多 rootkit 只隐藏特定前缀的文件,你得先读源码里的过滤规则。验证时用dmesg -w实时看内核日志,模块里的printk会告诉你它执行到了哪一步。如果加载后系统直接卡死,大概率是系统调用表地址找错了,或者替换函数里出现了空指针解引用。这时候只能重启,所以再次强调:虚拟机快照是你的后悔药。

3.4 卸载与残留检查

rmmod rootkit之后,别以为就干净了。检查dmesg有没有报错,lsmod确认模块已消失。然后手动验证被劫持的系统调用是否恢复:如果之前隐藏了文件,现在应该能看到了。有些 rootkit 会在卸载时故意不恢复系统调用表,导致内核状态错乱。你可以在源码的module_exit函数里看它有没有把sys_call_table[__NR_read]重新指向original_read。如果没有,那这份源码就是“一次性”的,加载后只能重启。另外,检查/proc/kallsyms里有没有残留的符号,虽然模块卸载后符号通常会消失,但有些 rootkit 会注册 kprobe 或者 tracepoint,这些需要单独清理。

4. 避坑与排查:那些让你系统崩溃的瞬间

4.1 编译报错 “invalid module format”

现象:insmod时提示invalid module format,dmesg里显示disagrees about version of symbol module_layout。原因:编译模块用的内核头文件版本和当前运行内核版本不一致。解决:用uname -r确认运行版本,然后安装完全对应的linux-headers包。如果是在容器里编译,容器内的内核版本可能和宿主机不同,必须在宿主机上编译或者挂载宿主机的/lib/modules和/usr/src。

4.2 加载后系统无响应或直接 panic

现象:insmod命令敲下去,终端卡死,SSH 断连,虚拟机黑屏。原因:系统调用表地址找错,替换函数里访问了非法内存,或者关闭写保护后没有及时恢复导致其他 CPU 核心崩溃。解决:在虚拟机里操作,提前拍快照。检查kallsyms_lookup_name的返回值是否为空,在替换系统调用前加printk确认地址。如果是多核 CPU,修改 CR0 的操作需要用stop_machine或者确保在单核模式下进行,否则其他核心可能在你修改期间访问到半更新的表项。

4.3 文件隐藏失效,ls仍然能看到

现象:模块加载成功,dmesg显示正常,但目标文件依然出现在ls输出里。原因:劫持的系统调用不对。现代 Linux 的ls和find用的是getdents64,而你只劫持了getdents。或者过滤逻辑写错了,比如比较文件名时用了strcmp但目标文件名带了路径前缀。解决:用strace ls /tmp看实际调用的系统调用号,确认是getdents64还是getdents。然后在源码里找到对应的__NR_getdents64宏,替换正确的表项。过滤逻辑里打印出每次比较的文件名,用dmesg观察。

4.4 模块无法卸载 “Device or resource busy”

现象:rmmod报错Module rootkit is in use或者Device or resource busy。原因:模块的引用计数没有归零,通常是因为在初始化时注册了 kprobe、创建了 proc 文件或者占用了其他内核资源但没有在退出函数里释放。解决:检查module_exit函数,确保每一个register_都有对应的unregister_,每一个create_都有对应的remove_。如果源码本身就没写清理逻辑,那这个模块就是设计成不可卸载的,只能重启。

4.5 内核日志被刷屏或丢失

现象:dmesg里看不到自己加的调试信息,或者被其他内核消息淹没。原因:printk的日志级别太低,或者 ring buffer 太小被冲掉。解决:用printk(KERN_ERR ...)提高级别,或者直接printk(KERN_EMERG ...)。也可以调整 ring buffer 大小:echo 1048576 > /sys/kernel/debug/tracing/buffer_size_kb(如果 tracing 可用)。更稳妥的办法是用trace_printk配合 ftrace,但需要额外配置。

5. 进阶:用 ftrace 和 kprobes 动态观察 rootkit 行为

读源码是一回事,看它实际运行时内核里发生了什么,是另一回事。ftrace 和 kprobes 是内核自带的动态追踪工具,不需要你重新编译内核,也不需要加载额外模块。你可以用它们来验证 rootkit 到底有没有成功劫持系统调用,以及劫持后内核的执行路径变成了什么样。

先看 ftrace 的基本用法。挂载 tracefs:mount -t tracefs nodev /sys/kernel/tracing。然后设置要追踪的函数:echo sys_read > /sys/kernel/tracing/set_ftrace_filter,开启追踪:echo function > /sys/kernel/tracing/current_tracer,最后echo 1 > /sys/kernel/tracing/tracing_on。这时候你执行cat /etc/passwd,ftrace 会记录下sys_read的调用。但如果 rootkit 已经把sys_read替换成了hacked_read,你会发现 ftrace 里显示的函数名变成了hacked_read,因为 ftrace 追踪的是实际被调用的函数地址。这是一个非常直接的验证手段。

kprobes 更灵活,可以在任意内核指令地址上插入探测点。比如你想知道sys_call_table在内存里的具体地址,可以写一个简单的 kprobe 模块,在kallsyms_lookup_name返回时打印结果。或者用perf probe命令行工具:perf probe --add kallsyms_lookup_name,然后perf record -e probe:kallsyms_lookup_name -a sleep 5,最后perf script看输出。这样你不需要改一行 rootkit 源码,就能观察到它初始化时调用了哪些内核函数。

还有一个技巧:用crash工具分析内核转储。如果你在虚拟机里让 rootkit 加载后触发一次 crash(比如echo c > /proc/sysrq-trigger),会生成一个vmcore文件。用crash vmlinux vmcore打开,然后sys_call_table命令可以直接打印系统调用表的当前内容,对比原始内核的符号表,就能精确看到哪些表项被替换了。这个方法适合做深度分析,但需要你提前准备好vmlinux文件(带调试符号的内核镜像)。

我自己的习惯是:每拿到一份 rootkit 源码,先在虚拟机里拍快照,编译加载,用 ftrace 确认劫持生效,再用dmesg看模块日志,最后rmmod并检查系统调用是否恢复。如果恢复失败,直接回滚快照。这套流程走下来,基本能摸清这份源码的底细。从那以后我每次分析内核模块都强制走一遍 ftrace 验证,因为源码里写的和实际跑的,有时候真的是两码事。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进