简介:这是一份面向Linux内核与系统安全初学者的rootkit源码包,体积精简但覆盖模块隐藏、进程隐藏等核心功能,适合用来理解LKM(可加载内核模块)的基本原理与常见反检测思路。包内共有46个文件,压缩后约36KB,主要包含C语言内核模块源码、Python辅助控制脚本、Makefile编译配置、sample示例目录以及说明文档,各类型文件分工明确,便于按需查阅。源码结构紧凑,通过阅读模块加载、隐藏、清理等关键代码片段,可以直观观察内核模块在系统中的工作过程,也可作为二次开发或编写检测工具的实验基础。目前已有369人学习下载,适合内核安全入门者、逆向爱好者以及对rootkit技术感兴趣的读者参考。1. 为什么拿着告警查不出问题时,我选择把 rootkit 源码逐行读一遍接到告警去查一台数据库服务器:连接数异常,日志里多出定时任务,可ps、netstat全部正常。常规工具跑完一无所获,最后我把一份 linux rootkit 源码拿到隔离环境里逐行读,才意识到攻击者根本不需要改进程列表,只要让系统调用把目录内容过滤一遍就够了。这就是 rootkit 和普通木马的区别:它不靠某个文件活着,而是让内核替它说谎。linux rootkit 源码是一手研究内核隐藏技术的材料:藏进程、藏文件、藏端口、藏模块。真到了案发现场再临时分析,时间根本不够;反过来在实验室里读透一份源码,比任何扫描器都先知道该去哪里翻。源码里能看到的不仅有 hook 点和加载逻辑,还有作者对内核版本兼容性的取舍,这些恰好是应急响应时最缺的线索。这篇文章写给三类人:安全运营工程师,想知道被入侵后怎么从源头排查;内核开发者,想理解 hook 点原理;威胁分析人员,想建立自己的源码指纹库。下文从防御和检测视角拆解,不教怎么写出一套能隐身的坏东西,而是告诉你怎么从源码里找到它动手脚的痕迹,并把一套可复现的校验方法带回家。2. 源码三要素:模块入口、符号定位和内核版本怎么组合在一起2.1 入口函数:init_module 是源码阅读的第一站rootkit 最常见的载体是内核模块,也就是 LKM。虽然现在也有不靠模块文件、直接写内存的加载方式,但绝大多数能拿到源码的样本都保留着module_init和module_exit。拿到源码后我第一步不是读 README,而是先搜这两个宏,把入口函数找出来。grep -R "module_init\|module_exit" . --include="*.c"这条命令会列出每个 C 文件的注册入口。module_init并不是一个普通函数名,它是编译器宏,展开后会把对应函数放到.init.text段,内核加载模块时从这个段调用。module_exit对应卸载逻辑,rootkit 的卸载函数里通常会做恢复操作,比如把被替换的函数指针还原、调用kfree释放内存。阅读入口函数时,我一般会列一个函数调用清单:入口函数里先调用了谁,再调用了谁。典型恶意模块的 init 流程逃不出三件事:拿到内核符号地址、把自己从模块链表里摘掉、替换某个函数指针。你可以顺着这个预期去源码里找调用点,比如看到del_module、lsmod_del、hide_self这类函数名,几乎就是隐藏模块的逻辑。源码和编译产物可以对照看。.ko是 ELF 格式,用readelf -s能列出符号表,即使作者不写注释,符号名也会把意图暴露得差不多。如果一个模块把 init 函数写在.init.text,而里面调用了sys_call_table相关符号,基本可以判断它要走系统调用表路线。2.2 符号定位:从 kallsyms 到查表,一段代码如何适配不同内核rootkit 要 hook 系统调用,必须先拿到目标符号地址。老办法是直接调kallsyms_lookup_name,但从内核 5.7 开始这个符号不再导出给模块,源码里就会出现各种绕法。读源码时这个位置最值得看,因为它直接决定这个样本能在哪些内核版本上工作。static unsigned long *find_syscall_table(void) { unsigned long addr = 0; #ifdef CONFIG_KALLSYMS addr = kallsyms_lookup_name("sys_call_table"); #endif if (addr) return (unsigned long *)addr; // 新内核中 kallsyms_lookup_name 不再导出, // 常见做法是先用 kprobe 或 ftrace 定位一个已知函数, // 再在内存中扫描 sys_call_table 的特征字节序列。 return scan_for_syscall_table(); }这段代码不是某个样本的原文,而是我从多份源码里提取出来的共性骨架。CONFIG_KALLSYMS开启时,地址可以直接拿到;关闭或者新内核不导出时,源码里会退回到扫描方式。阅读时注意条件编译分支,如果一段代码被#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 7, 0)包围,说明作者在主动适配新内核。还有个细节:/proc/kallsyms的内容依赖CONFIG_KALLSYMS,而且 rootkit 自己也可以 hook 输出,所以不要把它当可靠来源。我之前排查时发现/proc/kallsyms里完全找不到sys_call_table,但实际上只是样本把读 proc 的路径过滤了。源码里如果出现kobjs、kallsyms_open、proc_create("kallsyms")之类的代码,说明它在做信息隐藏。2.3 版本兼容在 Makefile 里就能看出大半读源码前先看 Makefile,比直接读 C 代码更能估算时间成本。一个能跨版本编译的 rootkit,Makefile 里通常会有大量条件判断,而那些只针对单一内核版本写的样本,往往版本一换就编不过。obj-m := sample.o KERNELDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean-C指定内核源码目录,M=$(PWD)表示在当前目录构建外部模块,modules是目标动作。这个框架几乎是 Linux 内核模块的通用模板,rootkit 源码和普通模块的区别不在 Makefile 本身,而在于它依赖了哪些内核符号。如果 Makefile 里出现ccflags-y带-D__KERNEL__这种强制宏,说明作者在弥补某些头文件路径的差异。我一般还会用modinfo检查编译产物的元数据,尤其是vermagic。modinfo sample.ko输出里会带vermagic字段,它由内核版本、编译器版本和 SMP/抢占配置计算而来。如果你在一台 5.15 内核的机器上编译,拿到 5.4 内核加载,insmod大概率直接报version magic不匹配。这不是源码 bug,是环境问题,先确认这一点能省掉很多无效调试。3. 从源码里找真正动手的 hook 点:sys_call_table 与文件操作结构3.1 系统调用表的操作手法:getdents64、kill 背后改了什么系统调用表是一张函数指针数组,每个槽位对应一个系统调用号。rootkit 把某个槽位替换成自己的函数,就能在内核态拦截用户态请求。最常被替换的槽位有两个:getdents64用来隐藏目录和文件,kill用来隐藏进程信号关系。源码里搜索这些系统调用名,能快速定位替换逻辑。grep -R "getdents64\|__NR_getdents64\|SYS_getdents64" . --include="*.c"这条命令的输出基本会指向一个自定义函数,比如fake_getdents64或hook_getdents。这些函数通常先调用原始函数拿到目录项,然后遍历缓冲区,把特定的文件名从结果里抹掉。阅读时注意两个参数:第一个是struct file *,用于判断当前遍历的是哪个目录;第二个是用户的缓冲区指针,过滤结果就是在这里动手脚。很多样本不只过滤文件名,还会检查当前进程身份,只对特定进程隐藏,避免把自己暴露给管理员。源码里体现为读取current进程相关的 cred 或者 comm,比如current->cred->uid等于某个值才执行隐藏逻辑,否则直接调用原始函数。如果你在分析源码时看到这种判断,说明它不是无差别隐藏,而是定向隐藏,检测时也要多留一个心眼。3.2 VFS 层的另一个藏身处:file_operations 里的函数指针新一些的源码不再碰 sys_call_table,而是直接挂到虚拟文件系统层。目录遍历最终会调到struct file_operations里的.iterate或.iterate_shared字段,源码只要把目录的f_op替换成伪造的结构体,就能在更底层隐藏文件。这种手法绕过了基于系统调用表的检测,因为表里的指针还是原样。grep -R "iterate_shared\|\.iterate\|file_operations" . --include="*.c"如果搜索结果中出现struct file_operations相关的赋值,比如.iterate = evil_iterate,就说明它没走 sys_call_table。这类 hook 的检测思路完全不同:不能只比较系统调用表地址,要去检查具体目录结构里的f_op。阅读源码时留意一个函数:filp_open。它用来打开一个路径并获取struct file *,然后样本通过file->f_op拿到原始操作集,替换其中一两个函数指针。这种 hook 隐藏文件更彻底,因为ls和find拿到的目录项在 VFS 层就被过滤了,用户态完全看不出异常。但它的缺点也存在:每次打开文件都要校验文件系统类型,所以源码里往往有针对文件系统判断的#ifdef,比如区分 ext4、xfs。3.3 用 grep 和 readelf 在源码里快速定位 hook 点分析一份陌生源码时,不要急着编译运行,先做静态定位。我习惯按“符号名→段信息→字符串”三层走。符号名能直接暴露意图,段信息能判断函数生命周期,字符串能弥补被 strip 掉的符号。readelf -s sample.ko | grep -E "hook|syscall|getdents|proc" strings sample.ko | grep -i "hide\|hidden\|shadow\|ghost"readelf -s输出符号表,grep过滤出名字里带 hook、syscall 等关键词的符号。strings则从二进制里提取可打印字符串,即使作者用 strip 去掉了符号表,字符串也会残留痕迹。如果字符串里出现/proc、/sys、/etc/ld.so.preload,说明它可能在干预 proc 文件系统或用户态加载路径。静态定位的意义在于不运行代码就能形成初步判断。比如我看到readelf输出里有一个notrace前缀的函数,就能推测它想避开 ftrace 检测;看到__kprobes注解,就知道这个函数被设计成可被 kprobe 追踪,也可能在用它做符号定位。这些细节在应急响应时都是证据链的一部分。4. 在隔离环境复现一个校验模块:从拿到源码到跑通检查4.1 准备内核头文件与签名策略:先能编译再说分析 rootkit 源码必须有隔离实验环境,虚拟机加快照是最低配置。编译内核模块需要和当前内核版本完全一致的头文件,我一般用 Debian 系命令行先补齐工具链。sudo apt-get update sudo apt-get install -y linux-headers-$(uname -r) build-essential uname -rlinux-headers-$(uname -r)会被解包到/usr/src/linux-headers-$(uname -r),包含编译模块所需的 Makefile、配置和头文件。build-essential提供 gcc 与 make。安装完成后务必用uname -r核对当前内核版本,确认头文件版本一致,否则编译出的模块加载必然失败。有一个系统配置对加载影响很大:CONFIG_MODULE_SIG。如果内核开启了强制签名校验,未签名模块会直接被拒,报Exec format error。查看当前配置可以用:zcat /proc/config.gz | grep CONFIG_MODULE_SIG_FORCE grep CONFIG_MODULE_SIG_FORCE /boot/config-$(uname -r)如果结果是y,要么换用相同签名配置的内核,要么在启动参数里临时关闭签名强制。实验室环境我会用后者,生产环境绝不做这种操作。这个决定必须在编译前做好,不然白跑一遍编译流程。4.2 可复现的最小校验模块:不改任何指针,只打印它该有的样貌为了验证一个 rootkit 是否篡改了系统调用表,我通常会写一个只读校验模块。它不修改任何指针,只打印sys_call_table的地址和几个关键槽位的当前函数地址,配合加载前后的对比,就能判断 hook 是否存在。#include <linux/init.h> #include <linux/module.h> #include <linux/kallsyms.h> #include <linux/syscalls.h> #include <asm/unistd.h> static unsigned long *sct; static unsigned long orig_read; static unsigned long orig_openat; static int __init chk_init(void) { sct = (unsigned long *)kallsyms_lookup_name("sys_call_table"); if (!sct) { pr_err("[-] cannot find sys_call_table\n"); return -EINVAL; } pr_info("[+] sys_call_table at %px\n", sct); orig_read = sct[__NR_read]; orig_openat = sct[__NR_openat]; pr_info("[+] __NR_read = %px\n", (void *)orig_read); pr_info("[+] __NR_openat = %px\n", (void *)orig_openat); return 0; } static void __exit chk_exit(void) { pr_info("[-] check module unloaded\n"); } module_init(chk_init); module_exit(chk_exit); MODULE_LICENSE("GPL");逻辑说明:kallsyms_lookup_name负责获取sys_call_table地址;如果没有这个符号,说明内核较新或者没有开启CONFIG_KALLSYMS,程序直接返回错误。拿到地址后,分别读取read和openat两个系统调用对应的函数指针,并用pr_info打印到内核日志。整个过程没有写操作,不会改变内核状态。参数说明:__NR_read和__NR_openat是系统调用号,在asm/unistd.h中定义,不同架构数值不同,所以务必在目标架构上编译。%px是打印指针的内核格式化符号,比%p更直接。MODULE_LICENSE("GPL")不是可选项,某些 GPL-only 符号在非 GPL 模块中无法使用,编译阶段就会报unknown symbol。配套的 Makefile 如下:obj-m := syscall_check.o KERNELDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean逐行看:obj-m := syscall_check.o把syscall_check.c声明为模块目标;KERNELDIR指定内核构建目录;all目标执行make -C进入该目录,并用M=$(PWD)告诉内核构建系统模块源码在哪。编译完成后当前目录下会生成.ko文件,这就是可加载的模块产物。4.3 加载前后对照:用一次 insmod 验证 hook 是否被挂上校验模块编译好之后,先不急着加载样本。流程要分两步走:先加载校验模块,记录干净的基线值;再加载可疑模块,重新加载校验模块,对比两次输出。任何槽位地址发生变化,都说明有 hook 发生。sudo insmod syscall_check.ko sudo dmesg | tail -n 10insmod加载模块,dmesg读取内核环形缓冲区。第一次输出里的sys_call_table地址和__NR_read、__NR_openat函数指针,就是这台机器的干净基线。保存好这段输出,然后关闭机器做一个快照,再开始分析可疑样本。可疑样本加载后,重新执行同样命令:sudo rmmod syscall_check sudo insmod syscall_check.ko sudo dmesg | tail -n 10如果两次输出中__NR_read的地址不一致,就可以确定系统调用表被篡改。如果校验模块报cannot find sys_call_table,先别急着下结论,可能是 rootkit 屏蔽了相关符号,也可能是内核配置问题。这时要去/proc/kallsyms验证一次,而不是默认代码有 bug。5. 读 rootkit 源码的避坑清单:5 个我踩过的判断失误5.1 现象:编译报 unknown symbol,第一反应是源码写错了源码本身是完整的,可一编译就出现unknown symbol或者undefined symbol,这是内核模块分析最常见的开局。原因多半不是源码问题,而是当前内核版本没有导出这个符号,或模块被声明为非 GPL。比如kallsyms_lookup_name在 5.7 之后不再导出,老源码直接编译就会栽在这里。解决:先用grep symbol /proc/kallsyms或nm查看符号是否存在于当前内核;如果存在但模块仍编译不过,检查 Makefile 里的ccflags-y有没有加入-D__KERNEL__和-DMODULE。我的习惯是建一个最小测试模块,只调用目标符号,如果最小模块也报同样错误,那就是内核导出一层的问题,跟源码逻辑无关。5.2 现象:insmod 加载被拒,提示 Exec format error模块编译成功,但insmod返回Exec format error,终端上一脸无辜,其实dmesg里写得清清楚楚。原因通常是版本魔法不匹配或者签名校验失败。modinfo里的vermagic字段和当前内核的UTS_RELEASE对不上时,内核直接拒绝加载。解决:先执行modinfo sample.ko | grep vermagic,再执行uname -r。如果两者不一致,就回到相同版本的内核环境下重新编译。如果版本一致但仍然报错,检查内核是否开启CONFIG_MODULE_SIG_FORCE。实验室环境可以给内核启动参数临时追加module.sig_enforce=0,但必须注意这是绕过了安全机制,只能在快照虚机里做。5.3 现象:源码里明明有 sys_call_table,运行时却找不到表源码中用了kallsyms_lookup_name("sys_call_table"),运行时返回空指针,日志显示找不到表。原因可能有两个方向:内核确实没导出该符号,或者 rootkit 自己把/proc/kallsyms的输出过滤了,导致探测路径失真。后者是隐蔽模块的常规操作。解决:不要在运行时只依赖/proc/kallsyms,改用readelf检查模块自身依赖的符号,结合kallsyms_on_each_symbol遍历内核符号表。如果源码里出现字符串sys_call_table且编译后能在.strtab段看到它,说明地址查找逻辑确实存在;找不到表的原因要往内核版本兼容性上排查。5.4 现象:进程列表正常但端口对不上号,源码却没 hook kill有时你以为样本会 hookgetdents64或kill,结果 grep 源码里完全没有这些符号,但进程还是被隐藏了。原因可能是它没有走 sys_call_table,而是隐藏了自己的进程 ID,或者 hook 了proc_pid_readdir这类 proc 文件系统遍历函数,让/proc目录里根本看不到这个进程。解决:调整搜索关键词,把范围扩大到proc_pid、proc_fops、getdents、filldir。阅读源码时优先看它注册了哪些 proc 文件,凡是调用proc_create的地方都可能是在干扰 proc 目录读取。判断隐藏范围时,用cat /proc/pid/status对比ps输出,比单纯看系统调用表更可靠。5.5 现象:hook 点判断错误,绕过了检测走 VFS有次我盯着 sys_call_table 从头比到尾,所有槽位都正常,但文件依然被隐藏。原因就是样本走的是 VFS 层,直接替换file_operations里的.iterate函数指针,系统调用表根本没动。只看表地址的检测工具对这种手法完全失灵。解决:源码里如果出现filp_open、f_op、iterate_shared这些关键词,就别再盯着表看了,去检查目标目录的file_operations是否被整体替换。检测时要同时监控表地址、f_op函数指针和/proc文件内容三层,缺一层都会漏。这个坑告诉我,评估一个 rootkit 源码,先确认它动的是哪一层,再决定用什么手段验证。6. 从源码指纹到检测模型:一小时内给自己留一张后悔药读了一堆 rootkit 源码之后,最有价值的事情是把它们的特征沉淀成检测基线。我常用的技巧是“快照+差异对比”:实验环境里先记录干净状态,再加载样本,再对比差异。这样既能验证样本行为,也能给自己留一张后悔药,一旦环境搞坏,还能从基线里定位问题。#!/bin/bash # 保存当前内核与模块基线的快照 KVER=$(uname -r) DATE=$(date +%F_%H%M%S) BASE_DIR=~/rk_baseline/${KVER}/${DATE} mkdir -p "${BASE_DIR}" cp /proc/kallsyms "${BASE_DIR}/kallsyms.txt" ls -1 /sys/module > "${BASE_DIR}/modules.txt" cat /proc/modules >> "${BASE_DIR}/modules.txt" ls -la /etc/ld.so.preload >> "${BASE_DIR}/ld_preload.txt" 2>/dev/null echo "baseline saved to ${BASE_DIR}"这段脚本把当前内核符号表、已加载模块列表和用户态 preload 文件都保存下来。/proc/kallsyms记录内核符号与地址,/sys/module记录模块目录,/proc/modules记录模块在内存中的状态,/etc/ld.so.preload是用户态预加载劫持的常见位置。保存时机必须是样本加载之前,干净状态才有对比价值。样本跑完后,执行同样的快照脚本到另一个目录,然后用 diff 比对:diff ~/rk_baseline/$(uname -r)/*/kallsyms.txt /proc/kallsyms | head -50 diff <(ls -1 /sys/module) <(cat /proc/modules)第一行对比内核符号表,看地址是否有变化;第二行对比模块列表,看有没有被隐藏的模块。rootkit 即使把lsmod的输出过滤掉,也很难同时掩盖/sys/module和/proc/modules两处记录。这个习惯救过我一次:某次实验里我在没有快照的机器上直接加载了模块,结果内核 panic,系统起不回来,只能从基线里找原因。从那以后我再也不做没快照的加载测试。读源码可以快,但验证必须稳,希望帮到你。本文还有配套的精品资源,点击获取
简介:这是一份面向Linux内核与系统安全初学者的rootkit源码包,体积精简但覆盖模块隐藏、进程隐藏等核心功能,适合用来理解LKM(可加载内核模块)的基本原理与常见反检测思路。包内共有46个文件,压缩后约36KB,主要包含C语言内核模块源码、Python辅助控制脚本、Makefile编译配置、sample示例目录以及说明文档,各类型文件分工明确,便于按需查阅。源码结构紧凑,通过阅读模块加载、隐藏、清理等关键代码片段,可以直观观察内核模块在系统中的工作过程,也可作为二次开发或编写检测工具的实验基础。目前已有369人学习下载,适合内核安全入门者、逆向爱好者以及对rootkit技术感兴趣的读者参考。1. 为什么拿着告警查不出问题时,我选择把 rootkit 源码逐行读一遍接到告警去查一台数据库服务器:连接数异常,日志里多出定时任务,可ps、netstat全部正常。常规工具跑完一无所获,最后我把一份 linux rootkit 源码拿到隔离环境里逐行读,才意识到攻击者根本不需要改进程列表,只要让系统调用把目录内容过滤一遍就够了。这就是 rootkit 和普通木马的区别:它不靠某个文件活着,而是让内核替它说谎。linux rootkit 源码是一手研究内核隐藏技术的材料:藏进程、藏文件、藏端口、藏模块。真到了案发现场再临时分析,时间根本不够;反过来在实验室里读透一份源码,比任何扫描器都先知道该去哪里翻。源码里能看到的不仅有 hook 点和加载逻辑,还有作者对内核版本兼容性的取舍,这些恰好是应急响应时最缺的线索。这篇文章写给三类人:安全运营工程师,想知道被入侵后怎么从源头排查;内核开发者,想理解 hook 点原理;威胁分析人员,想建立自己的源码指纹库。下文从防御和检测视角拆解,不教怎么写出一套能隐身的坏东西,而是告诉你怎么从源码里找到它动手脚的痕迹,并把一套可复现的校验方法带回家。2. 源码三要素:模块入口、符号定位和内核版本怎么组合在一起2.1 入口函数:init_module 是源码阅读的第一站rootkit 最常见的载体是内核模块,也就是 LKM。虽然现在也有不靠模块文件、直接写内存的加载方式,但绝大多数能拿到源码的样本都保留着module_init和module_exit。拿到源码后我第一步不是读 README,而是先搜这两个宏,把入口函数找出来。grep -R "module_init\|module_exit" . --include="*.c"这条命令会列出每个 C 文件的注册入口。module_init并不是一个普通函数名,它是编译器宏,展开后会把对应函数放到.init.text段,内核加载模块时从这个段调用。module_exit对应卸载逻辑,rootkit 的卸载函数里通常会做恢复操作,比如把被替换的函数指针还原、调用kfree释放内存。阅读入口函数时,我一般会列一个函数调用清单:入口函数里先调用了谁,再调用了谁。典型恶意模块的 init 流程逃不出三件事:拿到内核符号地址、把自己从模块链表里摘掉、替换某个函数指针。你可以顺着这个预期去源码里找调用点,比如看到del_module、lsmod_del、hide_self这类函数名,几乎就是隐藏模块的逻辑。源码和编译产物可以对照看。.ko是 ELF 格式,用readelf -s能列出符号表,即使作者不写注释,符号名也会把意图暴露得差不多。如果一个模块把 init 函数写在.init.text,而里面调用了sys_call_table相关符号,基本可以判断它要走系统调用表路线。2.2 符号定位:从 kallsyms 到查表,一段代码如何适配不同内核rootkit 要 hook 系统调用,必须先拿到目标符号地址。老办法是直接调kallsyms_lookup_name,但从内核 5.7 开始这个符号不再导出给模块,源码里就会出现各种绕法。读源码时这个位置最值得看,因为它直接决定这个样本能在哪些内核版本上工作。static unsigned long *find_syscall_table(void) { unsigned long addr = 0; #ifdef CONFIG_KALLSYMS addr = kallsyms_lookup_name("sys_call_table"); #endif if (addr) return (unsigned long *)addr; // 新内核中 kallsyms_lookup_name 不再导出, // 常见做法是先用 kprobe 或 ftrace 定位一个已知函数, // 再在内存中扫描 sys_call_table 的特征字节序列。 return scan_for_syscall_table(); }这段代码不是某个样本的原文,而是我从多份源码里提取出来的共性骨架。CONFIG_KALLSYMS开启时,地址可以直接拿到;关闭或者新内核不导出时,源码里会退回到扫描方式。阅读时注意条件编译分支,如果一段代码被#if LINUX_VERSION_CODE >= KERNEL_VERSION(5, 7, 0)包围,说明作者在主动适配新内核。还有个细节:/proc/kallsyms的内容依赖CONFIG_KALLSYMS,而且 rootkit 自己也可以 hook 输出,所以不要把它当可靠来源。我之前排查时发现/proc/kallsyms里完全找不到sys_call_table,但实际上只是样本把读 proc 的路径过滤了。源码里如果出现kobjs、kallsyms_open、proc_create("kallsyms")之类的代码,说明它在做信息隐藏。2.3 版本兼容在 Makefile 里就能看出大半读源码前先看 Makefile,比直接读 C 代码更能估算时间成本。一个能跨版本编译的 rootkit,Makefile 里通常会有大量条件判断,而那些只针对单一内核版本写的样本,往往版本一换就编不过。obj-m := sample.o KERNELDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean-C指定内核源码目录,M=$(PWD)表示在当前目录构建外部模块,modules是目标动作。这个框架几乎是 Linux 内核模块的通用模板,rootkit 源码和普通模块的区别不在 Makefile 本身,而在于它依赖了哪些内核符号。如果 Makefile 里出现ccflags-y带-D__KERNEL__这种强制宏,说明作者在弥补某些头文件路径的差异。我一般还会用modinfo检查编译产物的元数据,尤其是vermagic。modinfo sample.ko输出里会带vermagic字段,它由内核版本、编译器版本和 SMP/抢占配置计算而来。如果你在一台 5.15 内核的机器上编译,拿到 5.4 内核加载,insmod大概率直接报version magic不匹配。这不是源码 bug,是环境问题,先确认这一点能省掉很多无效调试。3. 从源码里找真正动手的 hook 点:sys_call_table 与文件操作结构3.1 系统调用表的操作手法:getdents64、kill 背后改了什么系统调用表是一张函数指针数组,每个槽位对应一个系统调用号。rootkit 把某个槽位替换成自己的函数,就能在内核态拦截用户态请求。最常被替换的槽位有两个:getdents64用来隐藏目录和文件,kill用来隐藏进程信号关系。源码里搜索这些系统调用名,能快速定位替换逻辑。grep -R "getdents64\|__NR_getdents64\|SYS_getdents64" . --include="*.c"这条命令的输出基本会指向一个自定义函数,比如fake_getdents64或hook_getdents。这些函数通常先调用原始函数拿到目录项,然后遍历缓冲区,把特定的文件名从结果里抹掉。阅读时注意两个参数:第一个是struct file *,用于判断当前遍历的是哪个目录;第二个是用户的缓冲区指针,过滤结果就是在这里动手脚。很多样本不只过滤文件名,还会检查当前进程身份,只对特定进程隐藏,避免把自己暴露给管理员。源码里体现为读取current进程相关的 cred 或者 comm,比如current->cred->uid等于某个值才执行隐藏逻辑,否则直接调用原始函数。如果你在分析源码时看到这种判断,说明它不是无差别隐藏,而是定向隐藏,检测时也要多留一个心眼。3.2 VFS 层的另一个藏身处:file_operations 里的函数指针新一些的源码不再碰 sys_call_table,而是直接挂到虚拟文件系统层。目录遍历最终会调到struct file_operations里的.iterate或.iterate_shared字段,源码只要把目录的f_op替换成伪造的结构体,就能在更底层隐藏文件。这种手法绕过了基于系统调用表的检测,因为表里的指针还是原样。grep -R "iterate_shared\|\.iterate\|file_operations" . --include="*.c"如果搜索结果中出现struct file_operations相关的赋值,比如.iterate = evil_iterate,就说明它没走 sys_call_table。这类 hook 的检测思路完全不同:不能只比较系统调用表地址,要去检查具体目录结构里的f_op。阅读源码时留意一个函数:filp_open。它用来打开一个路径并获取struct file *,然后样本通过file->f_op拿到原始操作集,替换其中一两个函数指针。这种 hook 隐藏文件更彻底,因为ls和find拿到的目录项在 VFS 层就被过滤了,用户态完全看不出异常。但它的缺点也存在:每次打开文件都要校验文件系统类型,所以源码里往往有针对文件系统判断的#ifdef,比如区分 ext4、xfs。3.3 用 grep 和 readelf 在源码里快速定位 hook 点分析一份陌生源码时,不要急着编译运行,先做静态定位。我习惯按“符号名→段信息→字符串”三层走。符号名能直接暴露意图,段信息能判断函数生命周期,字符串能弥补被 strip 掉的符号。readelf -s sample.ko | grep -E "hook|syscall|getdents|proc" strings sample.ko | grep -i "hide\|hidden\|shadow\|ghost"readelf -s输出符号表,grep过滤出名字里带 hook、syscall 等关键词的符号。strings则从二进制里提取可打印字符串,即使作者用 strip 去掉了符号表,字符串也会残留痕迹。如果字符串里出现/proc、/sys、/etc/ld.so.preload,说明它可能在干预 proc 文件系统或用户态加载路径。静态定位的意义在于不运行代码就能形成初步判断。比如我看到readelf输出里有一个notrace前缀的函数,就能推测它想避开 ftrace 检测;看到__kprobes注解,就知道这个函数被设计成可被 kprobe 追踪,也可能在用它做符号定位。这些细节在应急响应时都是证据链的一部分。4. 在隔离环境复现一个校验模块:从拿到源码到跑通检查4.1 准备内核头文件与签名策略:先能编译再说分析 rootkit 源码必须有隔离实验环境,虚拟机加快照是最低配置。编译内核模块需要和当前内核版本完全一致的头文件,我一般用 Debian 系命令行先补齐工具链。sudo apt-get update sudo apt-get install -y linux-headers-$(uname -r) build-essential uname -rlinux-headers-$(uname -r)会被解包到/usr/src/linux-headers-$(uname -r),包含编译模块所需的 Makefile、配置和头文件。build-essential提供 gcc 与 make。安装完成后务必用uname -r核对当前内核版本,确认头文件版本一致,否则编译出的模块加载必然失败。有一个系统配置对加载影响很大:CONFIG_MODULE_SIG。如果内核开启了强制签名校验,未签名模块会直接被拒,报Exec format error。查看当前配置可以用:zcat /proc/config.gz | grep CONFIG_MODULE_SIG_FORCE grep CONFIG_MODULE_SIG_FORCE /boot/config-$(uname -r)如果结果是y,要么换用相同签名配置的内核,要么在启动参数里临时关闭签名强制。实验室环境我会用后者,生产环境绝不做这种操作。这个决定必须在编译前做好,不然白跑一遍编译流程。4.2 可复现的最小校验模块:不改任何指针,只打印它该有的样貌为了验证一个 rootkit 是否篡改了系统调用表,我通常会写一个只读校验模块。它不修改任何指针,只打印sys_call_table的地址和几个关键槽位的当前函数地址,配合加载前后的对比,就能判断 hook 是否存在。#include <linux/init.h> #include <linux/module.h> #include <linux/kallsyms.h> #include <linux/syscalls.h> #include <asm/unistd.h> static unsigned long *sct; static unsigned long orig_read; static unsigned long orig_openat; static int __init chk_init(void) { sct = (unsigned long *)kallsyms_lookup_name("sys_call_table"); if (!sct) { pr_err("[-] cannot find sys_call_table\n"); return -EINVAL; } pr_info("[+] sys_call_table at %px\n", sct); orig_read = sct[__NR_read]; orig_openat = sct[__NR_openat]; pr_info("[+] __NR_read = %px\n", (void *)orig_read); pr_info("[+] __NR_openat = %px\n", (void *)orig_openat); return 0; } static void __exit chk_exit(void) { pr_info("[-] check module unloaded\n"); } module_init(chk_init); module_exit(chk_exit); MODULE_LICENSE("GPL");逻辑说明:kallsyms_lookup_name负责获取sys_call_table地址;如果没有这个符号,说明内核较新或者没有开启CONFIG_KALLSYMS,程序直接返回错误。拿到地址后,分别读取read和openat两个系统调用对应的函数指针,并用pr_info打印到内核日志。整个过程没有写操作,不会改变内核状态。参数说明:__NR_read和__NR_openat是系统调用号,在asm/unistd.h中定义,不同架构数值不同,所以务必在目标架构上编译。%px是打印指针的内核格式化符号,比%p更直接。MODULE_LICENSE("GPL")不是可选项,某些 GPL-only 符号在非 GPL 模块中无法使用,编译阶段就会报unknown symbol。配套的 Makefile 如下:obj-m := syscall_check.o KERNELDIR := /lib/modules/$(shell uname -r)/build all: $(MAKE) -C $(KERNELDIR) M=$(PWD) modules clean: $(MAKE) -C $(KERNELDIR) M=$(PWD) clean逐行看:obj-m := syscall_check.o把syscall_check.c声明为模块目标;KERNELDIR指定内核构建目录;all目标执行make -C进入该目录,并用M=$(PWD)告诉内核构建系统模块源码在哪。编译完成后当前目录下会生成.ko文件,这就是可加载的模块产物。4.3 加载前后对照:用一次 insmod 验证 hook 是否被挂上校验模块编译好之后,先不急着加载样本。流程要分两步走:先加载校验模块,记录干净的基线值;再加载可疑模块,重新加载校验模块,对比两次输出。任何槽位地址发生变化,都说明有 hook 发生。sudo insmod syscall_check.ko sudo dmesg | tail -n 10insmod加载模块,dmesg读取内核环形缓冲区。第一次输出里的sys_call_table地址和__NR_read、__NR_openat函数指针,就是这台机器的干净基线。保存好这段输出,然后关闭机器做一个快照,再开始分析可疑样本。可疑样本加载后,重新执行同样命令:sudo rmmod syscall_check sudo insmod syscall_check.ko sudo dmesg | tail -n 10如果两次输出中__NR_read的地址不一致,就可以确定系统调用表被篡改。如果校验模块报cannot find sys_call_table,先别急着下结论,可能是 rootkit 屏蔽了相关符号,也可能是内核配置问题。这时要去/proc/kallsyms验证一次,而不是默认代码有 bug。5. 读 rootkit 源码的避坑清单:5 个我踩过的判断失误5.1 现象:编译报 unknown symbol,第一反应是源码写错了源码本身是完整的,可一编译就出现unknown symbol或者undefined symbol,这是内核模块分析最常见的开局。原因多半不是源码问题,而是当前内核版本没有导出这个符号,或模块被声明为非 GPL。比如kallsyms_lookup_name在 5.7 之后不再导出,老源码直接编译就会栽在这里。解决:先用grep symbol /proc/kallsyms或nm查看符号是否存在于当前内核;如果存在但模块仍编译不过,检查 Makefile 里的ccflags-y有没有加入-D__KERNEL__和-DMODULE。我的习惯是建一个最小测试模块,只调用目标符号,如果最小模块也报同样错误,那就是内核导出一层的问题,跟源码逻辑无关。5.2 现象:insmod 加载被拒,提示 Exec format error模块编译成功,但insmod返回Exec format error,终端上一脸无辜,其实dmesg里写得清清楚楚。原因通常是版本魔法不匹配或者签名校验失败。modinfo里的vermagic字段和当前内核的UTS_RELEASE对不上时,内核直接拒绝加载。解决:先执行modinfo sample.ko | grep vermagic,再执行uname -r。如果两者不一致,就回到相同版本的内核环境下重新编译。如果版本一致但仍然报错,检查内核是否开启CONFIG_MODULE_SIG_FORCE。实验室环境可以给内核启动参数临时追加module.sig_enforce=0,但必须注意这是绕过了安全机制,只能在快照虚机里做。5.3 现象:源码里明明有 sys_call_table,运行时却找不到表源码中用了kallsyms_lookup_name("sys_call_table"),运行时返回空指针,日志显示找不到表。原因可能有两个方向:内核确实没导出该符号,或者 rootkit 自己把/proc/kallsyms的输出过滤了,导致探测路径失真。后者是隐蔽模块的常规操作。解决:不要在运行时只依赖/proc/kallsyms,改用readelf检查模块自身依赖的符号,结合kallsyms_on_each_symbol遍历内核符号表。如果源码里出现字符串sys_call_table且编译后能在.strtab段看到它,说明地址查找逻辑确实存在;找不到表的原因要往内核版本兼容性上排查。5.4 现象:进程列表正常但端口对不上号,源码却没 hook kill有时你以为样本会 hookgetdents64或kill,结果 grep 源码里完全没有这些符号,但进程还是被隐藏了。原因可能是它没有走 sys_call_table,而是隐藏了自己的进程 ID,或者 hook 了proc_pid_readdir这类 proc 文件系统遍历函数,让/proc目录里根本看不到这个进程。解决:调整搜索关键词,把范围扩大到proc_pid、proc_fops、getdents、filldir。阅读源码时优先看它注册了哪些 proc 文件,凡是调用proc_create的地方都可能是在干扰 proc 目录读取。判断隐藏范围时,用cat /proc/pid/status对比ps输出,比单纯看系统调用表更可靠。5.5 现象:hook 点判断错误,绕过了检测走 VFS有次我盯着 sys_call_table 从头比到尾,所有槽位都正常,但文件依然被隐藏。原因就是样本走的是 VFS 层,直接替换file_operations里的.iterate函数指针,系统调用表根本没动。只看表地址的检测工具对这种手法完全失灵。解决:源码里如果出现filp_open、f_op、iterate_shared这些关键词,就别再盯着表看了,去检查目标目录的file_operations是否被整体替换。检测时要同时监控表地址、f_op函数指针和/proc文件内容三层,缺一层都会漏。这个坑告诉我,评估一个 rootkit 源码,先确认它动的是哪一层,再决定用什么手段验证。6. 从源码指纹到检测模型:一小时内给自己留一张后悔药读了一堆 rootkit 源码之后,最有价值的事情是把它们的特征沉淀成检测基线。我常用的技巧是“快照+差异对比”:实验环境里先记录干净状态,再加载样本,再对比差异。这样既能验证样本行为,也能给自己留一张后悔药,一旦环境搞坏,还能从基线里定位问题。#!/bin/bash # 保存当前内核与模块基线的快照 KVER=$(uname -r) DATE=$(date +%F_%H%M%S) BASE_DIR=~/rk_baseline/${KVER}/${DATE} mkdir -p "${BASE_DIR}" cp /proc/kallsyms "${BASE_DIR}/kallsyms.txt" ls -1 /sys/module > "${BASE_DIR}/modules.txt" cat /proc/modules >> "${BASE_DIR}/modules.txt" ls -la /etc/ld.so.preload >> "${BASE_DIR}/ld_preload.txt" 2>/dev/null echo "baseline saved to ${BASE_DIR}"这段脚本把当前内核符号表、已加载模块列表和用户态 preload 文件都保存下来。/proc/kallsyms记录内核符号与地址,/sys/module记录模块目录,/proc/modules记录模块在内存中的状态,/etc/ld.so.preload是用户态预加载劫持的常见位置。保存时机必须是样本加载之前,干净状态才有对比价值。样本跑完后,执行同样的快照脚本到另一个目录,然后用 diff 比对:diff ~/rk_baseline/$(uname -r)/*/kallsyms.txt /proc/kallsyms | head -50 diff <(ls -1 /sys/module) <(cat /proc/modules)第一行对比内核符号表,看地址是否有变化;第二行对比模块列表,看有没有被隐藏的模块。rootkit 即使把lsmod的输出过滤掉,也很难同时掩盖/sys/module和/proc/modules两处记录。这个习惯救过我一次:某次实验里我在没有快照的机器上直接加载了模块,结果内核 panic,系统起不回来,只能从基线里找原因。从那以后我再也不做没快照的加载测试。读源码可以快,但验证必须稳,希望帮到你。本文还有配套的精品资源,点击获取