“又是深夜加班分析一台报异常的服务器内存镜像,Volatility 3 的 linux.pslist 输出整整齐齐,几十个进程都能对上 PID、父进程和启动时间,怎么看都是一台正常机器。可网络层明明有数据在持续回连外部地址,却始终找不到对应的进程在 pslist 里出现。那一刻我很确定:有什么东西从进程列表里‘消失’了。
后来验证是典型的 DKOM 型 rootkit,恶意进程把自己从内核进程链表上摘掉,常规插件按链表扫描自然看不见它。真正帮我把它捞出来的,是一款叫 caveman 的取证辅助工具——它不信任高层的进程链表,而是像原始人翻动兽骨一样,从内核的多个底层视图中重新搜索‘不该存在却存在’的对象。
这篇文章就从我的实战角度展开,说清楚 caveman 是什么、它解决的到底是哪一类检测盲区、我们怎么在自己可控的实验环境里跑通它,以及我踩过的几个比较磨人的坑。内容适合做安全分析、主机取证、恶意样本溯源的同行,也适合想深入理解 Linux 内核对象枚举的开发者参考。”
1. caveman 的定位:为什么常规扫描会漏掉“不在名单上的人”
1.1 Volatility 的常规插件并不是万能的
很多人第一次接触内存取证,用的都是 Volatility 3 里那几个经典插件:linux.pslist、linux.pstree、linux.mods、linux.sockets。它们的核心思路很直接:Linux 内核在运行过程中维护了一批链表和哈希表,进程、模块、socket 都挂在上面,插件遍历这些结构,把内容打印出来。
问题在于:这些结构本身是可写的。内核模块运行在高特权级,只要拿到 root 权限或成功提权,攻击者就有能力直接修改链表指针、摘除节点、伪造对象。一旦恶意进程从 task_struct 的 tasks 双向链表上被摘掉,linux.pslist 就算把链表走完也看不到它。可它仍然在运行,仍然保持网络连接,仍然占着内存。
这类技术统称 DKOM(Direct Kernel Object Manipulation,直接内核对象操作),名字听着唬人,原理其实像物理课时把木棍从排队模型中抽走——木棍不在了,但队列里空缺的位置还在。
1.2 从“信任列表”到“交叉验证”
caveman 的定位和常规插件不一样。常规插件默认信任内核提供的可见结构,而它更接近一种“狩猎者”思路:不依赖单一视图,而是从多个独立维护的内核结构里重新枚举一遍进程、线程、模块和回调,再做交叉比对。凡是在 A 结构里出现、却在 B 结构里看不到的,就是需要重点怀疑的对象。
我把这种差异整理成一张对照表,方便你理解它的价值:
| 怀疑对象 | 常规插件枚举方式 | 被隐藏后的现象 | caveman 式交叉验证手段 |
|---|---|---|---|
| 恶意进程 | 遍历 task_struct 的 tasks 链表 | 进程从链表摘除,pslist 看不到 | 结合 PID 哈希表、运行队列、RCU 回调等重新枚举 |
| 恶意内核模块 | 遍历 modules 链表 | 模块从链表移除,lsmod 和 linux.mods 均失效 | 从内核符号表、sysfs 残留等侧翼视角发现 |
| 系统调用钩子 | 检查 sys_call_table 内容 | 表项被替换为恶意地址,常规比对无感知 | 逐项校验 handler 是否指向预期内核函数 |
| 内核线程提权 | 查看进程 cred 结构 | 篡改 uid/gid 字段 | 对比不同视角下进程凭证的一致性 |
1.3 “原始人”命名的背后逻辑
我最初看到 caveman 这个名字,以为作者在开玩笑。后来用了一轮才明白,这个名字其实在说一种方法论:原始人捕猎不看地图,而是看地上的脚印、折断的树枝、泥土里的气味。内存取证也一样,高级攻击者很容易伪造“地图”,但很难同时伪造所有“脚印”。比如他摘除了进程链表节点,却可能漏掉调度器队列里残留的 task_struct 指针,也可能漏掉 PID 位图里的占用位。
所以 caveman 这类工具真正传达的理念是:不要轻信单一枚举,要多视角交叉验证,再回到内核对象本体重构证据链。这也是我建议任何做主机取证的人,不管最终用不用这个工具,都先把这个思路刻在脑子里的原因。
2. 藏在内存里的“隐身术”:进程、驱动与钩子的隐藏原理
要真正读懂 caveman 的输出,你得先明白攻击者是怎么藏东西的。下面拆几个最常见的隐藏手段。
2.1 DKOM 进程隐藏:链表摘除与进程视图重建
Linux 内核用双向链表把所有的 task_struct 串起来,ps、top、Volatility 的 linux.pslist 最终都要靠遍历这个链表来枚举进程。rootkit 的常见操作,是拿到目标进程的 task_struct 地址后,修改它前后两个邻居的指针,让这个节点从链表里“脱落”。
脱落之后,进程还在运行,因为调度器不一定依赖这条链表来找任务;网络连接也可能还在,因为 socket 不会因为进程被隐藏就自动断开。但从所有依赖这个链表的工具视角看,这个进程确实“不存在”了。
想把它找回来,就得换一条枚举路径。比如:
- 遍历 PID 哈希表,内核在 fork 的时候会把进程插进去,供 pid 号快速查找;
- 遍历运行队列,如果进程正在运行或可运行,调度器结构里可能有线索;
- 扫描 RCU 机制维护的进程通知链。
caveman 做的,就是把这几条路径同时走一遍,将结果放到一起比对。
2.2 系统调用表钩子与内联钩子:从“转发”到“篡改”
很多老式 rootkit 更直接,它们会改 sys_call_table,把某个系统调用的处理函数地址换成自己的函数。比如把sys_read的地址改成恶意函数地址,这样任何进程调用 read 都会先进恶意代码,由恶意代码过滤数据后再转发给真正的 sys_read。
检测思路也直接:把 sys_call_table 里每一项的地址取出来,和内核符号表里预期的处理函数地址对比。如果发现表项指向的地址不是已知函数,就是被挂钩了。
还有更隐蔽的 inline hook,不替换表项本身,而是改目标函数开头几个字节,塞入一条跳转指令,跳转到恶意代码。这种检测要从函数的指令字节入手,看 prologue 是否为常见的跳转指令,Caveman 这类工具会利用 Volatility 3 的数据遍历能力逐项检查。
2.3 模块隐藏与内核线程伪装
内核模块的隐藏类似进程,攻击者会把自己的模块从 modules 链表摘除,这样lsmod看不到,linux.mods 也扫不到。但模块加载会涉及内核符号表、模块布局、sysfs 的 kobject 等结构,这些关联对象未必都被清理干净,交叉枚举时往往能发现“孤儿”结构。
内核线程伪装又是一种。攻击者不隐藏线程,而是把恶意线程的 comm 改成一个看起来无害的名字,比如kworker/0:1,让你以为是内核正常线程。但仔细看它的启动时间、调用栈、关联的进程上下文,就会发现它根本不是标准的内核工作线程。这类伪装用单一列表很难识别,同样需要跨结构比对。
理解完这些隐藏手法,你再看后面的实验,就会发现 caveman 的每一步输出不是瞎猜,而是针对这些“隐藏逻辑”做的结构性排查。
3. 从零到一跑通 caveman:内存镜像、符号表与插件调用全链路
说一千道一万,不如直接上手跑一遍。下面是我在本地实验环境里完整跑通的过程。
3.1 准备一台“目标虚拟机”和一台“分析机”
我建议用 VirtualBox 或 VMware 开一台 Ubuntu 22.04 Server,内存给 2GB 到 4GB 就够了,目的是制造一份可复现的内存镜像。分析机可以是任意的 Linux 发行版,重点是 Python 3.8 以上环境。
目标机上我们需要先用 LiME 抓内存。LiME 是一个经典的内存采集工具,编译后会生成一个内核模块,加载时把物理内存 dump 出来。编译之前需要确保目标机装好了内核头文件:
sudo apt update sudo apt install git build-essential linux-headers-$(uname -r) git clone https://github.com/504ensicsLabs/LiME.git cd LiME/src makemake 完成后,src目录下会多出一个类似lime-6.5.0-...ko的文件。加载它并指定输出路径:
sudo insmod ./lime-6.5.0-....ko "path=/home/user/web-20240613-2200.lime format=lime"注意:加载这个模块的瞬间,内存镜像会捕捉到 LiME 自身的内核模块信息,这是正常现象,分析时看到 lime 模块不用惊讶。采集完成后sudo rmmod lime卸载模块即可。
3.2 分析机上的 Volatility 3 与符号表准备
Volatility 3 推荐用虚拟环境安装,避免污染系统 Python:
python3 -m venv vol3 source vol3/bin/activate pip install volatility3安装完成后,先跑一个最基础的 pslist 验证框架是否正常:
python3 vol.py -f /path/to/web-20240613-2200.lime linux.pslistVolatility 3 在 Linux 分析上相对省心的一点是:它会尝试自动处理内核符号表,某些场景还会从网上下载 symbol 文件。但如果分析机完全无法访问外部资源,或者目标内核版本太新/太冷门,就需要手动准备符号表。
最稳妥的办法,是直接把目标机的/usr/lib/debug/boot/下的文件拷贝一份到分析机,或者从目标机的内核 debug 包中提取vmlinux。符号表不匹配时,Volatility 会报类似 “could not locate symbol” 的错误,这一块我在第五部分会单独展开说。
3.3 安装 caveman 并将其注册成 Volatility 3 插件
caveman 通常以插件目录形式提供,结构类似一堆.py文件。拉下来后,找到它的插件目录,放到 Volatility 3 能识别的位置:
git clone https://github.com/目标仓库/caveman.git # 假设仓库里的插件在 caveman/plugins 目录 ln -s /path/to/caveman/plugins /path/to/vol3/volatility3/framework/plugins/caveman放置完成后重新执行:
python3 vol.py --help如果在插件列表里看到linux.caveman相关条目,说明注册成功。具体插件名可能因仓库版本而异,你只需要确认它不是报 import 错误即可。
启用插件来识别内核相关结构时,常见的调用形式类似:
python3 vol.py -f /path/to/mem.lime linux.caveman --detail具体参数要看你拉取的版本说明,这里不展开每个开关,重点是理解它输出的内容:它会列出多个不同视图枚举到的进程、线程、模块,然后把交叉比对发现的可疑对象单独标记出来。
3.4 首次跑通的判定标准
caveman 跑通并不意味着你能立刻发现攻击,它更多是给一张“交叉视图差异清单”。如果某个对象在进程链表里看不到,却在 PID 哈希表里存在,那么清单上就会有冲突提示。这个提示就是你进一步分析的核心线索。
我第一次跑通的时候,期待是看到一个大大的“Malicious”字样,结果没有。输出反而很像“数据一致性审计报告”,需要读懂哪些差异是内核自身状态带来的,哪些才值得警惕。这也是我觉得它真正有用的地方:它把“不可见”变成了“可考核的差异”。
4. 一次真实排演:把“消失的进程”重新用 caveman 找回来
下面记录一次在隔离环境中的完整排演,样本是我自己写的一个简单测试内核模块,仅供技术验证。
4.1 构造一个练习样本
我在测试模块中复刻了经典的链表摘除逻辑:找到目标进程的task_struct,修改它的tasks链表指针,让它从全局进程链表中消失。加载模块后,立刻在目标机上执行ps aux,确认目标进程确实看不到了,再把内存 dump 出来。
这里必须要提醒:任何这类验证都只能在隔离虚拟机中进行,不要在真实服务器上尝试,避免造成环境破坏。测试样本本身不值得复用,关键是掌握排查思路。
镜像文件我按时间命名,比如test-20240613-2200.lime,方便事后追踪。接下来把它拷到分析机上,开始验证。
4.2 常规插件视角:一片“干净”
先跑 Volatility 3 的常规进程枚举:
python3 vol.py -f test-20240613-2200.lime linux.pslist输出里从 PID 1 开始,一直到最后的 kworker 线程,没有那个被隐藏的进程。再跑linux.pstree也是一样,父子关系树里根本不会出现这个节点。
到这里,如果你的经验不足,可能会认为这台机器是干净的。所以分析取证讲究“凡事多验证一次”,不只是看一张表。
4.3 caveman 视角:冲突列表终于出声
调用 caveman 运行交叉比对后,输出里出现了我之前预埋的差异:
| 枚举视图 | 是否发现目标进程 | 备注 |
|---|---|---|
| 进程链表 (tasks) | 未发现 | 攻击者/测试模块摘除了节点 |
| PID 哈希表 (pid_hash) | 发现 | fork 时插入的映射关系还在 |
| 调度器运行队列 | 部分发现 | 如果进程处于可运行状态,调度结构会引用它 |
| RCU 通知链 | 视时间窗口而定 | 可能存在延迟清理 |
这个结果说明:即便进程从主链表上消失,内核仍然在多个辅助结构里保存了对它的引用。caveman 做的事情,就是把“链表没它”和“哈希表有它”这两件事放在同一张报告里,形成一条清晰的差异证据链。
4.4 顺着差异项确认进程身份
拿到 PID 哈希表里映射的信息后,下一步是从内存镜像中定位这个 task_struct 的地址,用它还原进程的更多细节:
# 假设 caveman 报告中给出了可疑 task_struct 地址 0xffff888012345678 # 我们可以用 Volatility 的通用对象/扫描模式去观察它的字段实操时更常见的是先看它的进程路径、启动参数、父进程关系,再结合网络连接缓存判断它跟哪些外联地址有关。我这次测试中,目标进程是一个 sleep 循环,但它只要持有 socket,内核的 socket 结构里就会有对应的引用,这也是另一个可交叉验证的视角。
整个排演给我最深的感受是:隐藏在内存取证里不是“全无”或“全有”的状态,而是只是一个视图问题。攻击者可以让你常用的视图失效,但很难让所有视图同时失效。caveman 这类工具的价值,就是把这种多视图差异摆到桌面上,而不是让分析师去猜。
5. 最让我头疼的三个坑:符号表、镜像质量与分析开销
工具好用归好用,实操中难免踩坑。下面几个是我遇到的真实问题,写出来给你避雷。
5.1 符号表不匹配:最磨人的第一个坑
Volatility 3 在 Linux 上分析,极度依赖能获取到正确的内核符号表和类型信息。如果目标内核是某个定制版本,或者你在分析机上拿到的符号表和目标机不匹配,跑插件时会直接报一堆“Symbol not found”。
我的建议是,在采集内存之前,先把目标机的内核版本记录下来:
uname -r然后在目标机上同步安装对应版本的 debug 符号包,把/usr/lib/debug/boot/vmlinux-$(uname -r)拷贝出来,放到分析机上备用。我遇到过最省心的方案,是把调试符号包的源码拷到分析机后,重新生成符号表文件,Volatility 3 加载后就能正确识别。
5.2 镜像质量:不是拿到 dump 就万事大吉
采集内存时的机器状态会影响镜像质量。如果一个进程正在频繁分配内存,采集期间它的某些页面可能处于不稳定状态;如果 LiME 用了压缩格式,分析时会有额外解压开销。更隐蔽的是,不同采集工具对保留页、交换映射的处理方式不同,可能导致 caveman 的输出里出现“疑似缺失”的假象。
我操作中比较稳的流程是:采集时尽量让目标机处于相对低负载状态,并且记录采集开始和结束时间。如果条件允许,把虚拟机的快照也保留一份,必要时可以直接从快照里重新导出内存,避免同一份镜像被反复采集造成信息丢失。
5.3 交叉比对结果中的误报与噪音
交叉验证理论上很美,实际中也确实会带来噪音。
比如某些正常的短生命周期线程,可能正好处于进程链表摘除、但 PID 哈希表还没清理的状态,这个时间窗口极短,但如果你运气好(或不好),镜像就记录下了这一刻。这种情况下 caveman 会标记一个差异项,但它并不是 rootkit,只是一个正在正常退出的线程。
处理这类问题,我的经验是:
- 不只看一次的交叉结果,而是结合镜像中的 RCU 回调、清理机制状态去判断;
- 如果条件允许,在不同时间点多抓几份镜像,看同一个可疑对象是否持续出现;
- 结合网络连接、打开文件、代码页等更“行为化”的证据做综合判断。
5.4 分析开销:内存在变多,分析机也要跟上
caveman 这类工具涉及到大量结构遍历和对象扫描,内存镜像本身越大,耗时越长。分析一个 4GB 的镜像,如果开启详细输出,可能需要十几分钟;64GB 服务器镜像更是需要耐心。我的建议是先跑基础插件快速定位可疑点,再用 caveman 对可疑区域做深度交叉验证,不要一上来就全量扫描。
另外,分析机的内存最好高于镜像本身大小,因为在解析过程中 Volatility 会缓存不少结构对象,内存不足时会导致 swap,分析速度骤降。
一点个人体会
我越来越觉得 caveman 这类工具最值钱的不是它能精确报出“这是某个已知恶意家族”,而是它强迫你用原始人的视角重新审视一遍内核的可见性——你不是在看一张名单,而是在看一组结构之间的关系。攻击者可以伪造一张完美的名单,却很难在短时间内让所有结构之间的关系都自洽。
内存取证本质上是时间游戏:先固化镜像,再交叉验证,最后重构链。caveman 在这个链条里是承上启下的一环,它不负责给你最终结论,但它能帮你找到那些“被藏起来的证据在哪里”。如果你也在做主机侧的分析工作,建议在实验环境里把它完整跑一遍,感受一下从“单视图扫描”切换到“多视图交叉”时的那种视角变化——那才是这个工具真正值得学习的地方。