Browse by type
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 monitorsThe program deliberately avoids bpf_get_current_pid_tgid() in this context. At sched_switch time, "current" is the outgoing task, not the incoming one. The tracepoint arguments provide the correct (incoming) process identity.
The detection engine uses a single monotonic time base: nanoseconds since SPiCa's process startup (Instant::now() since run_detection() entry). When a sched event arrives, the event handler stores the caller-provided now (process-local nanos), not the eBPF bpf_ktime_get_ns() value embedded in the event. This avoids the time-base mismatch that would occur if kernel boot time were mixed with process-local time — a bug that was present in earlier versions and caused all liveness predicates to silently fail.
The NMI program is attached to a hardware perf counter (HW_CPU_CYCLES) that fires at hardware interrupt level. NMIs cannot be disabled with cli — suppressing them requires reprogramming the PMU or hooking the IDT, both of which are loud and destabilizing.
The integrity check mechanism is dead simple:
.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
| Attack | What happens | What NMI sees |
|---|---|---|
| Tracepoint detached from funcs array | sched_switch stops executing | SCHED_HEARTBEAT freezes → TAMPER |
| BTF bug / attach failure (silent) | sched_switch never executes | SCHED_HEARTBEAT stays 0 → TAMPER (after grace) |
bpf_override_return on a scheduler function |
If it prevents the tracepoint from firing | SCHED_HEARTBEAT freezes → TAMPER |
| Bytecode patched in-place | Requires arbitrary kernel write (LKM-level) | Outside the eBPF threat model |
| Ring buffer consumer pointer manipulated | sched events don't reach userspace | SCHED_HEARTBEAT still advances (the program runs) → no false TAMPER; userspace detects via max(sched_last) stale → SILENT |
.bss specifically.bss globals are stored in the BPF program's internal data section, backed by an internal array map that the loader manages. They are:
/sys/fs/bpf/bpf_map_update_elem hooks — .bss writes are direct memory stores, not map-update syscalls. The old sc_canary mechanism (comparing a .bss copy against a named-map copy to detect bpf_map_update_elem interception) is no longer needed..bss without any external interfaceThe NMI integrity checker is structurally immune to BPF override attacks because of a fundamental property: bpf_override_return intercepts function calls, but the NMI checker doesn't call the things it verifies — it reads .bss memory directly. You cannot override the return value of a memory read because a memory read is not a function call.
Additionally:
- bpf_probe_read_kernel (used for kernel struct reads in alternative designs) is a fault-safe helper that accepts any address — but SPiCa's .bss heartbeat design doesn't even need it. The checker reads .bss globals via direct load instructions.
- The NMI program runs in NMI context, where kprobes are structurally unreliable (the kernel defers or suppresses them). A kprobe-based attack against the checker's execution fights the hardware.
The NMI ring buffer (sc_nmi) carries lightweight events:
event_type |
Meaning | Userspace action |
|---|---|---|
| 0 | Heartbeat — NMI alive, sched_switch alive | Update last_nmi_heartbeat timestamp |
| 1 | TAMPER — NMI alive, sched_switch heartbeat frozen | Print [TAMPER] immediately |
Events are emitted at most once per second (throttled by NMI_LAST_EMIT). If the NMI ring buffer goes silent for >5 seconds, userspace fires [SILENT] — the NMI channel itself is dead.
```mermaid graph TD subgraph RING0["Kernel Space: Four eBPF Programs"] direction TB SCHED_P["TracePoint, sched_switch
read next_pid, next_comm
write SCHED_HEARTBEAT (.bss)
XOR obfuscate → sc_sched"] NMI_P["PerfEvent hardware NMI
read SCHED_HEARTBEAT (.bss)
compare to NMI_LAST_HB
frozen → event_type=1 (TAMPER)
alive → event_type=0 (heartbeat)
→ sc_nmi"] LSM_P["BPF LSM, kernel_read_file
READING_MODULE
gate=0: allow + log
gate=1: EPERM + log → sc_lsm"] WATCH_P["TracePoint, sched_process_exit
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
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]"]
browse all types & interfaces →
$ claude mcp add SPiCa \
-- python -m otcore.mcp_server <graph>