简介:一份小型 Linux rootkit 源码,面向内核安全学习与实验场景,可实现模块隐藏、进程隐藏等基础功能,适合对内核模块编程与系统安全机制感兴趣的开发者作为入门研究对象。压缩包共46个文件,包含C源码、Makefile、py辅助脚本、README说明与论文tex格式文档等,整体体积约36KB,结构精简,便于快速阅读与分析。该资源已有369人学习下载,可结合源码注释与目录结构梳理 rootkit 的加载流程、隐藏原理及清理思路。通过阅读代码,读者能掌握 LKM 编写、系统调用挂钩、模块隐藏等核心知识点,并自行扩展实验。
1. 读 Linux Rootkit 源码前,先把它放回防御视角
Rootkit 源码是 Linux 内核学习里被误读最深的一类资源。多数人第一眼看到 rootkit 三个字母就给它定了性——攻击工具,要么直接跳过,要么把研究它当成不务正业,实际上最该读这类样本的恰恰是写检测脚本的蓝队。这份源码覆盖了内核模块装载、系统调用挂钩、文件与进程隐藏三条主线,适合正在啃内核符号与 syscall 流程但缺实物对照的学习者,也适合做主机安全巡检、想知道“到底该验证哪个地址是否被改写”的工程师。我的建议很直接:别拿它去搞破坏,拿它练读内核函数是怎么被劫持的。攻击手法读不透,检测时要验证什么根本写不出来。前提只有一条:实验环境完全归你所有,用自己的隔离虚拟机,动手前先打快照。
2. 内核模块的挂钩原理:从 syscall 表到 ftrace 的边界
2.1 LKM 为什么是 rootkit 最常见的载体
LKM(Loadable Kernel Module)允许在内核运行期插入代码,insmod 一条命令就能把编译好的 .ko 送进内核空间,module_init 声明的入口函数几乎可以做任意操作。rootkit 选它当载体不是因为隐蔽,而是因为门槛低、权限完整,这也是它反复出现在内核安全教材里的原因——要理解内核安全机制,绕不开这种最直接的代码插入方式。
读源码时第一件事是确认装载方式。最典型的是手动 insmod,但稍微完整一点的样本会先读 /proc/modules 判断自己是否已经被加载,避免重复插入导致内核崩溃。光看这个分支,就能大致判断作者对模块生命周期的理解:如果样本一上来就无脑注册,兼容性通常不怎么样。
另一个要认清的点是 sys_call_table 符号的内核导出限制。内核 2.6 之后就停止导出该符号,样本获取它一般走三条路:读 /proc/kallsyms、用 kprobe 机制临时取得、扫描内核起始区域定位。看到哪条路径基本就能推断源码面向的内核版本段。比如依赖 kallsyms_lookup_name 的样本,在内核 5.7 之后很难直接编译通过,因为该符号的导出被移除了,只有换用压栈调用或者 kprobe 间接取地址。
这里面的判断逻辑对防御方向同样有用:知道样本靠哪条路径定位符号,就知道你的防线应该设在哪一层。比如针对 kallsyms 路径的样本,把 kptr_restrict 设为 1,普通进程就看不到内核符号地址,这对大部分依赖该路径的老样本能起到不错的干扰作用。学习时别只盯着样本怎么实现,多问一句“这个动作对应哪个防御参数”,收获会完全不同。
2.2 两种挂钩方式的技术边界要分清
源码里的挂钩代码通常集中在两处:sys_call_table 表项改写和基于 ftrace 的函数替换。前者是传统手法,思路是把 sys_open、sys_getdents64 这类表项里的函数指针替换成样本自己的实现;后者借助 ftrace 机制,在某个函数入口插入跳转,效果类似但路径完全不同。两者边界差异明显,读代码时要主动区分,因为检测手段跟着它们走。
改写 sys_call_table 必须解决两个问题:表地址怎么找,以及写保护怎么绕过。老写法直接操作 CR0 寄存器取消写保护,这一步在启用了 W^X 强写保护的新内核上很容易触发 page fault;新一些的样本会改用 set_memory_rw 这类接口临时翻转页权限。如果你在源码里看到对 CR0 的操作,基本可以判断它是为老内核准备的,不要直接拿到新内核上跑,翻车概率接近九成。
ftrace 方案的坑集中在回调函数内部。ftrace 回调和被跟踪函数之间如果出现递归调用,比如回调里又走了被跟踪的 printk 路径,轻则死锁,重则空指针崩溃。读源码时留意有没有设置 FTRACE_OPS_FL_RECURSION_SAFE 之类的标志位,这能看出作者对 ftrace 接管流程有没有完整理解。有一个细节值得记住:ftrace 挂钩的对象是函数入口,影响的是某个函数每次被调用时的行为;表项改写影响的是某个系统调用从用户态进来那一下。两者的可见性完全不同,检测时前者要靠函数调用回溯来发现,后者靠比对表项地址来发现,用的工具不是一个层面。
实际操作中,快速定位挂钩代码的方法是用 grep 搜这几个关键词:sys_call_table、kallsyms_lookup_name、set_memory_rw、register_ftrace_function。它们分别指向符号获取、写保护绕过、ftrace 接管三条技术线,出现哪一个,源码的挂钩骨架就清楚了一半。一份样本通常只押注其中一条路线,两种都做的反而少见,因为维护成本翻倍。
2.3 实验环境:隔离虚拟机、固定内核版本与快照
跑这套样本的前提是环境完全受控。我一般的做法是:一台只属于自己的虚拟机,内核版本固定,关闭自动更新,先把快照打好。任何 rootkit 实验都不建议在生产机或共享主机上做,一旦 panic,恢复成本远高于一次快照的代价。
初始化环境按这个顺序来:
# 1. 确认当前内核版本,再安装严格匹配的 headers uname -r sudo apt install -y linux-headers-$(uname -r) build-essential # 2. 确认工具链可用 gcc --version | head -n 1 make --version | head -n 1 # 3. 编译前检查 .ko 的 vermagic 是否与内核一致 modinfo /lab/rootkit-sample/sample.ko | grep vermagic uname -runame -r 拿到的完整版本号是后续所有编译和加载的唯一依据。headers 与运行内核不一致,insmod 阶段会直接报 Invalid module format,这是复现时最高频的失败入口。modinfo 查到的 vermagic 字段相当于模块与内核之间的对接指纹,和 uname -r 对不上就不用继续了。
提示:加载任何内核样本前,先确认这个虚拟机没有挂载宿主的共享目录,避免样本看到宿主的文件系统。
建目录时我会把实验目录固定成 /lab/rootkit-sample,编译产物、dmesg 日志、前后对比文件全部放进去,实验结束统一清理。清理动作有三条:rmmod 卸载、清空 /lab、恢复快照。任何时候发现卸载不了,第一选择是恢复快照,而不是在带数据的机器上硬杠。这个习惯帮我省了至少两次重装系统的功夫。
3. 把样本拆成三段读:入口、挂钩、隐藏逻辑
3.1 从 module_init 和 module_exit 读装载与卸载路径
读 LKM 样本,永远从 module_init 看起,它定义了代码进内核后的第一条执行路径。源码里通常会有类似module_init(entry_func)的宏,入口函数做三件事:获取目标符号地址、挂钩目标函数、启动隐藏逻辑。顺序一般是先地址后挂钩,最后才是隐藏,因为隐藏逻辑依赖前两步的结果。
对应的 module_exit 负责卸载时还原所有被改写的函数指针。读的时候用一条线串起来:入口里拿到的每个地址,在出口里都必须有对应的还原动作。缺了任何一处,系统调用可能指向一个已经被释放的地址,最典型的后果是卸载的一瞬间触发内核 panic。所以源码里出口函数看着简单,其实是最容易暴露作者功底的地方。
入口和出口的函数名大概率被起得模棱两可,比如 start、cleanup、init_impl 之类,别被名字带偏。判断标准不是函数名,而是函数里调用的内核 API:看到 get_symbol、set_memory_rw、unregister 这类,基本就能锁定装载与卸载的真实边界。读这类源码的经验是:名字会骗人,API 调用不会。
3.2 挂钩函数怎么认:从符号解析到指针替换的完整链路
样本里真正干活的函数,命名往往有意伪装成内核自带风格,比如 custom_open、hooked_read、fake_write。识别方法不是猜函数名,而是顺调用链走三步:
第一步,看有没有通过 kallsyms_lookup_name 或相似 API 拿到 sys_call_table 基址;第二步,看有没有在基址上按序号取特定 syscall 的函数地址;第三步,看是否把取到的地址保存成全局变量,供挂钩和还原时复用。同时满足这三步的函数就是挂钩函数本身,源码里通常会把原始函数指针用一个明显变量保存,比如 orig_read、old_open、orig_getdents64。
被这条链路指上频率最高的三个 syscall 是 sys_read、sys_openat、sys_getdents64,分别对应端口回连的处理、文件访问的过滤、目录枚举的隐藏。拿到源码时先用 grep 搜这三个符号名,能在十分钟内定位到核心挂钩代码在哪个文件、哪个函数。如果搜 sys_read 出来一堆无关结果,可以加一行边界条件,比如grep -n "orig_read" -A 20,直接看引用处上下文。
这里要纠正一个常见误读:有人以为改了系统调用函数入口地址就是改了 syscall 表,这是两回事。表项改写把表里存的函数指针换掉,函数本体不动;机器码层面改写则是直接改函数开头几个字节,让执行流跳到样本代码。两者对 kallsyms 输出、对 ftrace 可见性、对检测脚本的影响完全不同,读源码时先分清是哪种,后面的检测方向才不会走偏。
3.3 隐藏逻辑对照:文件、进程、端口各挂在哪一层
隐藏是本类样本的核心玩法,找到隐藏函数的位置比看懂它更重要。文件隐藏一般挂在目录枚举链路上,让某个文件名从 readdir 结果里消失;进程隐藏挂在 /proc 目录的枚举上,或者更进一步让 /proc/ 整个消失;端口隐藏通常在 inet_diag 或 seq_file 的回调里做手脚,让连接槽位不出现在遍历结果中。读的时候按这张表对照:
| 隐藏对象 | 源码里找哪类代码 | 底层接口 |
|---|---|---|
| 文件 | readdir 枚举过滤 | getdents64 / iterate_shared |
| 进程 | proc 遍历过滤 | proc_pid_readdir 等 |
| 端口 | 连接槽位填充回调 | inet_diag / seq_file ops |
这张表最大的价值是反向使用。检测时只需验证这几个接口有没有被改动,而不必复现完整攻击逻辑。所以这份源码在“学习”维度上的产出,是一张攻击行为索引,而不是一段可以随手改改就用的恶意代码。做实验时,把样本的目标对象和挂钩位置记下来,直接去写对应的检查脚本,比对着源码猜它想干什么高效得多。
内核里隐藏逻辑还有个常见前置动作:把模块自身从 /proc/modules 的输出里摘掉。这个动作在入口阶段的隐藏函数里完成,判断依据是源码里有没有遍历 /proc/modules 或者修改 module 链表指针。有这个动作的样本,防御上要额外留意:lsmod 结果不可信,模块列表需要到 sysfs 或内核日志里交叉求证。还有一类样本会顺手清理 dmesg 里的自身装载日志,判断方法是加载前后清空日志再对比输出量。
3.4 三遍走读法:从找入口到抓行为
对于没摸过内核源码的读者,我建议用三遍走读法。第一遍只看 module_init 和 module_exit,画出装载与卸载的调用关系,不纠结细节;第二遍顺着符号解析找挂钩点,把被改的 syscall 名字列成清单;第三遍才进入隐藏函数,确认隐藏对象、触发条件和优先级。每一遍都不要在细节上恋战,碰到不认识的内核 API 先记名字,下一遍回来再看,效率会高很多。这份源码我看完第一遍大概二十分钟,第二遍四十分钟,第三遍已经把要验证的行为改写成检测脚本了——源码读到什么程度算到位,标志就是你能不能把它的每个动作翻译成一条可执行的验证命令。
4. 反向翻译成检测:文件、进程、端口三层校验脚本
4.1 文件层检测:绝对路径读取与枚举结果对照
rootkit 隐藏文件时,通常只挂 readdir 枚举路径,而 openat 这种按文件名显式打开的系统调用往往没有被完整挂钩。这给检测留了一个口子:先创建一个特征文件,再用 ls 枚举目录和 cat 绝对路径直接读文件做对照。ls 看不到但 cat 能读,说明目录枚举链路被过滤,这就是被隐藏对象的直接证据。
特征文件的名字要足够随机,避免和目标样本内置的隐藏名单撞车。我一般用echo "probe" > /tmp/.probe_$(date +%s)生成,每次实验重新生成,确保不触发样本里的固定名过滤。对比时注意以 root 身份执行,避免样本按 uid 放行普通用户导致对比失效。
4.2 进程层检测:kill -0 与 /proc 遍历对照
进程隐藏的检测思路类似。样本让 ps 和 /proc 枚举看不到某个进程,但进程本身还在运行。kill -0 是最轻量的探活方式——它只发送空信号检测进程是否存在,不产生实际副作用。如果 kill -0 返回成功且 /proc/ 下还有可读状态文件,但 ls /proc 的列表里找不到这个 PID,基本可以断定 /proc 枚举被挂钩。这里有一处细节:有些样本会同时挂钩 open 和 stat 路径,使 cat /proc/ /status 也失败。这种情况下看 /proc/ /fd 目录还能不能打开,能打开说明进程确实存在,只是部分枚举链路被伪装了。
4.3 落地脚本:三层校验一次跑完,结果落盘
下面这个脚本是做完实验后跑前后对比用的,在实验虚拟机里以 root 执行。文件、进程、端口三层校验串在一个流程里,输出追加到日志文件,方便加载样本前后各跑一次做 diff。
#!/bin/bash # 用法: sudo ./check_signals.sh <目标PID> <目标端口> PID="${1:-0}" PORT="${2:-0}" LOG="/lab/rootkit-sample/check.log" echo "==== 文件层校验 ====" | tee -a "$LOG" echo "probe-$$" > "/tmp/.probe_$$" if ls -la /tmp/ | grep "probe_$$" > /dev/null 2>&1; then echo "ls 可见" >> "$LOG" else echo "ls 不可见 -> 枚举链路疑似被挂钩" >> "$LOG" fi if [ -r "/tmp/.probe_$$" ]; then echo "cat 可读,open 路径未被过滤" >> "$LOG" fi echo "==== 进程层校验 ====" | tee -a "$LOG" if [ "$PID" -gt 0 ] && kill -0 "$PID" 2>/dev/null; then echo "kill -0 确认进程运行中" >> "$LOG" cat "/proc/$PID/status" > /dev/null 2>&1 \ && echo "proc 状态可读" >> "$LOG" \ || echo "proc 状态不可读,可能 stat 被挂钩" >> "$LOG" fi ls /proc | grep -w "$PID" > /dev/null 2>&1 \ && echo "/proc 枚举可见" >> "$LOG" \ || echo "/proc 枚举不可见" >> "$LOG" echo "==== 端口层校验 ====" | tee -a "$LOG" if [ "$PORT" -gt 0 ]; then timeout 1 bash -c "echo > /dev/tcp/127.0.0.1/$PORT" 2>/dev/null \ && echo "端口可连接" >> "$LOG" \ || echo "端口不可达" >> "$LOG" fi逻辑说明:文件层用 ls 与 cat 对照,判定挂钩是否作用在枚举路径;进程层用 kill -0 探活、/proc 状态可读性、/proc 枚举可见性三个信号交叉验证;端口层用本机回连测试真实连通性——样本在 seq_file 层做手脚时,ss 和 netstat 会撒谎,但真实连接不会。参数说明:PID 传观察目标进程号,PORT 传样本可能监听的本地端口,不确定就都传 0,脚本只跑文件层校验,不会误报。
注意:所有对比脚本的输出都落到 /lab 目录,别放在 /tmp 里,否则可能被样本的清理逻辑顺手删掉,事后连证据都找不到。
4.4 内核层校验的边界:kallsyms 能证明什么,不能证明什么
行为脚本只能证明现象,不能定位到具体内核函数。ls 撒谎不等于知道是哪个地址被改写。想再往下走一步,可以抓 kallsyms 基线做前后 diff:
# 加载样本前记录基线 grep -E " sys_open$| sys_read$| sys_getdents64$" /proc/kallsyms > /tmp/kallsyms_before.txt # 加载样本后再抓一次 grep -E " sys_open$| sys_read$| sys_getdents64$" /proc/kallsyms > /tmp/kallsyms_after.txt diff /tmp/kallsyms_before.txt /tmp/kallsyms_after.txt这里要讲清楚边界:这个对比只对“直接改写函数入口机器码”的样本有效;对改 syscall 表项和用 ftrace 接管的样本,符号地址不一定会变化,diff 无输出不代表环境干净。实验内核要先把 kptr_restrict 设为 0,否则符号地址对普通进程不可见。另外加一层低成本的交叉验证:加载样本前执行sudo dmesg -C清空日志,实验结束后dmesg | tail -n 50,如果看到模块装载相关输出,即使 lsmod 里是干净的,也能确认装载动作确实发生过。
5. 复现与排查:加载、隐藏、panic、符号缺失四个翻车记录
5.1 insmod 拒绝加载:Invalid module format
现象:insmod 报错 module: Invalid module format,报错信息里还带出一串 vermagic 字符串,格式大概像 5.4.0-xx-generic。这是复现 rootkit 源码的第一个拦路虎,十个人里至少有五个栽在这里。
原因:编译 .ko 用的 headers 版本和当前运行内核不一致。内核模块在加载时逐字节比较 vermagic,任何小版本差异都会拒绝加载,甚至连编译选项差异都会区分。这个校验机制的初衷是防不兼容,结果成了新手复现的第一道坎。
解决:重装与 uname -r 完全一致的 headers 后重新 make。检查命令就两条:
modinfo sample.ko | grep vermagic uname -r两行结果一致再继续。如果源码依赖特定内核配置项(比如 CONFIG_KALLSYMS_ALL),还要进 /boot/config-$(uname -r) 确认该选项已开启,否则同版本也会报配置不匹配。排查路径是:先对 vermagic,再对 config,最后看 dmesg 里 insmod 报错的完整字段,按这个顺序能省掉大部分无效操作。
5.2 加载成功但模块立刻隐藏自己,rmmod 找不到模块名
现象:dmesg 里能看到装载日志,但 lsmod 和 /proc/modules 里都没有对应模块,rmmod 提示 Module not found,卸载无从下手。这是学习场景下最容易懵的一步,因为你的第一反应是找模块名,结果列表里根本没有。
原因:样本的隐藏逻辑在 module_init 阶段就执行了,把自己的模块行从 /proc/modules 的 seq_file 输出里过滤掉,并清掉了 /sys/module 下的目录项。lsmod 读的就是这两处数据源,自然看不到。
解决:别对着 lsmod 找名字,回到 dmesg 或者 modinfo 记录下的原始模块名,尝试rmmod -f <模块名>强制卸载;如果样本还挂钩了卸载路径,rmmod 同样可能失效。到这一步我的习惯是直接恢复快照,这是最快、最干净的路。另一个顺手动作:加载之前cp sample.ko /tmp/留一份备份,卸载时用 modinfo 输出的原始文件名传参,而不是 lsmod 里的显示名。排查路径就两步:先查 dmesg 确认装载动作真实存在,再用原始文件名强制卸载。
5.3 一加载就 panic:unable to handle page fault
现象:insmod 执行后系统直接死机,VM 表现为黑屏或卡死,重启后 dmesg 里能看到 BUG: unable to handle page fault at 某地址。这种翻车发生在加载瞬间,没有任何后悔药,只能靠快照回滚。
原因:老式 CR0 写保护绕过在新内核的 W^X 强写保护机制下失效,样本尝试写入只读页时触发异常;或者加载时挂钩的 syscall 函数指针立即被内核调度路径调用,跳到一个非法地址。两种情况都发生在加载瞬间,所以没有后悔药可吃。
解决:换用 set_memory_rw 实现或改走 ftrace 方案再编译;同时确认内核没有开启 lockdown 模式,lockdown 状态下这类地址改写会直接被拦截。排查时建议先给内核加nokaslr参数启动一次,排除地址随机化带来的干扰;如果 nokaslr 下还是 panic,基本就是写保护路径本身的问题。这条翻车记录解释了为什么源码注解里总写着“仅支持 XX 内核”——兼容性差不是作者偷懒,是内核安全机制演进逼出来的。
5.4 编译失败:kallsyms_lookup_name 未定义
现象:源码在 make 阶段报 undefined reference to kallsyms_lookup_name,或者运行时调用该符号直接段错误。这是目前复现老样本最普遍的问题,几乎每个下过源码的人都撞过这一回,内核版本越新越常见。
原因:内核 5.7 开始不再导出这个符号,依赖它的源码失去链接入口。这个导出移除动作在社区里讨论过很多轮,实际影响就是一大批依赖 kallsyms_lookup_name 的老样本直接失去可用性。
解决:两条路。一是改样本实现,用 kprobe 临时注册去取符号地址,或者直接扫描 kallsyms 记录定位;二是如果目的是学习而非改造,把实验内核固定在 5.4 或更低版本,源码原样编译。我自己的习惯是同一份源码准备两个内核版本的环境:老内核看完整行为,新内核观察哪些 API 已经失效。这种横向对比对理解内核演进非常有效,比单纯修通一个样本的收获大得多。排查路径:先uname -r确认内核版本是否高于 5.7,再决定改源码还是换环境。
6. 收尾技巧:用对照脚本在十秒内确认环境是否被污染
最后的习惯动作:实验前后各跑一次基线脚本。加载样本前记录一批关键校验和与行为信号,实验结束后再跑一遍,任何 diff 都算环境被污染,直接恢复快照,而不是尝试手动清理。这样做的好处是把你从“不确定哪里被改过”的焦虑里解放出来,脚本说脏就是脏,不用分析。
实际做法是准备一个 /lab/rootkit-sample/audit.sh,里面包含三行最基础的校验:
#!/bin/bash # 用法: ./audit.sh > audit_$(date +%s).log date +%s > /tmp/audit_start sha256sum /usr/bin/ls /usr/bin/cat /usr/sbin/ss > /tmp/audit_bin.sha echo "audit finished, start at $(cat /tmp/audit_start)"这个脚本本身不检查 rootkit 是否加载,它只负责把当前系统的关键要素固定下来,作为事后对比的参照物。真正判断污染靠的是 4.3 里的三层校验脚本,两者配合用的顺序是:加载前跑 audit + check_signals 记录基线,加载样本做实验,结束后再跑一遍同样的组合,最后 diff 两份日志。任何一行对不上,直接恢复快照,整个过程不超过十秒。
从那以后,我每次内核实验都强制走这个流程:先打快照,再跑基线脚本,实验结束跑对比,看到任何异常输出直接回滚。这套习惯帮我省过两次重装系统的功夫,也让我在写检测逻辑时不再靠猜——内核函数被劫持后的行为特征,都是照着这份源码一条一条对出来的。希望帮到你。
本文还有配套的精品资源,点击获取