系统进程完整性 & 跨视图分析
"我要歌唱,所以闪耀吧,SPiCa……"
SPiCa 是一款基于 eBPF 的 Linux 内核级恶意软件检测器,用 Rust 编写。其名称来源于初音未来的歌曲《SPiCa》及其所指的恒星——角宿一(室女座α星),即室女座中最亮的那颗星。肉眼看似一颗星的它,实际上是一对分光双星:两颗恒星相互绕转,如果不测量它们的光谱,就无法区分它们是独立的天体。SPiCa 将相同的原理应用于内核观测:通过物理上不同的机制,多个独立通道测量相同的内核状态,抑制其中一条通道的内核级恶意软件会在其他通道中暴露无遗。
免责声明: 本代码库中有相当一部分代码是借助 GLM 生成或重构的。我们进行了严格的测试和迭代设计,但在投入生产前,请自行审查代码的安全性和性能。
SPiCa 的设计目标是击败 eBPF 受限攻击者——即拥有提升权限(CAP_BPF 或 CAP_SYS_ADMIN)并将特权 eBPF 程序加载到内核中的攻击者。此类攻击者本质上弱于 LKM 内核级恶意软件,因为 BPF 验证器施加了严格的限制:
当启动后通过 LSM 门控阻止了 LKM 时,这种受限攻击者便是实际剩余的威胁。SPiCa 的抗规避机制是针对这一威胁校准的——每项防御都如实说明其所覆盖和未覆盖的范围。
init_module 即可造成任意内核写入的内存破坏。SPiCa 通过阻断较易实现的 LKM 途径来提升防御底线,但并未封顶攻击能力。SPiCa 是纵深防御栈中的最后一道防线,而非其上层防御的替代品。
SPiCa 运行四个挂载到内核钩子的 eBPF 程序,外加一个用户态检测引擎,该引擎将它们的输出与系统对自身的视图(/proc)进行交叉关联。
核心架构特性:内核级恶意软件无法在不使压制行为本身变得可检测或破坏系统稳定的情况下,同时压制所有三条通道。 压制 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
subgraph RING3["User Space: Differential Engine"]
ENGINE["SPiCa (Tokio async)"] -->|XOR deobfuscate| RB_S[(sc_sched RingBuf)]
ENGINE -->|event_type=0: heartbeat<br/>event_type=1: TAMPER| RB_N[(sc_nmi RingBuf)]
ENGINE -->|raw read| RB_L[(sc_lsm RingBuf)]
ENGINE -->|read_dir| PROC[" /proc"]
RB_S --> FSM{Detection FSM}
PROC --> FSM
FSM -->|scheduled, absent /proc, >2s| D1["[DKOM]"]
FSM -->|in /proc, never seen by sched, >5s| D2["[GHOST]"]
RB_N -->|event_type = 1| D3["[TAMPER]"]
FSM -->|sched channel silent, /proc ≠∅| D4["[SILENT]"]
FSM -->|raw getdents64 ≠ libc readdir| D9["[HOOK]"]
RB_S -->|same TGID, different start_time_ns| D5["[DUPE]"]
RB_L -->|allowed = 1| D7["[LKM-ALLOW]"]
RB_L -->|allowed = 0| D8["[LKM-DENY]"]
WATCH_P -.->|pin exists at startup| D6["[WATCHDOG]"]
end
classDef kernbox fill:#fdf2f8,stroke:#9333ea,stroke-width:2px,color:#581c87;
classDef userbox fill:#eff6ff,stroke:#2563eb,stroke-width:2px,color:#1e3a8a;
classDef alertbox fill:#fff7ed,stroke:#ea580c,stroke-width:1px,color:#7c2d12;
classDef storage fill:#f8fafc,stroke:#64748b,stroke-width:1px;
class RING0 kernbox;
class RING3 userbox;
class D1,D2,D3,D4,D5,D6,D7,D8,D9 alertbox;
class RB_S,RB_N,RB_L,PROC storage;
SILENT 是一个 通道级 判定,而非按进程。它在以下情况下触发:
sched通道死亡: 所有注册记录中的 max(sched_last) 在 /proc 非空时过期超过 CHANNEL_DEAD_NANOS。非空 /proc 守卫是活跃保证:在任何运行的Linux系统上,/proc 非空(init、内核线程、kworkers),且调度器从不真正空闲(定时器滴答、I/O等待器、RCU回调)。在非空系统上过期的 max(sched_last) 意味着通道死亡——而非系统空闲。
NMI通道死亡: 超过5秒未从NMI环形缓冲区收到心跳事件 (event_type=0)。这意味着NMI性能事件停止触发——要么性能事件被分离,要么PMU被重新编程。
两个检查都有30秒的冷却时间,以防止告警泛滥。
[HOOK])SPiCa 在每个 tick 通过 两种独立机制 读取 /proc:
原始 getdents64 系统调用 (read_tgids()) — Rust 的 std::fs::read_dir 通过 libc::syscall(SYS_getdents64, ...) 直接调用此调用,绕过所有libc函数钩子。LD_PRELOAD rootkit 无法拦截此路径。
libc readdir (read_tgids_libc()) — 通过 FFI 调用 libc::opendir / libc::readdir,该调用经过动态链接器,可被 /etc/ld.so.preload 钩子完全拦截。
如果 getdents64 看到libc readdir 看不到的PID,则存在用户空间 rootkit 正在钩子 readdir 以隐藏进程。SPiCa 触发 [HOOK]:```
[HOOK] 3 PID(s) hidden from libc readdir — LD_PRELOAD rootkit suspected
此技术将SPiCa的内核级权限转化为对用户空间rootkit的主动检测。原始系统调用路径是事实真相——没有任何用户空间钩子能掩盖它。libc路径是普通工具(如ps、ls)所看到的“感知”视图。两者之间的差异是库拦截的明确证据。
**Rootkits detected by `[HOOK]`:**
| Rootkit | 隐藏机制 | 检测到 |
|---------|-----------------|----------|
| Symbiote | 寄生式LD_PRELOAD,钩子`readdir` | 是(主动隐藏时) |
| JynxKit | LD_PRELOAD,通过`readdir`隐藏`MAGIC_GID` | 是 |
| Azazel | LD_PRELOAD,`readdir` + `stat`钩子 | 是 |
| Medusa/OrBit | LD_PRELOAD,`readdir` + 凭证窃取 | 是 |
**针对真实Symbiote的测试:**一个活体Symbiote样本(SHA256 `f55af21f...`,来自MalwareBazaar)通过`/etc/ld.so.preload`部署在Ubuntu 24.04虚拟机上。该样本钩住了`readdir`、`readdir64`、`stat`、`fstatat`、`pam_authenticate`、`pcap_loop`、`recvmsg`、`fopen`、`read`、`execve`。当LD_PRELOAD钩子主动从`readdir`中隐藏一个PID时,SPiCa在一个tick周期内(<1秒)触发`[HOOK]`。当Symbiote被加载但处于被动状态(未主动隐藏)时,SPiCa产生零误报。
### 宽限期窗口
一个50毫秒的宽限期窗口可防止对新fork进程的误报。通道级SILENT检查具有5秒宽限期(NMI程序的`NMI_FIRST_TICK`参考值),以便启动延迟不会产生虚假的TAMPER警报。
---
## 6. 验证器受限的地址保密性
### 定义
**验证器受限的地址保密性**是一种在eBPF约束的对手模型下成立的计算安全属性。它指出:
> 给定一个能够加载eBPF程序(CAP\_BPF)但受BPF验证器约束(有界循环、受限的辅助函数、不能任意写入内核)的对手,BPF程序`.bss`数据段的内核地址在计算上是不可发现的。
### 四种机制
该属性由四个独立的屏障组成:
1. **kASLR** — 内核地址空间布局随机化随机化了内核堆区域,该区域分配了BPF映射内存(包括`.bss`后端映射)。确切地址在每次启动时都会变化。
2. **验证器指令上限** — BPF验证器将每个程序的总指令数限制约为100万,并要求循环可证明会终止。对手的扫描程序每次加载最多可以通过`bpf_probe_read_kernel`探测约10^6个地址。鉴于kASLR随机化的搜索空间,每个程序的成功概率可以忽略不计。
3. **LSM映射访问门**(参见[§7](#7-lsm-map-access-gate))— 没有此门,对手可以通过ID枚举BPF映射(`BPF_MAP_GET_NEXT_ID` → `BPF_MAP_GET_FD_BY_ID` → `BPF_MAP_LOOKUP_ELEM`)并直接读取`.bss`内容。此门移除了这条简单路径,迫使对手进入验证器受限的内存扫描路径。
4. **`.bss`放置** — `.bss`全局变量不会出现在具有人类可读名称的命名映射枚举接口中。内部的`.bss`后端映射被libbpf/aya命名为`.bss`,但其内容(实际的全局变量)仅在你拥有映射文件描述符(fd)时才能访问。映射fd需要加载进程的fd表或映射ID(已被门阻止)。
### 形式化论证结构```
Adversary's goal: locate SPiCa's .bss in kernel virtual memory
Search space: kernel heap region, kASLR-randomized (~2^30 entropy)
Path A — map enumeration (blocked):
BPF_MAP_GET_NEXT_ID → BPF_MAP_GET_FD_BY_ID → BPF_MAP_LOOKUP_ELEM
→ blocked by LSM gate (§7) for non-SPiCa processes
Path B — memory scan (bounded):
bpf_probe_read_kernel(addr, buf, len) — fault-safe, accepts any addr
Per-program capacity: N ≈ 10^6 probes (instruction limit)
Success probability per load: N / |search space| ≈ 10^6 / 2^30 ≈ 10^-24
Can reload programs, but: each load is a syscall (observable), and
the target address doesn't change between loads (same boot session)
Path C — timing side channel (theoretical):
bpf_probe_read_kernel timing varies on fault vs success
Binary search over the heap could reduce scan complexity to O(log |A|)
→ O(30) probes, well within instruction budget
LIMITATION: must be acknowledged. Mitigated by rate-limiting BPF_PROG_LOAD
via the LSM gate (future work).
大多数 BPF 安全研究将验证器视为一个 攻击面(验证器漏洞、非健全性、JIT spray)。本工作颠覆了这一框架:验证器的约束是一种 防御原语,限制了对手的搜索空间。这种保密性并非密码学意义上的(不是 AES);它是 计算上的 —— 受限于验证器允许对手计算的范围。
bpf_probe_read_kernel 是时序可观测的(出错与成功所需周期不同)。基于时序的堆上二分搜索理论上比线性扫描更高效。指令界限限制了每个程序的探测次数,但二分搜索可将探测次数减少至 O(log |A|) ≈ 30。这是一个实际限制。缓解措施:通过 LSM 门限控 BPF_PROG_LOAD 的速率。.bss 全局变量由内部 BPF map 支持。任何拥有 CAP_BPF 的进程都可以枚举系统上的所有 map:```
bpf(BPF_MAP_GET_NEXT_ID, …) → iterate all map IDs
bpf(BPF_MAP_GET_FD_BY_ID, id) → get fd to any map
bpf(BPF_MAP_LOOKUP_ELEM, fd, key, value) → read map contents
Against the eBPF adversary (who can't call syscalls), `.bss` is hidden. Against userspace root, it's a standard map — fully readable. The LSM gate closes this gap.
### The defense
A BPF LSM hook on the `bpf` syscall checks map-access commands:```
hook = "bpf"
read cmd (arg 0)
if cmd == BPF_MAP_GET_FD_BY_ID:
read map_id from userspace bpf_attr (bpf_probe_read_user)
if map_id matches any of SPiCa's stored IDs (.bss):
if caller_tgid != SPICA_PID:
return -EPERM
Map IDs 由用户空间加载器在程序加载后立即写入 .bss,在 LSM 钩子启动之前(与现有用于 LKM 阻断的 sc_gate 时序模式相同)。无需 BTF——cmd 和 map_id 来自系统调用参数,而非内核结构体。
精准阻断: 仅阻断对 SPiCa 特定 map ID 的访问。其他进程的 BPF 工具(tcpdump、bpftrace、bcc)访问各自 map,不受影响。
/proc/pid/mem — 另一个 root 进程可通过 ptrace 直接读取 SPiCa 的进程内存,完全绕过 BPF 系统调用。这是根本性局限:无法防范同一特权级别的进程通过 /proc/pid/mem 读取你的内存。防御措施:将日志发送到外部主机(若攻击者可读取本地内存,也能抑制本地警报——只有远程日志有效)。在配备 TPM 的主机上,64 位 XOR 混淆密钥来自 TPM2_GetRandom,通过 tss-esapi crate(直接调用 libtss2-esys 库函数)。密钥不经过任何管道、子进程命令行或磁盘。在 SPiCa 进程生命周期内,密钥仅存在于 TPM 硬件和内核易失性内存(.bss 后备 map)中。
在不包含 TPM 的主机(无 vTPM 的虚拟机、容器、无 TPM 芯片的机器)上,SPiCa 回退到由构建脚本从 /dev/urandom 生成的 per-build 编译时密钥。回退机制在启动时会显眼地打印警告横幅。
.bss 放置以下值位于 eBPF 程序的 .bss 段中,由用户空间在加载任何程序前通过 set_global() 写入:
具名 BPF map 可通过 bpf() 系统调用的 map 遍历接口枚举(由 LSM 门阻断——见§7);.bss 全局变量通过同一内部 map 访问,但无法单独命名或固定。
所有 ProcessInfo 字段在写入环形缓冲区前,均与 64 位 BASE_KEY 进行 XOR 折叠。event_type 字段故意不混淆,以便用户空间可将其作为哨兵读取,然后再去混淆其余部分。
这是针对读取泄露的混淆,而非针对强大对手的加密。 8 字节重复密钥的 XOR 在结构上易受已知明文攻击:可预测的 comm 值("bash"、"systemd"、"kthreadd")与密文 XOR 可直接恢复密钥字节。此防御措施适用于 eBPF 对手(他们无法轻易读取环形缓冲区——这需要与保护 .bss 相同的能力差距),而不是针对拥有二进制访问权限 + 环形缓冲区拦截能力的对手。
状态: 设计阶段。尚未实现。本节记录了研究论文的目标架构。
PCR 绑定 TPM 密封将密钥分发边界从软件层转移到硅片层。密钥在安装时针对预期的平台配置寄存器(PCR)值密封,仅当系统启动状态与预期测量值匹配时才能解封。
启动过程中,固件、引导加载程序和内核将关键组件测量到 PCR 中:
注意: PCR 分配依赖于启动链。GRUB 将内核测量到 PCR 4;systemd-stub 测量到 PCR 4,命令行测量到 PCR 8。密封策略必须与目标启动链匹配。
密封(安装时): 使用 TPM2_Create 及 TPM2_PolicyPCR 会话,将 64 位密钥针对预期 PCR 值进行密封。密封后的 blob 存储在磁盘上。它使用 TPM 内部密钥加密,仅当指定的 PCR 匹配时才能解密。
解封(早期启动,initramfs 阶段): 在任何不可信用户空间代码执行前,SPiCa 的 initramfs 钩子请求 TPM2_Unseal。若当前 PCR 值与密封策略匹配,TPM 释放密钥。
注入: 密钥在加载程序前通过 set_global() 写入 .bss。
陷阱: 若攻击者修改了内核(PCR 4 不匹配)、替换了 initramfs(PCR 9 不匹配)或更改了安全启动策略(PCR 7 不匹配),PCR 哈希值将发生偏离。TPM 拒绝解封,SPiCa 故障安全——它拒绝启动,而不是在密钥被攻陷的情况下盲目运行。
在研究论文中,呈现强策略,并在局限性中讨论重新密封的权衡。
这两种机制是互补的:
.bss。两者单独使用都不够。PCR 密封无法在运行时密钥被攻陷(map 枚举)时起作用。地址保密性无法在系统于 SPiCa 启动前已被攻陷(恶意 initramfs)时起作用。
spica_lsm_modblock、map 访问门)在运行时加载,并未测量到任何 PCR。在启动后分离它们的 rootkit 不会被 PCR 密封捕捉。捕捉它的是 NMI 心跳(检测检测系统自身已停止运行)。SPiCa 是最后一道强制层。它补全了正确配置的系统,而非取代上方的各层。```mermaid
flowchart TD
SB["UEFI Secure Boot
─────────────────────────────
Verifies bootloader signature against
the UEFI db certificate store
Measured to PCR 7"]
MS["Kernel Module Signing
─────────────────────────────
CONFIG_MODULE_SIG_FORCE=y
Kernel rejects unsigned .ko at load time"]
IMA["IMA, Integrity Measurement Architecture
─────────────────────────────
Measures file hashes to TPM PCR10
Appraise policy blocks non-matching signatures"]
SPICA["SPiCa
─────────────────────────────
LSM gate: blocks LKM loads + map enumeration
sched_switch + NMI integrity channels
Differential detection: DKOM · GHOST · TAMPER · SILENT · DUPE
PCR-bound key sealing (design phase)"]
SB -->|"boot chain verified"| MS
MS -->|"signed modules only"| IMA
IMA -->|"measured + appraised"| SPICA
classDef firmware fill:#fef3c7,stroke:#d97706,stroke-width:2px,color:#78350f
classDef kernel fill:#fdf2f8,stroke:#9333ea,stroke-width:2px,color:#581c87
classDef spica fill:#eff6ff,stroke:#2563eb,stroke-width:2px,color:#1e3a8a
class SB firmware
class MS,IMA kernel
class SPICA spica
SPiCa 在启动时检查所有四层并打印其状态。
---
## 11. BTF 错误事件
### 发生了什么
在 Ubuntu(最新内核)上进行测试时,BTF 不兼容导致 `sched_switch` 跟踪点程序成功附加但**从未触发任何事件。** `attach()` 系统调用返回 `Ok(())`,所以 SPiCa 正常进行——但调度环形缓冲区保持为空。没有触发检测警报,因为检测引擎没有调度数据。**SPiCa 盲目运行而没有指示失败。**
这是安全工具最糟糕的失败模式:静默盲区。
### 为什么未被检测到
原始检测引擎按**进程**推理:每个进程记录都有 `sched_last` 和 `nmi_last` 时间戳,并向每个记录计算活跃度。当 sched\_switch 全局死亡时:
1. `sched_live` 对所有记录变为 false(没有新的 sched 事件 → 所有 `sched_last` 值过期)
2. 每个进程的 `TAMPER` 谓词(`in_proc && nmi_live && !sched_live`)可能触发,但要求 `nmi_live` *连续*保持 2 秒。NMI 采样稀疏(10M 周期周期),因此 `suspect_since` 在采样抖动时不断重置,从未成熟。
3. 每个进程的 `SILENT` 谓词要求 `sched_live`,现在所有东西都是 false——该谓词在它应当检测的条件完全反转。
4. **没有通道级活跃度检查**——没有" sched 是否曾经触发?"或" max(sched_last) 是否过时?"机制。
另外,发现了一个时基不匹配:`sched_last` 存储了 `bpf_ktime_get_ns()`(内核启动纳秒),而 `evaluate()` 与 `nanos_since_startup()`(进程本地纳秒)进行比较。这些不同时基的 `wrapping_sub` 产生了巨大的值,使得所有活跃度谓词都微不足道地变为 false。检测逻辑在生产中从未正确工作——BTF 错误只是通过阻止事件到达而掩盖了它。
### 重新设计如何修复它
| 问题 | 修复 |
|---|---|
| 没有通道级活跃度检查 | `evaluate()` 现在计算 `max(sched_last)`,如果过时且 `/proc` 非空则触发 `[SILENT]` |
| 每个进程的 TAMPER 从未成熟(抖动重置) | TAMPER 现在是来自 NMI 程序的直接信号(`.bss` 心跳比较),而不是具有阈值的每个进程 FSM |
| 每个进程的 SILENT 在 sched 死亡时反转 | SILENT 现在是通道级的,从聚合中计算,而不是每个记录谓词 |
| 时基不匹配 | `sched_last` 现在存储进程本地时间(`now` 参数),而不是 `bpf_ktime_get_ns()` |
| NMI 是冗余观察 | NMI 现在是完整性检查器——它通过 `.bss` 心跳直接检测 sched 死亡 |
---
## 12. 已知限制与攻击面
### eBPF 威胁模型内
| 攻击 | 状态 | 备注 |
|---|---|---|
| **环形缓冲区消费者指针操纵** | **已检测** (SILENT) | `max(sched_last)` 变过时 → SILENT。`SCHED_HEARTBEAT` 仍推进(程序运行)→ 无虚假 TAMPER。 |
| **Perf 事件结构 DKOM**(零化 `state` 字段) | **已检测** (SILENT) | NMI 停止触发 → 无心跳事件 → NMI 通道上的 SILENT |
| **`task_struct` 字段欺骗**(覆盖 pid/tgid) | **部分** (DUPE) | 如果被欺骗的 TGID 与具有不同 `start_time_ns` 的现有记录匹配,则触发 DUPE。如果 `start_time_ns` 也被欺骗,则绕过。 |
| **eBPF 程序分离**(从 funcs 数组中移除) | **已检测** (TAMPER) | NMI 心跳冻结 → TAMPER |
| **字节码原地修补** | **未检测到** | 需要任意内核写入(LKM 级别)——超出 eBPF 威胁模型 |
| **对 `bpf_probe_read_kernel` 的定时侧信道** | **理论风险** | 对堆的二分搜索可在约 30 次探测中找到 `.bss`。请参见[§6](#6-verifier-bounded-address-secrecy)的限制。 |
### eBPF 威胁模型之外(已知,未解决)
| 攻击 | 状态 | 备注 |
|---|---|---|
| **IDT/NMI 处理程序钩子** | **活炸弹** | 架构上完全绕过。几乎确定的内核恐慌。 |
| **双通道抑制** | **活炸弹** | 同时抑制 sched + NMI。继承了 NMI 钩子的不稳定性。 |
| **验证器利用** | **非目标** | 逃逸到任意内核代码执行。单独威胁类别。 |
| **ptrace / `/proc/pid/mem`** | **基本限制** | 同级权限内存读取。只有异地日志传输有帮助。 |
| **消费者指针 + 心跳修补** | **LKM 级别** | 如果攻击者具有任意内核写入,他们既可以推进消费者指针,也可以写入虚假心跳。但任意内核写入 = LKM 级别 = 超出威胁模型。 |
---
## 13. 构建与运行
### 前提条件
- Linux 内核 >= 5.15,带有 `CONFIG_DEBUG_INFO_BTF=y`(仅用于 LSM 钩子)
- 用于模块阻止:内核命令行中的 `CONFIG_BPF_LSM=y` 和 `lsm=bpf`
- TPM 2.0 芯片 + `tpm2-tss` 库(可选;失败时带有可见警告回退)
- Rust 夜间工具链
> **注意:** `generate-vmlinux` 步骤**不再需要**。eBPF 程序使用传统的跟踪点偏移量和 `.bss` 全局变量——不需要 CO-RE/BTF 结构导航。xtask `generate-vmlinux` 命令保留供将来使用,但不是构建流程的一部分。
验证 BPF LSM 是否激活:`cat /sys/kernel/security/lsm` 应包含 `bpf`。
### 设置```shell
make install-deps # system packages + nightly Rust (pacman/apt/dnf auto-detected)
make install-tools # bpf-linker
make build # compiles eBPF + userspace (no vmlinux generation needed)
make run # sudo ./target/release/spica
### 安装到 initramfs(早期启动保护)```shell
sudo make install # spica install — auto-detects Debian (initramfs-tools) or Fedora (dracut)
sudo insmod some_module.ko # should fail with EPERM (gate locked) cat /sys/kernel/security/lsm # should contain 'bpf' ls /sys/fs/bpf/spica_watchdog # exists if previous run was killed ungracefully
### 开发(macOS 或 Linux)
eBPF 程序无法在 macOS 上运行。针对检测逻辑的 `cargo check` 和单元测试可以工作;完整的运行时验证需要 Linux。```shell
make check # cargo check for workspace
make check-ebpf # cargo check for the eBPF crate (bpfel-unknown-none)
make test # unit tests for detection FSM, key derivation, obfuscation
GetRandom 的做法。参见 §9。bpf 系统调用上附加 BPF LSM 钩子,阻止非 SPiCa 进程枚举 SPiCa 的内部状态映射。参见 §7。BPF_PROG_LOAD 速率限制 — 扩展 LSM 门,对非 SPiCa 进程的 BPF 程序加载进行速率限制,从而减轻对 .bss 发现的定时侧信道风险。参见 §6 的限制。siphasher 依赖项已存在于树中,用于完整性令牌)。SPiCa Engine 许可证: MIT 或 Apache-2.0(工作区)。eBPF 程序通过 _license 静态变量导出 GPL 许可证 — 这是内核对于使用 GPL 许可助手的 eBPF 程序的要求,仅适用于加载的 eBPF 字节码,不适用于用户空间二进制文件。
角色归因: “初音未来”及相关角色美术作品是 Crypton Future Media, INC.(www.piapro.net)的版权财产。本项目是一个独立的、非商业性研究工具,与 Crypton Future Media 无关。角色根据 Piapro 角色许可证 (PCL) 使用。
SPiCa 项目名称灵感来源于 Toku-P 的原版歌曲。
| 限制 | 对攻击者的影响 |
|---|
| 有界循环(每个程序约 100 万条指令) | 无法扫描全部内核内存 |
| 指针安全性(直接解引用需要可证明有效且有界的对象) | 无法跟随任意指针 |
| 受限辅助函数(按程序类型白名单) | 无法调用 kmalloc、vmalloc 或任何内核分配函数 |
无法任意写内核(bpf_probe_write_kernel 受到严格限制) | 无法修改 task_struct、tracepoint funcs 数组或 BPF 程序字节码 |
| 映射访问限定于已加载的引用 | 无法访问属于其他程序的映射(除非通过 ID 枚举——见 §7) |
| 通道 | 机制 | 压制方式 | 压制成本 |
|---|
/proc | procfs 目录枚举 | DKOM(从任务列表中取消进程链接) | 低——内核级恶意软件的成熟技术 |
sched_switch | 调度器上的 eBPF tracepoint | 从 funcs 数组中分离程序;过滤上下文 | 中等——需要对 funcs 数组进行内核写入 |
| NMI | 硬件性能计数器中断 → eBPF 性能事件 | 挂接 IDT/NMI 处理程序 | 几乎必然导致内核恐慌——硬件中断,不可屏蔽 |
| 告警 | 条件 | 检测内容 |
|---|
[DKOM] | 内核调度的TGID在 /proc 中缺失超过2秒 | 直接内核对象操纵 — 进程对用户空间枚举隐藏 |
[GHOST] | TGID在 /proc 中存在但 sched_switch 超过5秒未观察到 | /proc 欺骗 — 插入虚假条目以通过DKOM检查 |
[TAMPER] | NMI检测到 sched_switch 心跳冻结 (event_type=1) | 追踪点分离、被抑制,或BTF/附加失败 |
[SILENT] | max(sched_last) 在整个注册表中过期,而 /proc 非空;或NMI心跳缺失超过5秒 | 通道级观察丢失 — sched或NMI通道已死 |
[DUPE] | 相同TGID,事件中 start_time_ns 不同 | task_struct 字段欺骗 — rootkit修补 tgid 以冒充合法进程 |
[HOOK] | 通过原始 getdents64 可见PID,但libc readdir 缺失 | LD_PRELOAD rootkit — 用户空间库拦截,从 ps、ls 及其他工具中隐藏进程 (Symbiote, JynxKit, Azazel, Medusa/OrBit) |
[WATCHDOG] | /sys/fs/bpf/spica_watchdog 固定点存在于启动时 | 先前实例被非正常终止 (SIGKILL, OOM, crash) |
[LKM-ALLOW] | 在门打开 (启动窗口) 时拦截到 READING_MODULE | 审计记录:模块在门锁定前加载 |
[LKM-DENY] | 在门锁定时拦截到 READING_MODULE | insmod/modprobe 在初始化后被阻止 |
| 全局变量 | 用途 | 写入者 |
|---|
BASE_KEY | XOR 混淆密钥 | 加载时的用户空间 |
SPICA_PID | SPiCa 自身的 TGID(看门狗) | 加载时的用户空间 |
SCHED_HEARTBEAT | sched_switch 存活时间戳 | 每次调用时的 sched_switch 程序 |
NMI_LAST_HB | NMI 记录的最后一次 sched 心跳 | 每次检查时的 NMI 程序 |
NMI_FIRST_TICK | 首次 NMI 调用 ktime(宽限期) | 首次调用时的 NMI 程序 |
NMI_LAST_EMIT | 节流:最后一次事件发射 ktime | 每次发射时的 NMI 程序 |
| PCR | 测量内容 | 稳定性 |
|---|
| PCR 4 | 引导加载程序代码 + 内核镜像(GRUB 测量两者) | 内核更新时变化 |
| PCR 5 | GPT/MBR 分区表、启动配置 | 更新后稳定 |
| PCR 7 | 安全启动策略(SI 策略、MOK、db/dbx) | 内核更新后稳定 |
| PCR 8 | 内核命令行(systemd-stub 测量) | 除非命令行变化,否则稳定 |
| PCR 9 | Initramfs(GRUB 在此处测量 initrd) | initramfs 更新时变化 |
| PCR 10 | IMA 测量列表 | 随可执行文件测量而变化 |
| 策略 | 针对以下项密封 | 强度 | 运维成本 |
|---|
| 强 | PCR 4 + 7 + 9 + 10 | 捕捉内核、initramfs、安全启动和 IMA 变化 | 每次内核/initramfs 更新后重新密封 |
| 均衡 | PCR 7 + 10 | 捕捉安全启动和 IMA 评估变化;内核更新后稳定 | 仅安全启动策略或 IMA 策略变化时重新密封 |
| 最小 | PCR 7 仅 | 仅捕捉安全启动状态变化 | 非常稳定;绑定最弱 |
| 术语 | 定义 |
|---|
| BPF | 伯克利包过滤器 — 内核内执行引擎,用于沙箱化程序。现代 BPF(eBPF)扩展了数据包以外的功能,涵盖跟踪、安全和网络。 |
| BTF | BPF 类型格式 — 内核调试信息,使 CO-RE(一次编译,随处运行)程序能够可移植地导航内核结构体。 |
| CO-RE | 一次编译,随处运行 — 使用 BTF 编写可移植程序的 BPF 技术,使其适应不同的内核版本。 |
| DKOM | 直接内核对象操作 — 根kit技术,通过从内核链表中移除进程以在 /proc 中隐藏它。 |
| fmod_ret | BPF 程序类型,通过 BPF trampoline 修改内核函数的返回值。 |
| freplace | BPF 程序扩展 — 附加到另一个 BPF 程序的特定(子)函数,拦截其执行。 |
| funcs 数组 | 内核跟踪点结构体中的函数指针数组,包含在跟踪点触发时要调用的回调函数(包括 BPF 程序)。 |
| IDT | 中断描述符表 — CPU 结构,将中断向量映射到处理函数。挂钩 NMI 入口需要修补 IDT。 |
| kASLR | 内核地址空间布局随机化 — 每次启动时随机化内核代码/数据地址,以阻碍利用。 |
| NMI | 不可屏蔽中断 — 无法被软件(cli)禁用的硬件中断。由 perf 计数器用于硬件级观察。 |
| PCR | 平台配置寄存器 — TPM 寄存器,累积引导组件的度量(哈希)。无法重置(除重启外),只能扩展。 |
| PMU | 性能监控单元 — CPU 中的硬件计数器,统计事件(周期、缓存未命中等),并可在阈值处触发中断(NMI)。 |
| TPM | 可信平台模块 — 加密协处理器,提供基于硬件的密钥存储、随机数生成和度量认证。 |
| 验证器 | BPF 验证器 — 内核组件,在加载前静态分析 BPF 程序,以确保其终止且不访问不安全内存。 |