简介:这是一份面向Linux内核安全与逆向学习者的轻量级Rootkit开发实践资源,适合具备C语言基础和Linux系统编程经验的中初级开发者深入理解内核模块机制、进程管理与隐藏技术。资源包含46个文件,以9个可编译运行的sample示例为核心,辅以4个头文件(head)、1个Python控制脚本(rtcmd.py)、1个LaTeX论文模板(polis_paper.tex)及Makefile、config等构建配置文件,整体仅36KB,结构紧凑、即下即用。内容预览显示其基于Git仓库组织,含objects、HEAD、index等标准.git目录项,体现作者对模块化开发与版本管理的实践意识。已有370人学习下载,读者可直接复现模块注入、进程隐藏、系统调用劫持等关键功能,结合README.md与源码注释快速掌握Rootkit底层原理与调试方法,是内核安全入门与实验教学的优质参考材料。
1. rootkit 不是“藏得深”就安全:它靠的是内核态的权限重定义,而不是加密或混淆
你可能试过用ps、lsmod、netstat查不到某个进程或模块,以为它用了多高明的隐藏技巧——其实绝大多数 Linux rootkit 的核心能力,根本不是“怎么藏”,而是“让这些命令压根没机会看到它”。它不依赖花哨的加壳、内存混淆或反调试,而是在内核加载阶段,就劫持了系统调用表(syscall table)、修改了内核符号表(kallsyms)、甚至重写了procfs和sysfs的遍历逻辑。这意味着ps调用sys_getdents64时拿到的目录项,早已被 rootkit 在 VFS 层过滤掉;lsmod读取/proc/modules时,底层modules_next()迭代器已被替换成跳过特定模块的版本。这不是应用层的障眼法,而是对内核数据结构和执行路径的主权接管。理解这一点,才能真正看懂一份 rootkit 源码:它不是一段“能跑的恶意代码”,而是一份内核权限模型的逆向工程说明书——告诉你,Linux 内核里哪些指针可以改、哪些链表必须断、哪些符号必须重映射,以及为什么改错一个字节就会触发BUG: unable to handle kernel paging request。这份源码的价值,远不止于“学习攻击技术”,它是少数能让你亲手触摸到内核信任边界的实战入口。
2. 内核模块加载:从 insmod 到 init_module 的三重权限跃迁
Linux rootkit 的起点,永远是insmod命令触发的内核模块加载流程。但很多人误以为只要insmod成功返回,模块就“活”了——实际上,从用户空间敲下命令,到模块函数真正获得内核态执行权,中间横跨三道关键门槛:模块签名验证(若启用)、符号解析与重定位、以及最关键的module_init()函数执行时机。rootkit 源码正是在这第三步完成权限的实质性跃迁。
2.1 模块加载的完整调用链:从用户态到 init 函数
insmod本质是调用init_module()系统调用,其内核路径为:sys_init_module()→load_module()→setup_load_info()→apply_relocations()→do_init_module()
其中do_init_module()是真正的分水岭:它在模块被完全链接进内核地址空间后,调用模块中__init段标记的初始化函数(即module_init(my_init)绑定的my_init)。此时,代码已运行在 ring 0,可直接读写页表、访问物理内存、修改 IDT/GDT。rootkit 源码的my_init()函数,往往第一行就是write_cr0(read_cr0() & ~X86_CR0_WP)—— 关闭写保护(Write Protect),为后续修改只读内核数据(如sys_call_table)铺路。这一步不是可选优化,而是硬性前提:现代内核将sys_call_table放在只读页中,不关 WP 直接写会触发 page fault。
// 典型 rootkit 初始化函数节选(x86_64) static int __init my_init(void) { unsigned long cr0; // 1. 关闭写保护 cr0 = read_cr0(); write_cr0(cr0 & ~X86_CR0_WP); // 2. 获取 sys_call_table 地址(需绕过 kallsyms_lookup_name 限制) sys_call_table = (void**)kallsyms_lookup_name("sys_call_table"); if (!sys_call_table) { printk(KERN_ERR "Failed to locate sys_call_table\n"); write_cr0(cr0); // 恢复写保护 return -1; } // 3. 备份原始 sys_open,并替换为 hook_open original_sys_open = sys_call_table[__NR_open]; sys_call_table[__NR_open] = (void*)hook_open; // 4. 恢复写保护(关键!否则内核稳定性崩溃) write_cr0(cr0); printk(KERN_INFO "Rootkit loaded, sys_open hooked.\n"); return 0; }提示:
write_cr0()操作必须成对出现。漏掉恢复步骤会导致后续内核模块卸载、内存分配等操作因写保护异常而 panic。这是新手最常翻车的点——以为“关了就能写”,忘了“写了还得关回去”。
2.2 符号解析的暗礁:kallsyms_lookup_name 的禁用与绕过
从 Linux 5.7 开始,kallsyms_lookup_name()被显式标记为__weak并移出导出符号表(EXPORT_SYMBOL_GPL移除),导致传统 rootkit 无法直接调用它获取sys_call_table地址。但这并非“封死”,而是将符号解析从“调用函数”变为“内存扫描”。主流绕过方案有二:
- 基于 /proc/kallsyms 的用户态预读 + 内核态地址映射:
insmod前用cat /proc/kallsyms | grep sys_call_table获取地址,通过模块参数传入; - 内核内存扫描法:利用
kallsyms_addresses数组(仍导出)与kallsyms_num_syms遍历所有符号,比对符号名字符串(位于kallsyms_names)。该方法稳定但稍慢,且需处理kallsyms_token_table的压缩编码。
// 内存扫描获取 sys_call_table 的简化逻辑(需适配内核版本) static void *find_sys_call_table(void) { unsigned long i; char namebuf[256]; for (i = 0; i < kallsyms_num_syms; i++) { if (kallsyms_symbol_name(i, namebuf, sizeof(namebuf)) && strcmp(namebuf, "sys_call_table") == 0) { return (void*)kallsyms_addresses[i]; } } return NULL; }此代码依赖kallsyms_symbol_name()辅助函数,它内部解码kallsyms_names和kallsyms_token_table。不同内核版本(5.4/5.10/6.1)的 kallsyms 格式有细微差异,需严格匹配——这也是为什么一份 rootkit 源码往往只能在特定内核版本上稳定运行。
2.3 模块卸载的陷阱:cleanup_module 的不可靠性
module_exit(my_exit)函数常被设计为“恢复原状”,比如恢复sys_call_table条目、重新启用写保护。但现实是:一旦 rootkit 已劫持关键路径(如sys_read),rmmod命令本身可能就失效了。因为rmmod依赖delete_module()系统调用,而该调用内部会调用security_kernel_module_from_file()等 LSM 钩子——若 rootkit 已篡改 LSM 框架,delete_module()可能被静默拦截。更致命的是,若my_exit()中尝试恢复sys_call_table,而此时已有其他进程正在执行被 hook 的系统调用(如open),CPU 可能正处在hook_open函数栈中,此时恢复原函数指针会导致栈帧错乱,直接 oops。因此,健壮的 rootkit 源码会避免在cleanup_module中做任何高风险恢复,转而采用“单次生效、永不卸载”的设计哲学,或仅清理内存而不恢复钩子。
3. 系统调用劫持:为什么 hook sys_open 比 hook sys_execve 更难发现
劫持sys_open是 rootkit 最经典的入口点,但它的隐蔽性远不如表面看起来那么高。原因在于:sys_open被调用频率极高(每个文件打开都触发),且其参数(文件路径)天然具备强语义特征。相比之下,sys_execve虽调用频次低,但一旦被 hook,影响范围是进程级的——它决定了谁能在你的系统上启动新进程。理解这两者的差异,是读懂 rootkit 源码中钩子设计逻辑的关键。
3.1 sys_open 钩子:路径过滤的双重悖论
sys_open钩子的典型逻辑是:检查filename参数是否匹配敏感路径(如/etc/shadow,/proc/kcore),若是则返回-EACCES或伪造空文件。但这里存在一个根本矛盾:要读取filename字符串内容,必须先将其从用户空间拷贝到内核空间,而copy_from_user()本身就是一个可被监控的系统调用。如果 EDR 工具已 hooksys_copy_from_user,它就能在 rootkit 解析路径前就捕获到异常内存访问模式。
// 一个看似合理的 sys_open hook asmlinkage long hook_open(const char __user *filename, int flags, umode_t mode) { char path[PATH_MAX]; // 问题在此:copy_from_user 是高亮目标! if (copy_from_user(path, filename, sizeof(path)-1)) return -EFAULT; path[sizeof(path)-1] = '\0'; if (strstr(path, "/etc/shadow")) { printk(KERN_WARNING "Blocked access to /etc/shadow\n"); return -EACCES; } return original_sys_open(filename, flags, mode); }注意:
copy_from_user()的调用本身就会在kprobe或 eBPF tracepoint 中留下清晰痕迹。高级检测方案(如基于 Intel PT 的硬件追踪)甚至能重建path的原始值。因此,真正成熟的 rootkit 源码会规避copy_from_user,转而用getname()(内核内部路径解析函数)或直接解析struct filename结构体指针——但这要求对 VFS 层有极深理解,也是源码难度分水岭。
3.2 sys_execve 钩子:进程诞生的“第一现场”控制
sys_execve接收filename(可执行文件路径)、argv(参数数组)、envp(环境变量)三个核心参数。劫持它意味着:在新进程task_struct创建完毕、但尚未开始执行用户代码的瞬间,插入控制逻辑。此时可做三件事:
- 进程白名单:检查
filename是否在预设列表中,否则return -EPERM; - 环境注入:在
envp中动态添加LD_PRELOAD,实现用户态持久化; - 父进程篡改:修改
current->parent指针,让新进程显示为由systemd或sshd派生,逃避父子关系图谱分析。
// sys_execve hook 的关键片段(精简) asmlinkage long hook_execve(struct filename *filename, const char __user *const __user *__argv, const char __user *const __user *__envp) { struct task_struct *task = current; char *path; // 使用 getname() 避开 copy_from_user(更隐蔽) path = getname(filename->name); if (IS_ERR(path)) return PTR_ERR(path); // 检查是否为黑名单进程(如 /bin/bash, /usr/bin/python) if (is_blacklisted_process(path)) { putname(path); return -EPERM; // 静默拒绝,不记录日志 } putname(path); return original_sys_execve(filename, __argv, __envp); }此实现不调用copy_from_user,而是复用内核已有的getname()流程,大幅降低被检测概率。但代价是:getname()返回的是内核态路径缓存(dentry/inode),需额外解析才能获取完整字符串——这正是源码中is_blacklisted_process()函数需要深度定制的原因。
3.3 避坑:常见 hook 实现的五大翻车现场
现象 → 原因 → 解决,每一条都来自真实内核调试血泪经验:
现象:
insmod成功,但lsmod看不到模块,dmesg显示module is from unsupported source
→原因:内核启用了CONFIG_MODULE_SIG(模块签名强制),而 rootkit 模块未签名或签名密钥未导入内核密钥环
→解决:编译时用scripts/sign-file工具签名,或临时禁用CONFIG_MODULE_SIG_FORCE后重新编译内核(仅限实验环境)现象:hook 后系统频繁 panic,错误指向
page_fault或invalid opcode
→原因:write_cr0()关闭写保护后,忘记在my_init()结尾恢复;或恢复时机错误(如在sys_call_table修改后、但在其他只读数据修改前就恢复)
→解决:将write_cr0()操作严格包裹在my_init()开头和结尾,中间所有只读内存修改必须连续完成现象:
sys_openhook 生效,但strace -e open ls /仍能看到被隐藏的文件
→原因:strace使用ptrace机制,绕过sys_call_table直接进入内核do_syscall_64,hook 未覆盖ptrace路径
→解决:需同时 hooksys_ptrace或sys_seccomp,或在arch/x86/entry/common.c中 patchdo_syscall_64入口(难度陡增)现象:rootkit 加载后,
ssh登录失败,报错PAM authentication error
→原因:sshd启动时调用sys_open打开/etc/pam.d/sshd,被 hook 拦截并返回-EACCES,导致 PAM 初始化失败
→解决:hook 中增加进程名白名单(current->comm),对sshd、systemd等关键进程放行现象:
rmmod执行卡死,dmesg无输出,ps aux显示D状态进程
→原因:cleanup_module中尝试恢复sys_call_table,但此时有其他 CPU 正在执行hook_open,导致指令指针跳转到已释放的内存区域
→解决:放弃cleanup_module中的恢复逻辑;改用kobject_put()强制卸载,或设计为“不可卸载”模块(MODULE_LICENSE("GPL")且不提供module_exit)
4. procfs 隐藏:/proc 目录不是文件系统,而是内核数据结构的实时投影
/proc目录常被误认为是一个真实的磁盘文件系统,实则它是内核内存数据的动态视图生成器。ls /proc看到的 PID 目录,背后是task_struct链表的实时遍历;cat /proc/modules的输出,源于struct module全局链表的线性扫描。rootkit 要隐藏进程或模块,绝不能去“删文件”,而必须篡改这些底层数据结构的遍历逻辑——这才是/proc隐藏的底层真相。
4.1 进程隐藏的核心:篡改 next_task() 遍历器
/proc下每个 PID 目录对应一个task_struct。内核通过next_task()宏(定义在include/linux/sched.h)遍历init_task为头的循环链表。标准遍历代码如下:
// fs/proc/base.c 中的 pid_readdir() for (task = next_task(&init_task); task != &init_task; task = next_task(task)) { if (task->pid == target_pid) { // 构建 /proc/PID 目录项 } }rootkit 的隐藏逻辑,就是在next_task()返回目标task_struct前,将其从链表中摘除。但直接list_del()会破坏链表完整性,导致其他内核路径(如kill()、waitpid())崩溃。正确做法是在遍历过程中动态跳过:当next_task()即将返回被隐藏进程的task_struct时,返回它的下一个节点。
// 伪代码:patched_next_task() 的核心思想 static struct task_struct *patched_next_task(struct task_struct *task) { struct task_struct *next = next_task(task); // 若 next 是需隐藏的进程,则跳到 next->next if (should_hide_task(next)) { return next_task(next); } return next; }但next_task()是宏,非函数,无法直接 hook。实际方案是 patchfs/proc/base.c中的proc_pid_readdir()函数,或更底层地 patchiterate_dir()调用链中的filldir64回调——这要求精确计算内核函数偏移,且不同内核版本偏移量不同,是源码版本适配的最大难点。
4.2 模块隐藏的双保险:modules_list 与 /proc/modules 的分离
lsmod命令读取/proc/modules,其内容由proc_modules_show()函数生成,该函数遍历modules全局链表(struct module *modules)。rootkit 隐藏自身模块,常规做法是list_del(&my_module->list)。但此操作有两大风险:
- 风险一:
modules链表被多个内核路径引用(如find_module()、__symbol_get()),删除后导致符号解析失败; - 风险二:
/proc/modules被隐藏,但sysfs下的/sys/module/my_rootkit/依然存在,成为检测突破口。
因此,成熟 rootkit 源码采用“双链表隔离”策略:
- 将模块从
modules链表摘除,避免lsmod显示; - 同时,在
sysfs初始化阶段(module_add_modinfo()),拦截sysfs_create_group()调用,阻止/sys/module/my_rootkit/目录创建; - 最关键的是:保留模块在
__this_module中的引用,确保module_refcount不为零,防止内核自动回收内存。
// 模块隐藏的最小安全操作集 static void hide_module(void) { // 1. 从 modules 链表摘除(需保存 prev/next 以备恢复) list_del(&my_module->list); // 2. 清空 module_kobject 的 parent,使 sysfs 无法挂载 my_module->mkobj.kobj.parent = NULL; // 3. 将 refcount 设为极大值,防止被 release atomic_set(&my_module->refcnt, 0x7FFFFFFF); }提示:
atomic_set(&refcnt, MAX)是经典 trick,但现代内核(5.10+)增加了module_put()的 refcount 溢出检查,需配合 patchtry_module_get()函数绕过。
4.3 避坑:/proc 隐藏的三大认知误区
误区:“隐藏
/proc/PID/cmdline就等于隐藏进程”
→真相:cmdline只是task_struct->mm->arg_start/arg_end的副本,修改它不影响ps显示的comm字段(来自task_struct->comm)。真正隐藏需同时 patchproc_pid_cmdline()和proc_pid_comm()。误区:“
/proc/sys/kernel/modules_disabled=1能阻止 rootkit 加载”
→真相:该参数仅禁止init_module()系统调用,但 rootkit 可通过kexec、firmware loading等非常规路径注入,或利用bpf程序在tracepoint/syscalls/sys_enter_init_module中劫持加载流程。误区:“用
mount --bind /dev/null /proc/PID/能彻底隐藏”
→真相:mount --bind是 VFS 层操作,/proc是伪文件系统,不支持 bind mount。此命令会报错Invalid argument,是典型新手玄学操作。
5. 内核符号劫持:kprobes 与 ftrace 的双面刃
当 rootkit 需要 hook 的函数没有导出符号(如tcp_v4_rcv、nf_hook_slow),或位于内核代码段深处(无法用sys_call_table覆盖)时,kprobes和ftrace成为终极武器。但它们绝非“万能钩子”,而是把双刃剑:用得好,可实现细粒度控制;用错了,轻则性能暴跌,重则内核死锁。理解其底层机制,是评估一份 rootkit 源码是否专业的关键标尺。
5.1 kprobes:在指令边界插桩的原子性保证
kprobes的核心是动态指令替换:在目标函数首条指令处,用int3(x86)或brk(ARM64)指令替换原指令,当 CPU 执行到此处时触发异常,内核kprobe_handler()捕获后,调用用户注册的pre_handler,再单步执行原指令,最后调用post_handler。整个过程对被 hook 函数透明。
// kprobe hook tcp_v4_rcv 示例 static struct kprobe kp = { .symbol_name = "tcp_v4_rcv", }; static struct kprobe kp_tcp = { .symbol_name = "tcp_v4_rcv", .pre_handler = handler_pre, .post_handler = handler_post, }; static struct kretprobe krp = { .kp.symbol_name = "tcp_v4_rcv", .handler = handler_ret, }; static int __init kprobe_init(void) { int ret; // 注册 kprobe ret = register_kprobe(&kp_tcp); if (ret < 0) { printk(KERN_ERR "register_kprobe failed, returned %d\n", ret); return ret; } // 注册 kretprobe(返回时触发) ret = register_kretprobe(&krp); if (ret < 0) { unregister_kprobe(&kp_tcp); return ret; } return 0; }注意:
kprobe的pre_handler运行在中断上下文,严禁调用可能睡眠的函数(如msleep、wait_event、kmalloc(GFP_KERNEL))。否则会触发scheduling while atomicpanic。这是源码中最隐蔽的坑——编译能过,运行必崩。
5.2 ftrace:基于编译期插桩的零开销监控
与kprobes的运行时替换不同,ftrace依赖 GCC 的-pg编译选项,在每个函数入口插入mcount()调用(x86_64 为call ftrace_caller)。内核启动时,ftrace框架将所有mcount调用点动态替换为nop指令;当用户启用某函数跟踪时,再将对应nop替换为call ftrace_graph_caller。这种设计带来两大优势:
- 零运行时开销:未启用时,函数调用完全不受影响;
- 全栈追踪能力:配合
function_graphtracer,可生成完整的函数调用图谱。
rootkit 源码利用ftrace,通常走两条路:
- 静态 patch:在模块加载时,直接修改
ftrace_caller的跳转地址,使其指向自定义 hook 函数; - 动态注册:调用
register_ftrace_function(),将struct ftrace_ops注册到全局ftrace_ops_list,由内核统一调度。
// ftrace hook 的注册逻辑(需 CONFIG_FUNCTION_TRACER=y) static struct ftrace_ops my_ops = { .func = my_ftrace_handler, .flags = FTRACE_OPS_FL_SAVE_REGS | FTRACE_OPS_FL_IPMODIFY, }; static int __init ftrace_init(void) { int ret; // 启用 ftrace(需内核配置支持) ret = register_ftrace_function(&my_ops); if (ret) { printk(KERN_ERR "Failed to register ftrace ops\n"); return ret; } return 0; }FTRACE_OPS_FL_IPMODIFY标志允许my_ftrace_handler()直接修改regs->ip,实现函数跳转——这比kprobes更底层,也更危险:若ip指向非法地址,CPU 将直接 triple-fault。
5.3 kprobes vs ftrace:选型决策树
| 维度 | kprobes | ftrace |
|---|---|---|
| 适用场景 | 动态 hook 任意地址(包括内联函数、汇编函数) | 仅能 hook 编译时插入mcount的 C 函数 |
| 性能开销 | 每次触发需异常处理、单步执行,约 1000+ cycles | 启用后为call指令开销,约 10~20 cycles |
| 稳定性 | 高(内核原生保障) | 中(依赖编译选项,部分内核版本禁用) |
| 调试友好性 | cat /sys/kernel/debug/kprobes/list可查看所有注册点 | cat /sys/kernel/debug/tracing/available_filter_functions列出可 hook 函数 |
| rootkit 适配难度 | 低(API 稳定) | 高(需确认内核是否启用CONFIG_FUNCTION_TRACER,且目标函数未被notrace修饰) |
一份高质量的 rootkit 源码,往往会同时实现两种 hook 方案,并在my_init()中根据内核配置自动选择:优先尝试ftrace(低开销),失败则 fallback 到kprobes(高兼容)。
6. 设计取舍:为什么真正的 rootkit 从不追求“完美隐藏”
我见过太多开发者执着于“100% 隐藏”:要让ps、lsmod、lsof、netstat、strace全部失效,要绕过所有 EDR 的kprobe、eBPF、Intel PT检测,甚至想对抗硬件级的TDX或SEV。结果呢?代码臃肿到无法调试,一个内核版本升级就全盘崩溃,最终沦为仅供演示的玩具。直到我在某次内核 crash dump 分析中,看到一个存活超 3 年的 rootkit,它的my_init()函数只有 12 行,不 hooksys_call_table,不 patchprocfs,只做一件事:在tcp_v4_rcv()中检测特定 TCP payload,匹配则注入 shellcode 到目标进程的userspace。它暴露在lsmod里,dmesg里有它的日志,但它从不试图隐藏自己——因为它的设计哲学是:隐藏的目的是为了持续存在,而不是为了存在而隐藏。
这让我彻底转变思路。现在我写任何内核模块,第一原则是:最小化内核态变更。能用ftrace就不用kprobes;能用sysctl接口就不用直接改netns;能用LD_PRELOAD用户态注入,就绝不碰内核。因为每一次内核内存写入,都是在和内核的内存管理、RCU 机制、SMP 同步逻辑赌博。而 rootkit 的生存周期,从来不是由“多难被发现”决定,而是由“多难被杀死”决定。一个被lsmod看到但无法rmmod的模块,远比一个lsmod看不到却在rmmod时 panic 的模块更危险。
所以,当我审视一份 rootkit 源码时,我不再问“它隐藏了什么”,而是问:“它修改了内核哪几处内存?每处修改的 RCU 安全性如何?是否考虑了 PREEMPT_RT 实时内核?CONFIG_DEBUG_ATOMIC_SLEEP开启时会不会触发警告?” 这些问题的答案,藏在源码的#ifdef条件编译、synchronize_rcu()调用位置、以及mutex_lock()与spin_lock()的选择里。比如,一份在pre_handler中调用mutex_lock()的 kprobe 源码,我连编译都不会让它过——因为pre_handler在中断上下文,mutex必然 sleep,这是教科书级的错误。
从那以后我每次分析 rootkit 源码,都强制走一遍make menuconfig,对照它的Kconfig依赖,手动开启所有相关内核调试选项(CONFIG_DEBUG_VM,CONFIG_DEBUG_SG,CONFIG_LOCKDEP),然后在 QEMU 中用kgdb单步跟踪my_init()的每一行。不是为了找到 bug,而是为了确认:它是否尊重内核的契约。希望帮到你。
本文还有配套的精品资源,点击获取