
eBPF-based Linux rootkit detector using multi-channel cross-view analysis (sched_switch, NMI, /proc) to detect DKOM, tracepoint tampering, and process hiding with hardware-level integrity verification.
System Process Integrity & Cross-view Analysis
"I'm going to sing, so shine bright, SPiCa..."
SPiCa is an eBPF-based Linux rootkit detector written in Rust. The name comes from the Hatsune Miku song SPiCa and the star it references — Spica (Alpha Virginis), the brightest point in Virgo. What looks like a single star to the naked eye is actually a spectroscopic binary: two stars in mutual orbit, indistinguishable as separate objects without measuring their spectra. SPiCa applies the same principle to kernel observation: multiple independent channels measure the same kernel state from physically distinct mechanisms, and a rootkit that suppresses one is exposed by the others.
Disclaimer: Significant portions of this codebase were generated or refactored with GLM assistance. Rigorous testing and iterative design were applied, but review the code for security and performance before production use.
SPiCa is designed to defeat the eBPF-constrained adversary — an attacker with elevated privileges (CAP_BPF or CAP_SYS_ADMIN) who loads a privileged eBPF program into the kernel. This adversary is fundamentally weaker than an LKM rootkit because the BPF verifier imposes hard constraints:
| Constraint | Effect on adversary |
|---|---|
| Bounded loops (~1M instructions per program) | Cannot scan all of kernel memory |
| Pointer safety (direct dereference requires provably valid, bounded target) | Cannot follow arbitrary pointers |
| Restricted helpers (per-program-type allowlist) | Cannot call kmalloc, vmalloc, or any kernel allocation function |
No arbitrary kernel write (bpf_probe_write_kernel heavily restricted) | Cannot modify task_struct, tracepoint funcs arrays, or BPF program bytecode |
| Map access scoped to loaded references | Cannot access maps belonging to other programs (unless enumerated by ID — see §7) |
With LKMs blocked by the LSM gate after boot, this constrained adversary is the realistic remaining threat. SPiCa's anti-evasion machinery is calibrated for this threat — every defense is honest about what it covers and what it doesn't.
init_module. SPiCa raises the floor by blocking the easier LKM vector but does not ceiling the adversary.SPiCa is the last-resort layer in a defense-in-depth stack, not a substitute for the layers above.
SPiCa runs four eBPF programs attached to kernel hooks, plus a userspace detection engine that cross-correlates their output against the system's view of itself (/proc).
| Channel | Mechanism | What suppresses it | Cost of suppression |
|---|---|---|---|
/proc | procfs directory enumeration | DKOM (unlink process from task list) | Low — well-understood rootkit technique |
sched_switch | eBPF tracepoint on the scheduler | Detach program from funcs array; filter context | Medium — requires kernel write to funcs array |
| NMI | Hardware perf counter interrupt → eBPF perf event | Hook the IDT/NMI handler | Near-certain kernel panic — hardware interrupt, not maskable |
The key architectural property: a rootkit cannot suppress all three channels simultaneously without the suppression itself becoming detectable or destabilizing. Suppressing NMI requires patching the IDT (Interrupt Descriptor Table), which panics on most kernels. This is the "live bomb" — the attacker's only path to total blindness is one that likely crashes the system.
sched sees it, /proc doesn't → DKOM
NMI finds sched's heartbeat stopped → TAMPER
sched goes globally silent, /proc != empty → SILENT
Each detection class is a differential verdict: a discrepancy between two or more channels. The detection engine is a pure function over the registry + /proc snapshot + channel timestamps — no I/O, no side effects, fully unit-testable.
In the original design, NMI was a second process-observation channel that sampled the CPU and reported which task was running. This was redundant: sched_switch already observes scheduling, and NMI sampled the same data via a different mechanism. The redundancy cost ~1000+ ring-buffer events/sec/CPU of process data that 99.999% of the time confirmed "yes, the scheduler is doing what the scheduler does."
In the redesigned architecture, NMI is repurposed from process observation to tracepoint integrity verification. It no longer reports which process is on the CPU. Instead, it verifies that sched_switch is actually running by reading a shared .bss heartbeat. This:
bpf_override_return, kprobe interception, and funcs-array manipulation.bss globals via direct memory access, not BPF helpers — immune to fmod_ret on helper functionsAn eBPF program attached to the sched_switch tracepoint fires every time the kernel schedules a process onto a CPU. It reads the incoming task's PID and comm directly from the tracepoint arguments using traditional (non-BTF) fixed-offset reads:
ctx.read_at::<u32>(56) → next_pid
ctx.read_at::<[u8;16]>(40) → next_comm
Deliberately not BTF/CO-RE. The tracepoint argument layout is stable across kernel versions (it's part of the tracepoint ABI). Using hardcoded offsets avoids the kernel-version fragility of BTF-resolved struct navigation. This is a deliberate design choice documented in §11.
On every invocation, the program:
next_pid and next_comm from the tracepoint contextProcessInfo struct with BASE_KEYsc_sched ring bufferbpf_ktime_get_ns() to the .bss global SCHED_HEARTBEAT — the heartbeat that the NMI integrity checker monitors