基于eBPF的Linux rootkit检测器,使用多通道交叉视图分析(sched_switch、NMI、/proc)来检测DKOM、tracepoint篡改和进程隐藏,并具备硬件级完整性验证。
系统进程完整性 & 跨视图分析
"我要歌唱,所以闪耀吧,SPiCa……"
SPiCa 是一款基于 eBPF 的 Linux 内核级恶意软件检测器,用 Rust 编写。其名称来源于初音未来的歌曲《SPiCa》及其所指的恒星——角宿一(室女座α星),即室女座中最亮的那颗星。肉眼看似一颗星的它,实际上是一对分光双星:两颗恒星相互绕转,如果不测量它们的光谱,就无法区分它们是独立的天体。SPiCa 将相同的原理应用于内核观测:通过物理上不同的机制,多个独立通道测量相同的内核状态,抑制其中一条通道的内核级恶意软件会在其他通道中暴露无遗。
免责声明: 本代码库中有相当一部分代码是借助 GLM 生成或重构的。我们进行了严格的测试和迭代设计,但在投入生产前,请自行审查代码的安全性和性能。
SPiCa 的设计目标是击败 eBPF 受限攻击者——即拥有提升权限(CAP_BPF 或 CAP_SYS_ADMIN)并将特权 eBPF 程序加载到内核中的攻击者。此类攻击者本质上弱于 LKM 内核级恶意软件,因为 BPF 验证器施加了严格的限制:
| 限制 | 对攻击者的影响 |
|---|---|
| 有界循环(每个程序约 100 万条指令) | 无法扫描全部内核内存 |
| 指针安全性(直接解引用需要可证明有效且有界的对象) | 无法跟随任意指针 |
| 受限辅助函数(按程序类型白名单) | 无法调用 kmalloc、vmalloc 或任何内核分配函数 |
无法任意写内核(bpf_probe_write_kernel 受到严格限制) | 无法修改 task_struct、tracepoint funcs 数组或 BPF 程序字节码 |
| 映射访问限定于已加载的引用 | 无法访问属于其他程序的映射(除非通过 ID 枚举——见 §7) |
当启动后通过 LSM 门控阻止了 LKM 时,这种受限攻击者便是实际剩余的威胁。SPiCa 的抗规避机制是针对这一威胁校准的——每项防御都如实说明其所覆盖和未覆盖的范围。
init_module 即可造成任意内核写入的内存破坏。SPiCa 通过阻断较易实现的 LKM 途径来提升防御底线,但并未封顶攻击能力。SPiCa 是纵深防御栈中的最后一道防线,而非其上层防御的替代品。
SPiCa 运行四个挂载到内核钩子的 eBPF 程序,外加一个用户态检测引擎,该引擎将它们的输出与系统对自身的视图(/proc)进行交叉关联。
| 通道 | 机制 | 压制方式 | 压制成本 |
|---|---|---|---|
/proc | procfs 目录枚举 | DKOM(从任务列表中取消进程链接) | 低——内核级恶意软件的成熟技术 |
sched_switch | 调度器上的 eBPF tracepoint | 从 funcs 数组中分离程序;过滤上下文 | 中等——需要对 funcs 数组进行内核写入 |
| NMI | 硬件性能计数器中断 → eBPF 性能事件 | 挂接 IDT/NMI 处理程序 | 几乎必然导致内核恐慌——硬件中断,不可屏蔽 |
核心架构特性:内核级恶意软件无法在不使压制行为本身变得可检测或破坏系统稳定的情况下,同时压制所有三条通道。 压制 NMI 需要修改 IDT(中断描述符表),这在大多数内核上会导致系统崩溃。这就是"活炸弹"——攻击者通往完全盲区的唯一路径,很可能导致系统崩溃。
sched sees it, /proc doesn't → DKOM NMI finds sched's heartbeat stopped → TAMPER sched goes globally silent, /proc != empty → SILENT
每个检测类别都是一个差分判决:两个或多个通道之间的差异。检测引擎是一个纯函数,基于注册表 + /proc 快照 + 通道时间戳 —— 无 I/O、无副作用、完全可进行单元测试。
### NMI 重新设计:从观察到完整性
在原始设计中,NMI 是第二个进程观察通道,它对 CPU 进行采样并报告当前运行的任务。这是冗余的:sched\_switch 已经观察调度,而 NMI 通过不同的机制采样相同的数据。这种冗余消耗了每个 CPU 每秒约 1000+ 个环形缓冲区事件,而这些事件 99.999% 的情况下只是确认“是的,调度程序正在执行其工作。”
在重新设计的架构中,**NMI 被从进程观察重新定位为跟踪点完整性验证**。它不再报告哪个进程在 CPU 上运行,而是通过读取一个共享的 `.bss` 心跳来验证 `sched_switch` 是否真正在运行。这带来了:
1. 消除了约 99% 的 NMI 环形缓冲区流量(接近零稳态事件)
2. 直接检测跟踪点分离、抑制以及 BTF 附加失败(最初的 BTF 错误——参见 [§11](#11-the-btf-bug-incident))
3. 从硬件中断运行,位于跟踪点分发路径之外——免疫于 `bpf_override_return`、kprobe 拦截和函数数组操作
4. 通过直接内存访问读取 `.bss` 全局变量,而非 BPF 辅助函数——免疫于对辅助函数的 `fmod_ret`
---
## 3. sched\_switch 观察通道
一个挂载在 `sched_switch` 跟踪点上的 eBPF 程序在每次内核将进程调度到 CPU 上时触发。它使用传统的(非 BTF)固定偏移读取,直接从跟踪点参数中读取传入任务的 PID 和 comm:```
ctx.read_at::<u32>(56) → next_pid
ctx.read_at::<[u8;16]>(40) → next_comm
故意不使用 BTF/CO-RE。 追踪点的参数布局在不同内核版本间是稳定的(它是追踪点 ABI 的一部分)。使用硬编码偏移量避免了 BTF 解析结构导航的内核版本脆弱性。这是一个有意的设计选择,记录在 §11 中。
每次调用时,程序执行以下操作:
next_pid 和 next_commBASE_KEY 对 ProcessInfo 结构体进行 XOR 混淆sc_sched 环形缓冲区bpf_ktime_get_ns() 写入 .bss 全局变量 SCHED_HEARTBEAT — NMI 完整性检查器监视的心跳程序在此上下文中故意避免使用 bpf_get_current_pid_tgid()。在 sched_switch 时刻,“current”是传出任务,而不是传入的任务。追踪点参数提供了正确的(传入)进程标识。
检测引擎使用单一的单调时间基准:自 SPiCa 进程启动以来的纳秒数(从 run_detection() 入口开始的 Instant::now())。当调度事件到达时,事件处理程序存储调用者提供的 now(进程本地纳秒),而不是事件中嵌入的 eBPF bpf_ktime_get_ns() 值。这避免了将内核启动时间与进程本地时间混合时可能出现的时间基准不匹配——早期版本中存在此错误,导致所有活性谓词静默失败。
NMI 程序附加到硬件性能计数器 (HW_CPU_CYCLES) 上,该计数器在硬件中断级别触发。NMI 无法通过 cli 禁用——抑制它们需要重新编程 PMU 或挂钩 IDT,这两种操作都很明显且具有破坏性。
完整性检查机制非常简单:``` .bss (shared within SPiCa's ELF object — all programs see the same memory): SCHED_HEARTBEAT ← written by sched_switch on every invocation NMI_LAST_HB ← NMI's record of the last heartbeat it observed NMI_FIRST_TICK ← first NMI invocation timestamp (grace period reference) NMI_LAST_EMIT ← throttle: last event emission timestamp
sched_switch runs → SCHED_HEARTBEAT = bpf_ktime_get_ns()
NMI fires (once per second, throttled): if SCHED_HEARTBEAT == NMI_LAST_HB: if past grace period (5 seconds since first NMI tick): → sched_switch stopped running → emit event_type=1 (TAMPER) else: → sched_switch is alive → emit event_type=0 (heartbeat OK) NMI_LAST_HB = SCHED_HEARTBEAT
### 为什么这能检测到范围内的每一种攻击
| 攻击 | 发生了什么 | NMI 观察到的情况 |
|---|---|---|
| 从 funcs 数组中分离出的 Tracepoint | sched_switch 停止执行 | `SCHED_HEARTBEAT` 冻结 → 侦测到篡改 |
| BTF 错误 / 附加失败(静默) | sched_switch 从未执行 | `SCHED_HEARTBEAT` 保持为 0 → 侦测到篡改(宽限后) |
| 对调度函数执行 `bpf_override_return` | 如果它阻止了 tracepoint 触发 | `SCHED_HEARTBEAT` 冻结 → 侦测到篡改 |
| 字节码原地修补 | 需要任意内核写入(LKM 级别) | 不在 eBPF 威胁模型范围内 |
| 环形缓冲区消费者指针被操纵 | sched 事件未到达用户空间 | `SCHED_HEARTBEAT` 仍在推进(程序在运行)→ 无虚假篡改;用户空间通过 `max(sched_last)` 过期检测到 → 静默 |
### 为什么特别选择 `.bss`
`.bss` 全局变量存储在 BPF 程序的内部数据段中,由加载程序管理的内部数组映射支持。它们:
- **不可单独固定** — 不会作为命名映射出现在 `/sys/fs/bpf/` 中
- **无法通过 `bpf_map_update_elem` 钩子截获** — `.bss` 写入是直接内存存储,而非映射更新系统调用。旧的 `sc_canary` 机制(通过比较 `.bss` 副本与命名映射副本来检测 `bpf_map_update_elem` 截获)不再需要。
- **在同一 ELF 对象的不同程序间共享** — sched_switch 和 NMI 通过 `.bss` 通信,无需任何外部接口
### 对 BPF 级别截获的免疫
NMI 完整性检查器在结构上对 BPF 覆盖攻击免疫,原因在于一个基本属性:`bpf_override_return` 截获的是函数**调用**,但 NMI 检查器并不*调用*它所验证的事物——它直接*读取 `.bss` 内存*。你无法覆盖内存读取的返回值,因为内存读取不是函数调用。
此外:
- `bpf_probe_read_kernel`(用于替代方案中的内核结构读取)是一个故障安全的辅助函数,可接受任何地址——但 SPiCa 的 `.bss` 心跳设计甚至不需要它。检查器通过直接加载指令读取 `.bss` 全局变量。
- NMI 程序在 NMI 上下文中运行,在该上下文中 kprobes 在结构上不可靠(内核会延迟或抑制它们)。针对检查器执行的基于 kprobe 的攻击将与硬件对抗。
### NMI 事件语义
NMI 环形缓冲区(`sc_nmi`)携带轻量级事件:
| 事件类型 | 含义 | 用户空间操作 |
|---|---|---|
| 0 | 心跳——NMI 存活,sched_switch 存活 | 更新 `last_nmi_heartbeat` 时间戳 |
| 1 | 侦测到篡改——NMI 存活,sched_switch 心跳冻结 | 立即打印 `[TAMPER]` |
事件最多每秒触发一次(由 `NMI_LAST_EMIT` 节流)。如果 NMI 环形缓冲区静默超过 5 秒,用户空间将触发 `[SILENT]`——NMI 通道本身已失效。
---
## 5. 检测逻辑```mermaid
graph TD
subgraph RING0["Kernel Space: Four eBPF Programs"]
direction TB
SCHED_P["TracePoint, sched_switch<br/>read next_pid, next_comm<br/>write SCHED_HEARTBEAT (.bss)<br/>XOR obfuscate → sc_sched"]
NMI_P["PerfEvent hardware NMI<br/>read SCHED_HEARTBEAT (.bss)<br/>compare to NMI_LAST_HB<br/>frozen → event_type=1 (TAMPER)<br/>alive → event_type=0 (heartbeat)<br/>→ sc_nmi"]
LSM_P["BPF LSM, kernel_read_file<br/>READING_MODULE<br/>gate=0: allow + log<br/>gate=1: EPERM + log → sc_lsm"]
WATCH_P["TracePoint, sched_process_exit<br/>current == SPICA_PID (.bss)? → sc_wd flag"]
end