资讯动态

Linux内核rootkit原理:系统调用劫持与内核态权限控制

发布时间:2026/10/28 13:27:43 来源:尧图企业网站定制
简介这是一份面向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_readrmmod命令本身可能就失效了。因为rmmod依赖delete_module()系统调用而该调用内部会调用security_kernel_module_from_file()等 LSM 钩子——若 rootkit 已篡改 LSM 框架delete_module()可能被静默拦截。更致命的是若my_exit()中尝试恢复sys_call_table而此时已有其他进程正在执行被 hook 的系统调用如openCPU 可能正处在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_64hook 未覆盖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_exit4. 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_disabled1能阻止 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的核心是动态指令替换在目标函数首条指令处用int3x86或brkARM64指令替换原指令当 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_TRACERy 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选型决策树维度kprobesftrace适用场景动态 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而是为了确认它是否尊重内核的契约。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑