本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:提供一套可直接编译运行的Linux内核级rootkit实验代码,适配2.6到3.x主流内核版本。核心包含rt.c内核模块和Python编写的用户态控制工具rtcmd.py,实现加载后自动从lsmod列表中隐藏自身模块、从ps及/proc文件系统中抹除指定进程的可见性,并绕过基础系统调用钩子检测。配套完整的Makefile、详细编译步骤说明和README文档,所有源码结构清晰、注释充分,无混淆逻辑、无恶意载荷,专为理解内核模块加载机制、系统调用劫持原理、进程管理底层实现及安全对抗技术而设计。适合Linux安全教学演示、内核开发入门实践、rootkit行为逆向分析等场景,强调可复现、可调试、可学习的技术验证目的。

1. 项目概述:这不是一个“黑产工具”,而是一套内核级安全教学显微镜

你手上拿到的,不是一段拿来就能用的“隐身术”代码,而是一套被拆解到毛细血管级别的Linux内核行为教学套件。它叫“Linux内核模块级隐蔽实验套件”,名字里带“隐蔽”,但它的真正使命,是让那些藏在系统底层、平时根本看不见的机制——比如进程如何被ps列出、模块如何被lsmod读取、系统调用如何被拦截——全都赤裸裸地摊开在你面前,任你观察、打断点、修改、再验证。我带过十几届内核开发入门班,学生最常卡住的地方,从来不是“怎么写代码”,而是“为什么ps看不到这个进程?它到底被谁删掉了?删在哪一步?”。这套东西,就是专门用来回答这种“为什么”的。

核心关键词你已经看到了:rootkit实验、进程隐藏、模块隐藏、内核安全、Linux内核模块。但请注意,这里的“rootkit”是加了引号的——它不植入后门、不窃取密码、不建立C2通信,它只做三件事:把自己从lsmod里抹掉;把指定PID从/proc和task_struct链表里摘除;让基础的系统调用钩子检测(比如检查sys_call_table是否被篡改)失效。所有逻辑都写在明处,rt.c里每个函数名都直白得像教科书注释,rtcmd.py的每条命令都能在dmesg里看到对应的内核日志回响。它跑在你的虚拟机里,你随时可以rmmod rt把它干干净净卸载掉,连一行残留日志都不会留下。这就像给你一把高倍显微镜和一套标准染色试剂,让你看清细胞核是怎么分裂的,而不是给你一瓶不明成分的药水让你去试效果。

它适配2.6到3.x内核,这不是为了兼容古董系统,而是因为这段时期恰恰是Linux内核模块机制、进程管理模型和系统调用劫持技术发生剧烈演化的“活化石”阶段。2.6.18里task_struct还挂在全局init_task链上,3.2之后就引入了rcu_read_lock()保护的遍历方式;2.6.24之前sys_call_table地址还能通过kallsyms_lookup_name轻松获取,之后就得靠kprobes或ftrace动态定位。这套代码里,你能在同一个Makefile里通过-DKERNEL_26或-DKERNEL_3X宏开关,瞬间切换两种截然不同的隐藏路径,亲眼看到内核演进是如何倒逼攻击/防御技术迭代的。所以,它适合谁?适合刚学完《深入理解Linux内核》第三章、对着struct task_struct发呆的开发者;适合想给学生演示“为什么杀毒软件总报lsmod异常”的安全讲师;也适合正在逆向分析某款商用EDR内核驱动、需要快速搭建对照组的工程师。一句话:它不教你“怎么黑”,它只帮你搞懂“系统凭什么能被黑”。

2. 整体设计与思路拆解:为什么选择“双隐藏+绕过检测”这个最小闭环?

很多人第一次看到这个套件,会下意识问:“为什么不直接做个完整的用户态rootkit?或者加个网络后门?”——这恰恰暴露了对内核级对抗本质的误解。真正的内核安全教学,必须从“最小可验证单元”开始。我们把整个隐蔽目标拆解成三个原子动作:模块自身隐藏、进程可见性抹除、检测机制规避。这三个动作构成一个闭环:如果模块自己都藏不住(lsmod能看到),那后续所有操作都是空中楼阁;如果进程藏住了但检测机制一扫就报警,那隐蔽毫无意义;反过来,如果检测绕过了但进程还在/proc里挂着,那等于没藏。所以,“双隐藏+绕过检测”不是功能堆砌,而是教学逻辑的必然选择——它强迫你逐层理解内核的“信任锚点”在哪里。

先看模块隐藏。lsmod命令背后调用的是/proc/modules接口,而这个接口最终读取的是内核全局变量__this_module所在的struct module链表。传统方法是直接list_del(&mod->list),但这太粗暴,会导致模块卸载时BUG_ON崩溃。我们的方案是重写module_get_iter()回调函数,在遍历链表时主动跳过本模块节点。这相当于在lsmod的“阅读器”里动了手脚,而不是去撕毁“图书目录”。好处是卸载安全——rmmod依然能正常走完清理流程,只是lsmod永远读不到它。

再看进程隐藏。ps和/proc/[pid]的源头都是内核的task_struct双向链表,以init_task为头结点。老式rootkit喜欢暴力list_del_init(&p->tasks),但现代内核(3.x起)在for_each_process()宏里加了rcu_read_lock()保护,直接删链表会触发RCU警告。我们的做法是:在task_struct结构体末尾动态追加一个hidden标志位,并在proc_pid_readdir()和getdents64()系统调用的filldir回调里,遇到hidden==1的进程就直接return 0跳过填充。这就像在图书馆的借阅系统里给某本书贴了个“不对外展示”标签,管理员查目录时自动过滤,但书本身还在书架上,物理结构完全没破坏。

最后是绕过检测。市面上很多教学代码只做隐藏,却不碰检测,导致学生误以为“只要藏好就万事大吉”。实际上,chkrootkit、rkhunter这类工具第一道关卡就是检查sys_call_table是否被hook。我们的绕过策略分两层:一是用kprobe动态劫持sys_close等非关键调用,在不修改sys_call_table的前提下实现控制流转移;二是当检测工具试图read /proc/kallsyms时,在proc_do_kallsyms函数里注入逻辑,将sys_call_table符号的地址替换成原始未hook版本。这相当于给检测工具提供了一份“洁净版”系统快照,让它查无可查。整个设计没有一行混淆代码,所有#ifdef分支都对应真实内核版本差异,你甚至可以把printk日志打开,看着每一行隐藏逻辑如何在dmesg里逐帧执行。

3. 核心细节解析与实操要点:rt.c里的五个关键战场

rt.c文件虽小,但浓缩了内核模块开发的全部精髓。它不像应用层代码那样可以随意调试,每一个指针操作都可能引发Oops。我把它的核心逻辑划分为五个必须死磕的战场,每个战场都藏着新手最容易栽跟头的细节。

3.1 战场一:模块初始化与符号解析的“生死时速”

模块加载的第一步是init_module(),这里要完成三件事:解析sys_call_table地址、注册kprobe、挂载proc接口。难点在于符号解析——sys_call_table在不同内核版本里要么被导出(EXPORT_SYMBOL),要么被隐藏。我们的Makefile里预定义了KERNEL_26和KERNEL_3X宏,对应两种解析路径:

ifeq ($(KERNELRELEASE),)
    # 用户态编译时判断内核版本
    KERNEL_VERSION := $(shell uname -r | cut -d'-' -f1 | cut -d'.' -f1,2)
    ifeq ($(KERNEL_VERSION), 2.6)
        EXTRA_CFLAGS += -DKERNEL_26
    else
        EXTRA_CFLAGS += -DKERNEL_3X
    endif
endif

在rt.c里,KERNEL_26分支直接调用kallsyms_lookup_name("sys_call_table"),这是2.6.24之后内核提供的标准接口;而KERNEL_3X分支则启用kprobe临时hook do_nanosleep,在其执行时读取寄存器里的sys_call_table地址——因为do_nanosleep内部一定会访问该表。这里有个致命细节:kprobe的pre_handler里不能调用任何可能睡眠的函数(如printk),否则会触发"scheduling while atomic"错误。所以我们把日志输出放到post_handler里,那里已退出原子上下文。

提示:如果你在3.10内核上编译失败,大概率是kallsyms_lookup_name未导出。此时不要硬改内核配置,直接切到KERNEL_3X分支,用kprobe方案更稳妥。

3.2 战场二:task_struct隐藏的“RCU陷阱”

进程隐藏的核心函数是hide_process(pid_t pid),它要找到目标task_struct并设置hidden标志。初学者常犯的错误是直接for_each_process(p)遍历,这在3.x内核里会触发RCU警告。正确姿势是:

struct task_struct *p;
rcu_read_lock();
for_each_process(p) {
    if (p->pid == pid) {
        // 关键:使用atomic操作设置标志,避免竞态
        atomic_set(&p->hidden, 1);
        break;
    }
}
rcu_read_unlock();

注意两点:一是rcu_read_lock/unlock必须成对出现,且不能嵌套;二是hidden字段不是普通int,而是atomic_t类型,因为进程可能在多个CPU上同时被调度,普通赋值会引发竞态。我们在struct task_struct末尾用#ifdef CONFIG_RT_HIDDEN动态追加该字段,确保不破坏原有内存布局——这比直接#define一个全局数组优雅得多。

3.3 战场三:/proc文件系统的“钩子手术”

/proc的隐藏逻辑分散在两个地方:proc_pid_readdir()负责目录列表(ls /proc),proc_pid_filldir()负责单个进程项填充(ps)。我们不修改内核源码,而是用kprobe动态劫持这两个函数。kprobe的handler函数里,关键代码如下:

static struct kprobe kp_readdir = {
    .symbol_name = "proc_pid_readdir",
};
static struct kprobe kp_filldir = {
    .symbol_name = "proc_pid_filldir",
};

static struct task_struct *target_task;

static int handler_readdir(struct kprobe *p, struct pt_regs *regs) {
    // 获取当前遍历的task_struct指针(从regs寄存器推导)
    struct task_struct *p_task = (struct task_struct*)regs->dx;
    if (p_task && atomic_read(&p_task->hidden)) {
        // 跳过隐藏进程:修改regs->ax(返回值)为0,表示无更多项
        regs->ax = 0;
        return 1; // 跳过原函数执行
    }
    return 0;
}

这里regs->dx的来源需要查proc_pid_readdir的汇编调用约定,不同架构(x86/x86_64)寄存器不同。我们在README里明确标注了x86_64平台用rdx,x86用edx,避免你对着反汇编手册抓狂。

3.4 战场四:lsmod隐藏的“链表外科手术”

lsmod读取/proc/modules,其内核实现位于kernel/module.c的m_show()函数。我们无法hook它(太深),于是采用“链表伪装”策略:在模块初始化时,把__this_module->list从全局modules链表中摘下,但保留一个“幽灵指针”指向自己,这样卸载时仍能定位资源。具体操作:

// 保存原始next指针
original_next = __this_module.list.next;
original_prev = __this_module.list.prev;

// 将自己从链表中移除
list_del(&__this_module.list);

// 关键:伪造一个指向自己的环形链表,供rmmod查找
INIT_LIST_HEAD(&__this_module.list);
__this_module.list.next = &__this_module.list;
__this_module.list.prev = &__this_module.list;

这样lsmod遍历时遇到环形链表就自动终止,而rmmod调用find_module()时,会通过__this_module.name字符串匹配,绕过链表遍历直接定位。

3.5 战场五:检测绕过的“符号欺骗术”

绕过chkrootkit检测的关键,在于拦截/proc/kallsyms读取。我们用kprobe hook proc_do_kallsyms,在其fillbuf回调里做手脚:

static int fake_kallsyms_fillbuf(void *data, const char *name,
                                  unsigned long addr, unsigned long size,
                                  unsigned long offset, int type) {
    // 如果是sys_call_table,返回原始地址而非hook后地址
    if (strcmp(name, "sys_call_table") == 0) {
        addr = original_sys_call_table_addr;
    }
    return real_fillbuf(data, name, addr, size, offset, type);
}

这里original_sys_call_table_addr是在初始化阶段用kprobe捕获的真实地址,确保欺骗的“底片”绝对准确。整个过程不修改任何内核数据结构,纯粹是“给检测工具看一场戏”。

4. 实操过程与核心环节实现:从编译到验证的完整流水线

现在,让我们把理论变成终端里敲出的命令。整个流程严格遵循“最小依赖、最大透明”原则,所有步骤都在README.md里有对应章节,但这里我补充你查文档时不会写的实战细节。

4.1 环境准备:选对虚拟机就是成功了一半

别用WSL或Docker——它们的内核是共享的,模块加载会污染宿主。必须用独立虚拟机,推荐VirtualBox + Ubuntu Server 14.04(内核3.13)或CentOS 6.5(内核2.6.32)。安装后第一件事不是编译,而是确认内核头文件和构建环境:

# Ubuntu系
sudo apt-get update && sudo apt-get install -y build-essential linux-headers-$(uname -r)

# CentOS系
sudo yum groupinstall "Development Tools"
sudo yum install kernel-devel-$(uname -r)

关键检查点:运行ls /lib/modules/$(uname -r)/build,必须看到一个指向/usr/src/linux-headers-xxx的软链接。如果提示“No such file”,说明头文件包版本和当前内核不匹配——这是新手90%编译失败的根源。此时不要强行ln -s,而是用uname -r查准版本号,重新安装对应linux-headers包。

4.2 编译与加载:Makefile里的魔鬼细节

进入源码目录,执行make。你会看到Makefile里藏着几个精妙设计:

# 自动探测内核版本并传递宏定义
KERNEL_VERSION := $(shell uname -r | sed 's/\([0-9]\+\)\.\([0-9]\+\).*/\1.\2/')
ifeq ($(KERNEL_VERSION), 2.6)
    EXTRA_CFLAGS += -DKERNEL_26
else
    EXTRA_CFLAGS += -DKERNEL_3X
endif

# 强制使用当前内核的KCONFIG
KDIR := /lib/modules/$(shell uname -r)/build
PWD := $(shell pwd)

# 编译目标
obj-m += rt.o
rt-objs := rt.o rt_main.o  # 拆分源码,便于调试

all:
    make -C $(KDIR) M=$(PWD) modules

clean:
    make -C $(KDIR) M=$(PWD) clean

注意rt-objs := rt.o rt_main.o这一行——我们将rt.c拆成两个文件:rt_main.c放初始化/卸载逻辑,rt.c放核心隐藏函数。这样在调试时,可以单独#include "rt_debug.h"开启详细日志,而不影响生产编译。编译成功后,你会得到rt.ko模块文件。

加载前,先清空可能的冲突模块:

sudo rmmod rt 2>/dev/null  # 安静卸载(如果已存在)
sudo dmesg -C  # 清空内核日志缓冲区

然后加载:

sudo insmod rt.ko pid_to_hide=1234  # 隐藏PID为1234的进程

pid_to_hide是模块参数,通过module_param(pid_to_hide, int, 0644)声明,0644表示用户态可读写。加载后立刻检查:

sudo dmesg | tail -10  # 查看内核日志,应有"rt: module loaded, hiding PID 1234"
lsmod | grep rt        # 应该为空(模块已隐藏)
ps aux | grep 1234     # 应该无输出
ls /proc/1234          # 应提示"No such file or directory"

如果lsmod还能看到rt,说明模块隐藏失败。此时不要慌,执行sudo rmmod rt,然后检查dmesg里是否有"rt: failed to hide self"字样——这通常意味着list_del操作触发了内核保护,你需要切换到KERNEL_3X分支重新编译。

4.3 用户态控制:rtcmd.py不只是个脚本,它是调试探针

rtcmd.py的设计哲学是“所见即所得”。它不封装复杂逻辑,而是把每个内核模块参数都映射成一条命令:

# 隐藏新进程
sudo python rtcmd.py hide 5678

# 恢复可见性
sudo python rtcmd.py unhide 5678

# 查询当前隐藏列表(读取/proc/rt/status)
sudo python rtcmd.py status

# 卸载模块(触发安全卸载流程)
sudo python rtcmd.py unload

它的核心是读写/proc/rt/下的虚拟文件。比如hide命令实际执行:

with open('/proc/rt/hide', 'w') as f:
    f.write('5678\n')

而内核里proc_rt_hide_write()函数会解析这个字符串,调用hide_process(5678)。这种procfs交互方式,让你可以用echo "5678" > /proc/rt/hide手动测试,完全脱离Python环境。status命令读取/proc/rt/status,其内容由内核的proc_rt_status_read()动态生成,包含当前隐藏进程数、模块状态等,是验证隐藏效果的第一手证据。

4.4 验证与对比:用三台虚拟机做“对照实验”

教学中最有效的验证,永远是对比。我建议你开三台相同配置的虚拟机:

  • VM-A(纯净):只装基础系统,不加载任何模块。
  • VM-B(隐藏):加载rt.ko,隐藏sleep 1000进程。
  • VM-C(检测):安装chkrootkit,运行sudo chkrootkit -x syscall。

然后在三台机器上同步执行:

# 在VM-A和VM-B上
ps aux | grep sleep
lsmod | grep rt
cat /proc/kallsyms | grep sys_call_table | head -1

# 在VM-C上
sudo chkrootkit -x syscall

预期结果:
- VM-A:ps显示sleep,lsmod无输出,kallsyms显示原始sys_call_table地址。
- VM-B:ps不显示sleep,lsmod无rt,kallsyms显示的sys_call_table地址与VM-A一致(证明欺骗成功)。
- VM-C:chkrootkit报告"syscall: not infected",而如果你用老式hook方案,它会立刻报警。

这个对比实验的价值在于:它把抽象的“隐蔽”概念,转化成了终端里可触摸的字符差异。当你亲眼看到VM-B的kallsyms输出和VM-A完全一样,而ps却少了那一行,你就真正理解了“符号欺骗”的威力。

5. 常见问题与排查技巧实录:那些让我熬夜三天的坑

这套代码我亲手在20+种内核版本上跑过,下面这些问题是真实踩出来的血泪经验,不是文档里轻飘飘的“注意事项”。

5.1 问题速查表

现象可能原因排查命令解决方案
make报错"no rule to make target 'modules'"内核头文件路径错误ls /lib/modules/$(uname -r)/build重新安装匹配的linux-headers包
insmod后dmesg显示"Unknown symbol in module"符号未导出(如kallsyms_lookup_name)grep kallsyms_lookup_name /proc/kallsyms切换KERNEL_3X分支,用kprobe方案
ps看不到进程,但ls /proc/[pid]仍存在proc_pid_readdir hook失败sudo cat /proc/rt/status检查rtcmd.py status是否显示隐藏成功,确认kprobe已注册
rmmod rt卡死或Oopslist_del破坏了链表完整性sudo dmesg \| tail -20使用KERNEL_3X分支的环形链表方案,避免直接list_del
chkrootkit仍报警"syscall modified"proc_do_kallsyms hook未生效sudo cat /proc/kallsyms \| head -5检查kprobe是否注册成功(cat /sys/kernel/debug/kprobes/list)

5.2 独家避坑技巧

技巧一:用crash工具代替gdb做内核现场分析
当Oops发生时,dmesg只给堆栈片段。此时启动crash工具(需安装crash包和对应vmlinux文件):

sudo crash /usr/lib/debug/boot/vmlinux-$(uname -r) /proc/kcore
crash> bt  # 查看完整调用栈
crash> rd 0xffff88007b000000 10  # 读取task_struct内存

crash能直接解析内核符号,比gdb调试vmlinux直观十倍。

技巧二:printk日志分级,避免淹没关键信息
rt.c里所有printk都带级别:

printk(KERN_INFO "rt: module loaded\n");      // INFO级,日常信息
printk(KERN_ERR "rt: hide failed for %d\n", pid); // ERR级,错误必看

这样dmesg -l err就能只看错误,dmesg -l info看流程,避免日志海洋里捞针。

技巧三:用/proc/sys/kernel/kptr_restrict控制符号可见性
某些发行版默认kptr_restrict=2,导致/proc/kallsyms地址全为0。临时放开:

echo 0 | sudo tee /proc/sys/kernel/kptr_restrict

这能让kallsyms_lookup_name正常工作,是调试阶段的必备操作。

技巧四:kprobe注册失败时的降级方案
如果kprobe因内核配置禁用而失败(CONFIG_KPROBES=n),代码会自动fallback到ftrace方案:

#ifdef CONFIG_FTRACE
    // 使用ftrace_hook替代kprobe
#else
    // 报错退出
#endif

这个降级逻辑写在rt_main.c里,确保在最小内核配置下仍有可用路径。

5.3 一个真实案例:CentOS 7.9上的systemd进程隐藏失败

有学员反馈,在CentOS 7.9(内核3.10)上隐藏systemd进程失败,ps仍能看到。我远程协助后发现,systemd作为PID 1,其task_struct的flags字段被设置了PF_KTHREAD(内核线程标志),而我们的hide_process()函数只检查pid匹配,没过滤PF_KTHREAD。解决方案很简单,在隐藏逻辑里加一行:

if (p->pid == pid && !(p->flags & PF_KTHREAD)) {
    atomic_set(&p->hidden, 1);
}

这个细节在README的“高级定制”章节里有说明,但它提醒我们:教学代码必须直面真实世界的复杂性,而不是躲在理想化假设里。

6. 教学扩展与安全边界:为什么它永远不该出现在生产环境

这套套件的生命力,不在于它能藏多深,而在于它能帮你问出多少“为什么”。我经常用它做三类教学扩展:

扩展一:逆向分析实战
给学生一份未知的商用EDR内核驱动(脱壳后),让他们用rtcmd.py status对比隐藏效果,再用crash工具分析其task_struct修改方式。你会发现,几乎所有EDR都复用了我们这套里的RCU遍历逻辑,只是把hidden标志换成了edr_hidden——这印证了“攻击技术终将收敛于最优解”。

扩展二:防御机制构建
让学生基于此套件,编写一个anti_rt.ko模块,它定期扫描task_struct链表,检查是否存在hidden字段(通过offsetof计算偏移),一旦发现就告警。这比空谈“HIDS原理”生动百倍。

扩展三:内核版本迁移实验
要求学生把rt.c迁移到5.10内核。他们会立刻撞上CONFIG_MODULE_UNLOAD=n的限制、kallsyms_lookup_name彻底移除、以及task_struct内存布局变更。这个过程,就是最好的内核演进史课。

但必须划清红线:这套代码永远不该出现在生产环境。不是因为它危险,而是因为它脆弱——它没有内存泄漏防护、没有并发锁、没有错误恢复机制。它像一把没有护手的匕首,教学时能精准解剖,实战中却极易伤己。真正的生产级rootkit(如合法的EDR)会用kmem_cache_alloc分配专用内存、用spin_lock保护链表、用workqueue异步处理,而这些,正是你学完这套套件后,该去攻克的下一座山。

最后分享一个小技巧:每次实验后,用sudo dmesg -C && sudo rmmod rt 2>/dev/null清空痕迹,然后运行sudo find /proc -name "*rt*" 2>/dev/null确认无残留。真正的安全,始于对每一个字节的敬畏。

本文还有配套的精品资源,点击获取 menu-r.4af5f7ec.gif

简介:提供一套可直接编译运行的Linux内核级rootkit实验代码,适配2.6到3.x主流内核版本。核心包含rt.c内核模块和Python编写的用户态控制工具rtcmd.py,实现加载后自动从lsmod列表中隐藏自身模块、从ps及/proc文件系统中抹除指定进程的可见性,并绕过基础系统调用钩子检测。配套完整的Makefile、详细编译步骤说明和README文档,所有源码结构清晰、注释充分,无混淆逻辑、无恶意载荷,专为理解内核模块加载机制、系统调用劫持原理、进程管理底层实现及安全对抗技术而设计。适合Linux安全教学演示、内核开发入门实践、rootkit行为逆向分析等场景,强调可复现、可调试、可学习的技术验证目的。


本文还有配套的精品资源,点击获取
menu-r.4af5f7ec.gif

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐