
Detector de rootkits en Linux basado en eBPF que utiliza análisis de vista cruzada multicanal (sched_switch, NMI, /proc) para detectar DKOM, manipulación de tracepoints y ocultación de procesos con verificación de integridad a nivel de hardware.
Integridad de Procesos del Sistema y Análisis entre Vistas
"Voy a cantar, así que brilla fuerte, SPiCa..."
SPiCa es un detector de rootkits de Linux basado en eBPF escrito en Rust. El nombre proviene de la canción de Hatsune Miku SPiCa y la estrella a la que hace referencia — Spica (Alfa Virginis), el punto más brillante de Virgo. Lo que a simple vista parece una sola estrella es en realidad una binaria espectroscópica: dos estrellas en órbita mutua, indistinguibles como objetos separados sin medir sus espectros. SPiCa aplica el mismo principio a la observación del núcleo: múltiples canales independientes miden el mismo estado del núcleo desde mecanismos físicamente distintos, y un rootkit que suprime uno es expuesto por los demás.
Descargo de responsabilidad: Partes significativas de este código fueron generadas o refactorizadas con asistencia de GLM. Se aplicaron pruebas rigurosas y diseño iterativo, pero revise el código para seguridad y rendimiento antes del uso en producción.
SPiCa está diseñado para derrotar al adversario restringido por eBPF — un atacante con privilegios elevados (CAP_BPF o CAP_SYS_ADMIN) que carga un programa eBPF privilegiado en el núcleo. Este adversario es fundamentalmente más débil que un rootkit LKM porque el verificador BPF impone restricciones estrictas:
| Restricción | Efecto sobre el adversario |
|---|---|
| Bucles acotados (~1M instrucciones por programa) | No puede escanear toda la memoria del núcleo |
| Seguridad de punteros (desreferenciación directa requiere objetivo comprobablemente válido y acotado) | No puede seguir punteros arbitrarios |
| Ayudantes restringidos (lista blanca por tipo de programa) | No puede llamar a kmalloc, vmalloc ni a ninguna función de asignación del núcleo |
Sin escritura arbitraria en el núcleo (bpf_probe_write_kernel muy restringido) | No puede modificar task_struct, arreglos de funciones de tracepoints ni bytecode de programas BPF |
| Acceso a mapas limitado a referencias cargadas | No puede acceder a mapas pertenecientes a otros programas (a menos que se enumeren por ID — ver §7) |
Con los LKMs bloqueados por la puerta LSM después del arranque, este adversario restringido es la amenaza realista restante. La maquinaria anti-evasiva de SPiCa está calibrada para esta amenaza — cada defensa es honesta sobre lo que cubre y lo que no.
init_module. SPiCa eleva el piso al bloquear el vector LKM más fácil, pero no limita al adversario superior.SPiCa es la capa de último recurso en una pila de defensa en profundidad, no un sustituto de las capas superiores.
SPiCa ejecuta cuatro programas eBPF adjuntos a ganchos del núcleo, más un motor de detección en espacio de usuario que correlaciona su salida con la vista del sistema sobre sí mismo (/proc).
| Canal | Mecanismo | Qué lo suprime | Costo de supresión |
|---|---|---|---|
/proc | Enumeración de directorio procfs | DKOM (desenlace del proceso de la lista de tareas) | Bajo — técnica de rootkit bien conocida |
sched_switch | Tracepoint eBPF en el planificador | Desadjuntar programa del arreglo funcs; filtrar contexto | Medio — requiere escritura en el núcleo al arreglo funcs |
| NMI | Interrupción de contador de rendimiento hardware → evento eBPF perf | Enganchar el manejador IDT/NMI | Pánico del núcleo casi seguro — interrupción hardware, no enmascarable |
La propiedad arquitectónica clave: un rootkit no puede suprimir los tres canales simultáneamente sin que la supresión misma se vuelva detectable o desestabilizadora. Suprimir NMI requiere parchear la IDT (Tabla de Descriptores de Interrupción), lo que provoca pánico en la mayoría de los núcleos. Esta es la "bomba viva" — el único camino del atacante hacia la ceguera total es uno que probablemente bloquee el sistema.
sched sees it, /proc doesn't → DKOM NMI finds sched's heartbeat stopped → TAMPER sched goes globally silent, /proc != empty → SILENT
Cada clase de detección es un veredicto diferencial: una discrepancia entre dos o más canales. El motor de detección es una función pura sobre el registro + la instantánea de /proc + las marcas de tiempo de los canales — sin E/S, sin efectos secundarios, totalmente comprobable mediante pruebas unitarias.
### El rediseño del NMI: de la observación a la integridad
En el diseño original, NMI era un segundo canal de observación de procesos que muestreaba la CPU e informaba qué tarea se estaba ejecutando. Esto era redundante: sched\_switch ya observa la planificación, y NMI muestreaba los mismos datos mediante un mecanismo diferente. La redundancia costaba ~1000+ eventos ring-buffer/segundo/CPU de datos de proceso que el 99.999% del tiempo confirmaban "sí, el planificador está haciendo lo que hace el planificador".
En la arquitectura rediseñada, **NMI se reutiliza de la observación de procesos a la verificación de integridad de los tracepoints.** Ya no informa qué proceso está en la CPU. En su lugar, verifica que `sched_switch` se esté ejecutando realmente leyendo un heartbeat compartido en `.bss`. Esto:
1. Elimina ~99% del tráfico del ring-buffer del NMI (eventos de estado estacionario casi nulos)
2. Detecta directamente desconexión de tracepoints, supresión y fallos de BTF/attach (el error original de BTF — ver [§11](#11-the-btf-bug-incident))
3. Se ejecuta desde una interrupción de hardware, fuera de la ruta de despacho de tracepoints — inmune a `bpf_override_return`, interceptación de kprobes y manipulación de arrays de funciones
4. Lee variables globales de `.bss` mediante acceso directo a memoria, no helpers de BPF — inmune a `fmod_ret` en funciones helper
---
## 3. El canal de observación de sched\_switch
Un programa eBPF adjunto al tracepoint `sched_switch` se dispara cada vez que el kernel planifica un proceso en una CPU. Lee el PID y el comm de la tarea entrante directamente de los argumentos del tracepoint utilizando lecturas tradicionales de desplazamiento fijo (no BTF):```
ctx.read_at::<u32>(56) → next_pid
ctx.read_at::<[u8;16]>(40) → next_comm
Deliberadamente no es BTF/CO-RE. La disposición de los argumentos de tracepoint es estable entre versiones del kernel (es parte de la ABI de tracepoint). Usar offsets fijos evita la fragilidad entre versiones del kernel de la navegación de estructuras resueltas con BTF. Esta es una decisión de diseño deliberada documentada en §11.
En cada invocación, el programa:
next_pid y next_comm del contexto de tracepointProcessInfo con BASE_KEYsc_schedbpf_ktime_get_ns() en la variable global .bss SCHED_HEARTBEAT — el latido que el verificador de integridad NMI monitoreaEl programa evita deliberadamente bpf_get_current_pid_tgid() en este contexto. En el momento de sched_switch, "current" es la tarea saliente, no la entrante. Los argumentos del tracepoint proporcionan la identidad correcta del proceso (entrante).
El motor de detección utiliza una única base de tiempo monotónica: nanosegundos desde el inicio del proceso de SPiCa (Instant::now() desde la entrada de run_detection()). Cuando llega un evento sched, el manejador del evento almacena el now proporcionado por el llamante (nanos locales del proceso), no el valor de bpf_ktime_get_ns() de eBPF incrustado en el evento. Esto evita la discrepancia de base temporal que ocurriría si el tiempo de arranque del kernel se mezclara con el tiempo local del proceso — un error que estuvo presente en versiones anteriores y que causó que todos los predicados de actividad fallaran silenciosamente.
El programa NMI está adjunto a un contador perf de hardware (HW_CPU_CYCLES) que se dispara a nivel de interrupción de hardware. Las NMI no pueden deshabilitarse con cli — suprimirlas requiere reprogramar la PMU o enganchar la IDT, ambas acciones ruidosas y desestabilizadoras.
El mecanismo de verificación de integridad es extremadamente 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
### Por qué esto detecta todos los ataques en el ámbito
| Ataque | Qué sucede | Qué ve el NMI |
|---|---|---|
| Tracepoint desvinculado del array de funciones | sched\_switch deja de ejecutarse | `SCHED_HEARTBEAT` se congela → TAMPER |
| Error de BTF / fallo de attach (silencioso) | sched\_switch nunca se ejecuta | `SCHED_HEARTBEAT` permanece en 0 → TAMPER (después del período de gracia) |
| `bpf_override_return` en una función del planificador | Si evita que el tracepoint se active | `SCHED_HEARTBEAT` se congela → TAMPER |
| Bytecode parcheado in situ | Requiere escritura arbitraria del kernel (a nivel de LKM) | Fuera del modelo de amenazas de eBPF |
| Puntero de consumidor del ring buffer manipulado | Los eventos de sched no llegan al espacio de usuario | `SCHED_HEARTBEAT` sigue avanzando (el programa se ejecuta) → no hay falso TAMPER; el espacio de usuario detecta mediante `max(sched_last)` obsoleto → SILENT |
### Por qué `.bss` específicamente
Las globales `.bss` se almacenan en la sección de datos interna del programa BPF, respaldadas por un mapa de array interno que el cargador gestiona. Son:
- **No se pueden anclar por separado** — no aparecen como mapas con nombre en `/sys/fs/bpf/`
- **No interceptables mediante hooks de `bpf_map_update_elem`** — las escrituras en `.bss` son almacenamientos directos en memoria, no llamadas al sistema de actualización de mapas. El antiguo mecanismo `sc_canary` (que comparaba una copia de `.bss` con una copia de un mapa con nombre para detectar la intercepción de `bpf_map_update_elem`) ya no es necesario.
- **Compartidas entre programas en el mismo objeto ELF** — sched\_switch y NMI se comunican a través de `.bss` sin ninguna interfaz externa
### Inmunidad a la intercepción basada en BPF
El verificador de integridad NMI es estructuralmente inmune a los ataques de anulación de BPF debido a una propiedad fundamental: `bpf_override_return` intercepta **llamadas** a funciones, pero el verificador NMI no *llama* a las cosas que verifica — *lee directamente la memoria `.bss`*. No se puede anular el valor de retorno de una lectura de memoria porque una lectura de memoria no es una llamada a función.
Además:
- `bpf_probe_read_kernel` (usado para lecturas de estructuras del kernel en diseños alternativos) es un auxiliar seguro contra fallos que acepta cualquier dirección — pero el diseño de latido `.bss` de SPiCa ni siquiera lo necesita. El verificador lee las globales `.bss` mediante instrucciones de carga directa.
- El programa NMI se ejecuta en contexto NMI, donde las kprobes son estructuralmente poco fiables (el kernel las aplaza o las suprime). Un ataque basado en kprobes contra la ejecución del verificador lucha contra el hardware.
### Semántica de eventos NMI
El ring buffer NMI (`sc_nmi`) transporta eventos ligeros:
| `event_type` | Significado | Acción del espacio de usuario |
|---|---|---|
| 0 | Latido — NMI vivo, sched\_switch vivo | Actualizar la marca de tiempo `last_nmi_heartbeat` |
| 1 | TAMPER — NMI vivo, latido de sched\_switch congelado | Imprimir `[TAMPER]` inmediatamente |
Los eventos se emiten como máximo una vez por segundo (limitados por `NMI_LAST_EMIT`). Si el ring buffer NMI queda en silencio durante más de 5 segundos, el espacio de usuario dispara `[SILENT]` — el canal NMI en sí está muerto.
---
## 5. Lógica de Detección```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;
| Alerta | Condición | Qué detecta |
|---|---|---|
[DKOM] | TGID programado por el kernel ausente de /proc por >2 s | Manipulación Directa de Objetos del Kernel — proceso oculto de la enumeración de espacio de usuario |
[GHOST] | TGID presente en /proc pero nunca visto por sched_switch por >5 s | Suplantación de /proc — entrada falsa insertada para pasar las comprobaciones de DKOM |
[TAMPER] | NMI detecta que el latido de sched_switch está congelado (event_type=1) | Tracepoint desconectado, suprimido o error de BTF/attach |
[SILENT] | max(sched_last) obsoleto en todo el registro mientras /proc no está vacío; o latido NMI ausente por >5 s | Pérdida de observación a nivel de canal — el canal sched o NMI está muerto |
[DUPE] | Mismo TGID, diferente start_time_ns entre eventos | Suplantación de campo task_struct — el rootkit parchea tgid para hacerse pasar por un proceso legítimo |
[HOOK] | PIDs visibles mediante getdents64 sin formato pero ausentes de libc readdir | Rootkit LD_PRELOAD — interceptación de bibliotecas de espacio de usuario que oculta procesos de ps, ls y otras herramientas (Symbiote, JynxKit, Azazel, Medusa/OrBit) |
[WATCHDOG] | El pin /sys/fs/bpf/spica_watchdog existe al inicio | La instancia anterior fue terminada de forma no controlada (SIGKILL, OOM, crash) |
[LKM-ALLOW] | READING_MODULE interceptado mientras la puerta está abierta (ventana de arranque) | Registro de auditoría: módulo cargado antes de cerrar la puerta |
[LKM-DENY] | READING_MODULE interceptado mientras la puerta está cerrada | insmod/modprobe bloqueado después del inicio |
SILENT es un veredicto a nivel de canal, no por proceso. Se dispara cuando:
Canal sched muerto: max(sched_last) sobre todos los registros del registro está obsoleto más allá de CHANNEL_DEAD_NANOS mientras /proc no está vacío. La protección de /proc no vacío es la garantía de actividad: en cualquier sistema Linux en ejecución, /proc no está vacío (init, hilos del kernel, kworkers), y el planificador nunca está realmente inactivo (tics de temporizador, esperas de E/S, callbacks de RCU). Un max(sched_last) obsoleto en un sistema no vacío significa que el canal murió, no que el sistema está inactivo.
Canal NMI muerto: No ha llegado ningún evento de latido (event_type=0) del búfer circular NMI durante >5 segundos. Esto significa que el evento perf NMI dejó de dispararse — ya sea que el evento perf se desconectó o la PMU fue reprogramada.
Ambas comprobaciones tienen un período de enfriamiento de 30 segundos para evitar inundaciones de alertas.
[HOOK])SPiCa lee /proc mediante dos mecanismos independientes en cada tick:
Llamada al sistema getdents64 sin formato (read_tgids()) — std::fs::read_dir de Rust lo llama directamente a través de libc::syscall(SYS_getdents64, ...), evitando todos los hooks de funciones libc. Los rootkits LD_PRELOAD no pueden interceptar esta ruta.
libc readdir (read_tgids_libc()) — llama a libc::opendir / libc::readdir a través de FFI, que pasa por el enlazador dinámico y es completamente interceptable mediante los hooks de /etc/ld.so.preload.
Si getdents64 ve PIDs que libc readdir no ve, un rootkit de espacio de usuario está interceptando readdir para ocultar procesos. SPiCa dispara [HOOK]:```
[HOOK] 3 PID(s) hidden from libc readdir — LD_PRELOAD rootkit suspected
This technique turns SPiCa's kernel-level privilege into an active detection of userspace rootkits. The raw syscall path is the ground truth — no userspace hook can blind it. The libc path is the "perceived" view that a normal tool (ps, ls) would see. The discrepancy is unambiguous proof of library interception.
**Rootkits detected by `[HOOK]`:**
| Rootkit | Mecanismo de ocultación | Detectado |
|---------|-------------------------|-----------|
| Symbiote | Parasitic LD_PRELOAD, hooks `readdir` | Sí (cuando se oculta activamente) |
| JynxKit | LD_PRELOAD, `MAGIC_GID` hiding via `readdir` | Sí |
| Azazel | LD_PRELOAD, `readdir` + `stat` hooks | Sí |
| Medusa/OrBit | LD_PRELOAD, `readdir` + credential theft | Sí |
**Probado contra Symbiote real:** Una muestra viva de Symbiote (SHA256 `f55af21f...`, MalwareBazaar) fue desplegada mediante `/etc/ld.so.preload` en una VM Ubuntu 24.04. La muestra engancha `readdir`, `readdir64`, `stat`, `fstatat`, `pam_authenticate`, `pcap_loop`, `recvmsg`, `fopen`, `read`, `execve`. Cuando el hook LD_PRELOAD oculta activamente un PID de `readdir`, SPiCa dispara `[HOOK]` en un ciclo de tick (<1 s). Cuando Symbiote está cargado pero pasivo (sin ocultar activamente), SPiCa produce cero falsos positivos.
### Ventana de gracia
Una ventana de gracia de 50 ms evita falsos positivos en procesos recién bifurcados. Las comprobaciones SILENT a nivel de canal tienen un período de gracia de 5 segundos (la referencia `NMI_FIRST_TICK` del programa NMI) para que la latencia de inicio no produzca alertas TAMPER falsas.
---
## 6. Secreto de Dirección Limitado por el Verificador
### Definición
**Secreto de dirección limitado por el verificador** es una propiedad de seguridad computacional que se cumple bajo el modelo de adversario restringido por eBPF. Establece que:
> Dado un adversario que puede cargar programas eBPF (CAP\_BPF) pero está restringido por el verificador BPF (bucles acotados, helpers limitados, sin escritura arbitraria en el kernel), la dirección del kernel de la sección de datos `.bss` de un programa BPF es computacionalmente inviable de descubrir.
### Los cuatro mecanismos
La propiedad es una composición de cuatro barreras independientes:
1. **kASLR** — La aleatorización del diseño del espacio de direcciones del kernel aleatoriza la región del heap del kernel donde se asigna la memoria de los mapas BPF (incluyendo los mapas de respaldo de `.bss`). La dirección exacta cambia en cada arranque.
2. **Límite de instrucciones del verificador** — El verificador BPF limita las instrucciones totales por programa a ~1 millón y requiere bucles que terminan demostrablemente. Un programa de escaneo del adversario puede sondear como máximo N ≈ 10^6 direcciones por carga mediante `bpf_probe_read_kernel`. Dado un espacio de búsqueda aleatorizado por kASLR, la probabilidad de éxito por programa es insignificante.
3. **Puerta de acceso a mapas LSM** (ver [§7](#7-lsm-map-access-gate)) — Sin la puerta, un adversario podría enumerar los mapas BPF por ID (`BPF_MAP_GET_NEXT_ID` → `BPF_MAP_GET_FD_BY_ID` → `BPF_MAP_LOOKUP_ELEM`) y leer el contenido de `.bss` directamente. La puerta elimina este camino fácil, forzando al adversario a la ruta de escaneo de memoria limitada por el verificador.
4. **Ubicación de `.bss`** — Las variables globales de `.bss` no aparecen en las interfaces de enumeración de mapas con nombres. El mapa de respaldo interno de `.bss` se llama `.bss` por libbpf/aya, pero su contenido (las variables globales reales) solo es accesible si se tiene el fd del mapa. El fd del mapa requiere o la tabla de fds del proceso cargador o el ID del mapa (bloqueado por la puerta).
### Estructura del argumento formal```
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).
La mayoría de las investigaciones de seguridad sobre BPF tratan al verificador como una superficie de ataque (bugs del verificador, falta de solidez, JIT spray). Este trabajo invierte el marco: las restricciones del verificador son una primitiva defensiva que acota el espacio de búsqueda del adversario. El secreto no es criptográfico (no es AES); es computacional — limitado por lo que el verificador permite al adversario computar.
bpf_probe_read_kernel es observable en el tiempo (fallo vs. éxito toma diferentes ciclos). Una búsqueda binaria basada en temporización sobre el montón es teóricamente más eficiente que un escaneo lineal. El límite de instrucciones restringe las sondas por programa, pero la búsqueda binaria podría reducir la cantidad de sondas a O(log |A|) ≈ 30. Esta es una limitación real. Mitigación: limitar la tasa de BPF_PROG_LOAD mediante la puerta LSM.Los globales .bss están respaldados por un mapa BPF interno. Cualquier proceso con CAP_BPF puede enumerar todos los mapas del sistema:```
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
Contra el adversario eBPF (que no puede llamar syscalls), `.bss` está oculto. Contra el root de userspace, es un mapa estándar — completamente legible. El gate LSM cierra esta brecha.
### La defensa
Un hook LSM de BPF en el syscall `bpf` verifica los comandos de acceso a mapas:```
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
Los IDs de mapa son escritos en .bss por el cargador de espacio de usuario inmediatamente después de la carga del programa, antes de que el hook de LSM esté armado (mismo patrón de temporización que el sc_gate existente para el bloqueo de LKM). No se necesita BTF — cmd y map_id provienen de argumentos de llamada al sistema, no de estructuras del kernel.
Quirúrgico: solo bloquea el acceso a los IDs de mapa específicos de SPiCa. Otras herramientas BPF de otros procesos (tcpdump, bpftrace, bcc) acceden a sus propios mapas y no se ven afectadas.
/proc/pid/mem — Otro proceso root podría leer la memoria del proceso de SPiCa directamente a través de ptrace, evitando por completo la llamada al sistema BPF. Esta es una limitación fundamental: no se puede proteger contra un proceso con el mismo nivel de privilegio que lea tu memoria a través de /proc/pid/mem. Defensa: envío de registros fuera del host (si el atacante puede leer la memoria local, también puede suprimir alertas locales — solo el registro remoto ayuda).En hosts equipados con TPM, la clave de ofuscación XOR de 64 bits se obtiene de TPM2_GetRandom a través de la crate tss-esapi (llamada directa a la biblioteca libtss2-esys). La clave no transita por tuberías, ni por línea de comandos de subprocesos, ni por disco. Existe solo en el hardware TPM y en la memoria volátil del kernel (mapa de respaldo .bss) durante la duración del proceso SPiCa.
En hosts sin TPM (VMs sin vTPM, contenedores, máquinas sin chip TPM), SPiCa recurre a una clave de tiempo de compilación por compilación generada desde /dev/urandom por el script de construcción. El fallback es visiblemente ruidoso al inicio con un banner de advertencia.
.bssLos siguientes valores residen en la sección .bss del programa eBPF, escritos por el espacio de usuario a través de set_global() antes de cargar cualquier programa:
| Global | Propósito | Escrito por |
|---|---|---|
BASE_KEY | Clave de ofuscación XOR | espacio de usuario al cargar |
SPICA_PID | TGID propio de SPiCa (watchdog) | espacio de usuario al cargar |
SCHED_HEARTBEAT | Marca de tiempo de viveza de sched_switch | programa sched_switch en cada invocación |
NMI_LAST_HB | Registro del NMI del último heartbeat de sched | programa NMI en cada verificación |
NMI_FIRST_TICK | ktime de la primera invocación del NMI (período de gracia) | programa NMI en la primera invocación |
NMI_LAST_EMIT | Límite: ktime de la última emisión de evento | programa NMI en cada emisión |
Los mapas BPF nombrados son enumerables a través de la interfaz de iteración de mapas de la llamada al sistema bpf() (bloqueada por la puerta LSM — ver §7); las globales de .bss se acceden a través del mismo mapa interno pero no tienen nombre separado ni son anclables.
Todos los campos de ProcessInfo se pliegan con XOR usando la BASE_KEY de 64 bits antes de escribirlos en los buffers circulares. El campo event_type se deja intencionalmente sin ofuscar para que el espacio de usuario pueda leerlo como centinela antes de desofuscar el resto.
Esto es ofuscación contra fugas de lectura, no cifrado contra un adversario capaz. XOR con una clave repetitiva de 8 bytes es estructuralmente vulnerable a texto plano conocido: valores predecibles de comm ("bash", "systemd", "kthreadd") aplicados XOR contra el texto cifrado recuperan los bytes de la clave directamente. La defensa es apropiada para el adversario eBPF (que no puede leer fácilmente el buffer circular — requiere la misma brecha de capacidad que protege .bss), no para un adversario con acceso binario e interceptación del buffer circular.
Estado: Fase de diseño. Aún no implementado. Esta sección documenta la arquitectura objetivo para el artículo de investigación.
El sellado TPM vinculado a PCR mueve el límite de distribución de claves de la capa de software al silicio. La clave se sella en el momento de la instalación contra valores esperados del Registro de Configuración de Plataforma (PCR), y solo se puede desellar si el estado de arranque del sistema coincide con las mediciones esperadas.
Durante el arranque, el firmware, el gestor de arranque y el kernel miden componentes críticos en los PCR:
| PCR | Qué mide | Estabilidad |
|---|---|---|
| PCR 4 | Código del gestor de arranque + imagen del kernel (GRUB mide ambos) | Cambia en actualización del kernel |
| PCR 5 | Tabla de particiones GPT/MBR, configuración de arranque | Estable entre actualizaciones |
| PCR 7 | Política de Secure Boot (política SI, MOK, db/dbx) | Estable entre actualizaciones del kernel |
| PCR 8 | Línea de comandos del kernel (mediciones de systemd-stub) | Estable a menos que cmdline cambie |
| PCR 9 | Initramfs (GRUB mide initrd aquí) | Cambia en actualización de initramfs |
| PCR 10 | Lista de mediciones de IMA | Cambia a medida que se miden ejecutables |
Nota: La asignación de PCR depende de la cadena de arranque. GRUB mide el kernel en PCR 4; systemd-stub mide en PCR 4 y la línea de comandos en PCR 8. La política de sellado debe coincidir con la cadena de arranque objetivo.
Sellar (tiempo de instalación): La clave de 64 bits se sella contra valores de PCR esperados usando TPM2_Create con una sesión TPM2_PolicyPCR. El blob sellado se almacena en disco. Está cifrado con la clave interna del TPM y solo se puede descifrar cuando los PCR especificados coinciden.
Desellar (arranque temprano, etapa de initramfs): Antes de que se ejecute cualquier código de espacio de usuario no confiable, el hook de initramfs de SPiCa solicita TPM2_Unseal. Si los valores actuales de PCR coinciden con la política de sellado, el TPM libera la clave.
Inyectar: La clave se escribe en .bss a través de set_global() antes de cargar los programas.
La trampa: Si un atacante ha modificado el kernel (PCR 4 no coincide), intercambiado el initramfs (PCR 9 no coincide) o alterado la política de Secure Boot (PCR 7 no coincide), los hashes de PCR divergen. El TPM se niega a desellar, y SPiCa falla de forma segura — se niega a iniciar en lugar de ejecutarse a ciegas con una clave comprometida.
| Política | Sellado contra | Fortaleza | Costo operativo |
|---|---|---|---|
| Fuerte | PCR 4 + 7 + 9 + 10 | Captura cambios en kernel, initramfs, Secure Boot e IMA | Re-sellar después de cada actualización de kernel/initramfs |
| Equilibrada | PCR 7 + 10 | Captura cambios en Secure Boot y evaluación de IMA; estable entre actualizaciones del kernel | Re-sellar solo en cambios de política Secure Boot o política IMA |
| Mínima | PCR 7 solo | Captura solo cambios en el estado de Secure Boot | Muy estable; vinculación más débil |
Para el artículo de investigación, presentar la política fuerte y discutir la compensación del re-sellado en las limitaciones.
Los dos mecanismos son complementarios:
.bss donde reside la clave.Ninguno es suficiente por sí solo. El sellado PCR no ayuda si la clave se ve comprometida en tiempo de ejecución (enumeración de mapas). El secreto de direcciones no ayuda si el sistema fue comprometido antes de que SPiCa iniciara (initramfs hostil).
spica_lsm_modblock, la puerta de acceso a mapas) se cargan en tiempo de ejecución y NO se miden en ningún PCR. Un rootkit que los desconecte después del arranque no es capturado por el sellado PCR. Lo que captura eso es el heartbeat NMI (detectando que el propio sistema de detección dejó de ejecutarse).SPiCa es la última capa de cumplimiento. Complementa un sistema correctamente configurado, no reemplaza las capas superiores.```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 verifica las cuatro capas al inicio e imprime su estado.
---
## 11. El incidente del error BTF
### Lo que sucedió
Durante las pruebas en Ubuntu (kernel más reciente), una incompatibilidad BTF provocó que el programa de tracepoint `sched_switch` se adjuntara correctamente pero **nunca disparara un solo evento.** La llamada al sistema `attach()` devolvió `Ok(())`, por lo que SPiCa continuó normalmente — pero el búfer de anillo sched permaneció vacío. No se dispararon alertas de detección porque el motor de detección no tenía datos de planificación. **SPiCa funcionó a ciegas sin ninguna indicación de fallo.**
Este es el peor modo de fallo para una herramienta de seguridad: ceguera silenciosa.
### Por qué no se detectó
El motor de detección original razonaba **por proceso**: cada registro de proceso tenía marcas de tiempo `sched_last` y `nmi_last`, y la vivacidad se calculaba por registro. Cuando sched\_switch falló globalmente:
1. `sched_live` se volvió falso para cada registro (sin nuevos eventos sched → todos los valores `sched_last` envejecen)
2. El predicado `TAMPER` por proceso (`in_proc && nmi_live && !sched_live`) podía dispararse, pero requería que `nmi_live` se mantuviera *continuamente* durante 2 segundos. El NMI muestrea de forma dispersa (período de 10M ciclos), por lo que `suspect_since` se reiniciaba constantemente debido a la fluctuación del muestreo y nunca maduraba.
3. El predicado `SILENT` por proceso requería `sched_live`, que ahora era falso para todo — el predicado se invertía exactamente bajo la condición que debía detectar.
4. No había **verificación de vivacidad a nivel de canal** — ningún mecanismo "¿ha disparado sched ALGUNA VEZ?" o "¿está obsoleto max(sched_last)?".
Además, se descubrió una falta de coincidencia en la base de tiempo: `sched_last` almacenaba `bpf_ktime_get_ns()` (nanosegundos de arranque del kernel) mientras que `evaluate()` comparaba con `nanos_since_startup()` (nanosegundos locales del proceso). La `wrapping_sub` de estas diferentes bases de tiempo producía valores enormes, haciendo que todos los predicados de vivacidad fueran trivialmente falsos. La lógica de detección nunca había funcionado correctamente en producción — el error BTF simplemente lo enmascaró al evitar que los eventos llegaran en absoluto.
### Cómo el rediseño lo soluciona
| Problema | Solución |
|---|---|
| Sin verificación de vivacidad a nivel de canal | `evaluate()` ahora calcula `max(sched_last)` y dispara `[SILENT]` si está obsoleto mientras `/proc` no está vacío |
| TAMPER por proceso nunca maduraba (reinicio por fluctuación) | TAMPER ahora es una señal directa del programa NMI (comparación de heartbeat `.bss`), no una FSM por proceso con umbrales |
| SILENT por proceso se invertía cuando sched fallaba | SILENT ahora es a nivel de canal, calculado a partir de agregados, no predicados por registro |
| Desajuste de base de tiempo | `sched_last` ahora almacena tiempo local del proceso (parámetro `now`), no `bpf_ktime_get_ns()` |
| NMI era una observación redundante | NMI ahora es un verificador de integridad — detecta la muerte de sched directamente a través del heartbeat `.bss` |
---
## 12. Limitaciones conocidas y superficie de ataque
### Dentro del modelo de amenazas eBPF
| Ataque | Estado | Notas |
|---|---|---|
| **Manipulación del puntero consumidor del búfer de anillo** | **Detectado** (SILENT) | `max(sched_last)` se vuelve obsoleto → SILENT. `SCHED_HEARTBEAT` aún avanza (el programa se ejecuta) → no hay falso TAMPER. |
| **DKOM de la estructura de evento Perf** (campo `state` en cero) | **Detectado** (SILENT) | NMI deja de disparar → sin eventos de heartbeat → SILENT en el canal NMI |
| **Suplantación de campo `task_struct`** (sobrescribir pid/tgid) | **Parcial** (DUPE) | Si el TGID suplantado coincide con un registro existente con un `start_time_ns` diferente, DUPE se dispara. Si `start_time_ns` también es suplantado, esto pasa desapercibido. |
| **Desvinculación de programa eBPF** (eliminar del array funcs) | **Detectado** (TAMPER) | El heartbeat de NMI se congela → TAMPER |
| **Bytecode parcheado in situ** | **No detectado** | Requiere escritura arbitraria del kernel (nivel LKM) — fuera del modelo de amenazas eBPF |
| **Canal lateral de temporización en `bpf_probe_read_kernel`** | **Riesgo teórico** | La búsqueda binaria en el heap podría encontrar `.bss` en ~30 sondas. Ver limitaciones en [§6](#6-verifier-bounded-address-secrecy). |
### Fuera del modelo de amenazas eBPF (reconocido, no resuelto)
| Ataque | Estado | Notas |
|---|---|---|
| **Hook del manejador IDT/NMI** | **La bomba activa** | Bypass arquitectónicamente completo. Pánico de kernel casi seguro. |
| **Supresión de doble canal** | **La bomba activa** | Suprime simultáneamente sched + NMI. Hereda la inestabilidad del hooking de NMI. |
| **Exploit del verificador** | **No objetivo** | Escapa a ejecución de código arbitrario del kernel. Clase de amenaza separada. |
| **ptrace / `/proc/pid/mem`** | **Limitación fundamental** | Lectura de memoria con el mismo privilegio. Solo ayuda el envío de registros fuera del host. |
| **Parche de puntero consumidor + heartbeat** | **Nivel LKM** | Si el atacante tiene escritura arbitraria del kernel, puede avanzar el puntero consumidor Y escribir heartbeats falsos. Pero escritura arbitraria del kernel = nivel LKM = fuera del modelo de amenazas. |
---
## 13. Construcción y ejecución
### Prerrequisitos
- Kernel Linux >= 5.15 con `CONFIG_DEBUG_INFO_BTF=y` (solo para el hook LSM)
- Para bloqueo de módulos: `CONFIG_BPF_LSM=y` y `lsm=bpf` en la línea de comandos del kernel
- Chip TPM 2.0 + biblioteca `tpm2-tss` (opcional; retrocede con advertencia visible)
- Cadena de herramientas Rust nightly
> **Nota:** El paso `generate-vmlinux` **ya no es necesario**. Los programas eBPF utilizan offsets de tracepoint tradicionales y variables globales `.bss` — no se necesita navegación de estructuras CO-RE/BTF. El comando xtask `generate-vmlinux` se conserva para uso futuro pero no forma parte del pipeline de construcción.
Verificar que BPF LSM esté activo: `cat /sys/kernel/security/lsm` debe contener `bpf`.
### Configuración```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
### Instalar en initramfs (protección de arranque temprano)```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
### Desarrollo (macOS o Linux)
Los programas eBPF no pueden ejecutarse en macOS. `cargo check` y las pruebas unitarias para la lógica de detección funcionan; la verificación completa en tiempo de ejecución requiere 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 por una raíz de confianza completa basada en hardware. Consulte §9.bpf que bloquea la enumeración de mapas del estado interno de SPiCa desde procesos que no son SPiCa. Consulte §7.BPF_PROG_LOAD — Extender la puerta LSM para limitar la velocidad de carga de programas BPF desde procesos que no son SPiCa, mitigando el riesgo de canal lateral de tiempo en el descubrimiento de .bss. Consulte las limitaciones en §6.siphasher ya está en el árbol para el token de integridad).| Término | Definición |
|---|---|
| BPF | Berkeley Packet Filter — motor de ejecución dentro del kernel para programas en espacio aislado. El BPF moderno (eBPF) se extiende más allá de los paquetes hacia el rastreo, la seguridad y las redes. |
| BTF | BPF Type Format — información de depuración del kernel que permite que los programas CO-RE (Compile Once, Run Everywhere) naveguen por las estructuras del kernel de forma portátil. |
| CO-RE | Compile Once, Run Everywhere — técnica BPF que utiliza BTF para escribir programas portátiles que se adaptan a diferentes versiones del kernel. |
| DKOM | Direct Kernel Object Manipulation — técnica de rootkit que elimina un proceso de la lista enlazada del kernel para ocultarlo de /proc. |
| fmod_ret | Tipo de programa BPF que modifica el valor de retorno de una función del kernel a través del trampolín BPF. |
| freplace | Extensión de programa BPF — se adjunta a una (sub)función específica de otro programa BPF, interceptando su ejecución. |
| funcs array | El arreglo de punteros a función en una estructura de tracepoint del kernel que contiene funciones de devolución de llamada (incluidos programas BPF) para invocar cuando se activa el tracepoint. |
| IDT | Interrupt Descriptor Table — estructura de la CPU que asigna vectores de interrupción a funciones de manejo. Enganchar la entrada NMI requiere parchear la IDT. |
| kASLR | Kernel Address Space Layout Randomization — aleatoriza las direcciones de código/datos del kernel en cada inicio para dificultar la explotación. |
| NMI | Non-Maskable Interrupt — interrupción de hardware que no puede ser deshabilitada por software (cli). Utilizada por los contadores de rendimiento para la observación a nivel de hardware. |
| PCR | Platform Configuration Register — registro TPM que acumula mediciones (hashes) de los componentes de arranque. No se puede restablecer (excepto reinicio), solo extender. |
| PMU | Performance Monitoring Unit — contadores de hardware en la CPU que cuentan eventos (ciclos, fallos de caché, etc.) y pueden activar interrupciones (NMI) en umbrales. |
| TPM | Trusted Platform Module — coprocesador criptográfico que proporciona almacenamiento de claves basado en hardware, generación de números aleatorios y atestación de mediciones. |
Licencia del Motor SPiCa: MIT O Apache-2.0 (espacio de trabajo). El programa eBPF exporta una licencia GPL a través del estático _license — este es un requisito del kernel para programas eBPF que usan ayudantes con licencia GPL, y se aplica solo al bytecode eBPF cargado, no al binario de espacio de usuario.
Atribución del Personaje: "Hatsune Miku" y las ilustraciones de personajes asociadas son propiedades con derechos de autor de Crypton Future Media, INC. (www.piapro.net). Este proyecto es una herramienta de investigación independiente y no comercial, no afiliada a Crypton Future Media. Personaje utilizado bajo la Licencia de Personajes Piapro (PCL).
El nombre del proyecto SPiCa está inspirado en la canción original de Toku-P.
| Verifier | El verificador BPF — componente del kernel que analiza estáticamente los programas BPF antes de cargarlos para asegurar que terminen y no accedan a memoria no segura. |