资讯动态

Linux Rootkit源码逐行解读:内核隐藏技术与检测实践

发布时间:2026/10/20 6:33:32 来源:尧图企业网站定制
简介这是一份面向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 代码更能估算时间成本。一个能跨版本编译的 rootkitMakefile 里通常会有大量条件判断而那些只针对单一内核版本写的样本往往版本一换就编不过。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\|ghostreadelf -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_enforce0但必须注意这是绕过了安全机制只能在快照虚机里做。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系统起不回来只能从基线里找原因。从那以后我再也不做没快照的加载测试。读源码可以快但验证必须稳希望帮到你。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑