Skip to content
Kernel Security

22 Kernel Hooks: How Inner Warden Detects Full Kill Chains in eBPF

10 min read

Why 7 hooks wasn't enough

The first version of Inner Warden's eBPF subsystem had 7 hooks: execve, connect, openat, process_exit, setuid, commit_creds, and bprm_check. Those 7 hooks were good at catching individual syscalls. A suspicious exec here, a privilege escalation there, a connection to a known bad IP. Each event made sense on its own.

The problem is that attackers don't operate in individual syscalls. They operate in sequences. An attacker doing ptrace injection followed by a mount namespace escape followed by a kernel module load is executing a kill chain. With only 7 hooks, those three steps looked like three unrelated events. The sensor saw a ptrace call, a mount call, and a module load, but it had no idea they were the same attack.

To detect kill chains, you need to see enough of the syscall surface to reconstruct the full story. You need to watch memory operations, process creation, file deletion, signal delivery, and namespace manipulation. Seven hooks left too many gaps. An attacker who stayed between the cracks was invisible.

The 22-hook architecture

eBPF v2 expands coverage from 7 hooks to 22. Every hook was chosen because it closes a specific detection gap that real-world attack techniques exploit. Here is the full inventory, organized by program type.

18 Tracepoints
SyscallWhat it catches
execveEvery process execution. Reverse shells, crypto miners, binaries dropped in /tmp.
connectOutbound network connections. C2 callbacks, data exfiltration, unexpected egress.
openatFile access. Reading /etc/shadow, modifying SSH keys, tampering with binaries.
process_exitProcess lifecycle tracking. Detects short-lived processes that run and vanish.
ptraceProcess injection. Debugger attach, memory manipulation, code injection into running processes.
setuidUID transitions. SUID binary abuse, privilege boundary crossings.
bindSocket binding. Backdoor listeners, rogue services, bind shells on unexpected ports.
mountFilesystem mount operations. Container escapes, namespace manipulation, overlay abuse.
memfd_createAnonymous file creation in memory. The signature of fileless malware payloads.
init_moduleKernel module loading. Rootkits, LKM backdoors, unauthorized driver installation.
dupFile descriptor duplication. Used in reverse shell construction (dup2 stdin/stdout/stderr).
listenSocket listen. Confirms a bind shell or backdoor is actively accepting connections.
mprotectMemory permission changes. Making memory pages executable (W+X) for shellcode or JIT payloads.
cloneProcess/thread creation with namespace flags. Container breakout via namespace unsharing.
unlinkFile deletion. Log tampering, evidence destruction, covering tracks after exploitation.
renameFile renaming. Binary replacement, config file swaps, disguising malicious files.
killSignal delivery. Killing security processes, rootkit hidden-PID probing via signal errors.
prctlProcess control. Name changes to disguise malicious processes (PR_SET_NAME), seccomp manipulation.
acceptIncoming connection acceptance. Confirms active use of backdoor listeners.
1 Kprobe
commit_creds - privilege escalation detection

Fires when a process changes its kernel credentials. Inner Warden watches for UID transitions to 0 (root) from non-root processes outside of legitimate sudo/su paths. Kernel exploits, SUID abuse, and container escapes all funnel through this function. This is the single most important hook for catching privilege escalation at the exact moment it happens.

1 LSM Hook
bprm_check_security - block execution from /tmp

This Linux Security Module hook runs before a binary is loaded for execution. Inner Warden denies exec from world-writable directories: /tmp, /dev/shm, and /var/tmp. The binary never runs. The exec call returns EPERM. This is not detection. This is prevention. The payload never executes.

1 XDP Program
XDP blocklist - wire-speed IP blocking

Runs at the network driver level, before the kernel networking stack processes the packet. Blocked IPs are dropped with zero response. No TCP handshake, no SYN-ACK, nothing. Handles 10M+ packets per second on commodity hardware. When the agent decides to block an IP, the block takes effect at wire speed.

1 Raw Tracepoint
sys_enter dispatcher - tail call routing via ProgramArray

A single raw tracepoint on sys_enter that dispatches to the correct handler using a BPF ProgramArray (tail calls). Instead of attaching 18 separate tracepoints with individual overhead, the dispatcher routes each syscall to its handler in a single lookup. This keeps attach points minimal and dispatch fast.

Kill chain detection in practice

The real power of 22 hooks is not the individual events. It is the sequences. Here are three attack scenarios that were invisible with 7 hooks and are now fully visible.

Scenario 1: Container escape
1. clone(CLONE_NEWNS | CLONE_NEWPID)   // create new namespace
2. mount("/", "/host", MS_BIND)         // bind-mount host filesystem
3. ptrace(PTRACE_ATTACH, host_pid)      // inject into host process
4. execve("/host/bin/bash")             // spawn shell on host

All 4 events share the same cgroup_id.
Inner Warden correlates them into a single incident:
  "container escape via namespace + mount + ptrace injection"

With 7 hooks, you would see the execve and maybe the ptrace. The clone with namespace flags and the mount were invisible. You got two unrelated alerts instead of one kill chain.

Scenario 2: Fileless malware
1. memfd_create("payload", MFD_CLOEXEC) // create anonymous file in memory
2. openat(memfd, O_WRONLY)               // write payload to memory file
3. mprotect(addr, len, PROT_EXEC)        // make memory executable
4. connect(C2_IP, 443)                   // phone home to command server

No file ever touches disk.
Traditional file-integrity monitoring sees nothing.
Inner Warden sees the full sequence:
  "fileless payload loaded via memfd, made executable, C2 connection initiated"

Before v2, the connect was visible but the memfd_create and mprotect were not. The sensor would flag a suspicious outbound connection with no context about how the payload got there. Now you see the entire chain.

Scenario 3: Kernel rootkit installation
1. init_module(rootkit.ko)                // load kernel module
2. kill(0, 0) on PIDs 1-65535             // scan for hidden PIDs
3. prctl(PR_SET_NAME, "kworker/0:1")      // rename to look like kernel thread
4. unlink("/tmp/rootkit.ko")              // delete the module file
5. rename("/usr/bin/ps", "/usr/bin/ps.bak") // replace system tools

Inner Warden correlates:
  "kernel module loaded, PID scan for hidden processes,
   process disguised as kernel thread, evidence destroyed"

Every step in this chain uses a different syscall. With 7 hooks, only the init_module might have triggered an alert if you happened to be watching for it. The kill scan, the prctl rename, the evidence cleanup through unlink and rename were all blind spots.

Noise filtering learned from Falco

Watching 18 syscalls on a production server generates a lot of events. A busy web server can produce thousands of connect, openat, and clone calls per second. Without filtering, you drown in noise. This is the problem Falco solves with its rules engine, and we learned from their approach.

Inner Warden uses three filtering mechanisms, all applied in kernel space before events reach userspace:

COMM_ALLOWLIST (137 entries)

Known-safe process names that generate high-volume, low-signal events. Derived from Falco's falco_rules.yaml but expanded and adapted. Includes system daemons like systemd-resolved, timesyncd, networkd, and common package managers. These processes still trigger hooks. They are just filtered before entering the ring buffer.

CGROUP_ALLOWLIST

Trusted container cgroups that produce expected syscall patterns. Your monitoring stack, your log aggregators, your health checkers. Events from allowlisted cgroups are suppressed at the eBPF layer.

PID_RATE_LIMIT

Per-PID rate limiting in the eBPF program itself. A single process calling openat 10,000 times per second (think: a web server serving static files) only generates a limited number of events. The rate limit is per-syscall-type, so a rate-limited openat does not suppress that same PID's connect events.

The result: on a typical production server, the 22 hooks produce roughly the same event volume as the original 7. You get 3x the syscall coverage with no increase in noise because the filtering happens before events leave kernel space.

What Falco can't do

Falco is an excellent tool. We use it as an input source (the sensor has a dedicated Falco log collector). But Falco is fundamentally an observer. It watches syscalls and generates alerts. It cannot act on what it sees.

Inner Warden observes and acts. The difference matters in three specific ways:

  • LSM enforcement - The bprm_check_security hook does not alert on /tmp execution. It prevents it. The binary never runs. Falco would tell you about it after the fact. Inner Warden's LSM hook returns EPERM before the first instruction executes.
  • XDP blocking - When the agent decides an IP is malicious, the block is pushed into the XDP BPF map. Packets from that IP are dropped at the network driver, before they reach the TCP stack. Falco has no mechanism to block network traffic. You would need a separate tool (iptables, nftables, or a custom XDP program) for that.
  • Same codebase - Detection, prevention, and response are all part of the same binary. There is no integration layer between "the tool that detects" and "the tool that blocks." The sensor detects with eBPF hooks, the agent decides, and the response flows back into eBPF programs (XDP map, LSM policy). One process, one config, one audit trail.

Ring buffer and performance

All 22 eBPF programs write events into a shared 1MB ring buffer. The userspace sensor reads from this buffer using epoll-based wakeup, not polling. When there are no events, the sensor sleeps. When events arrive, the kernel wakes the sensor immediately via the epoll file descriptor. There is no busy loop, no timer-based polling, no wasted CPU cycles.

Performance characteristics
Ring buffer:     1MB shared across all 22 programs
Wakeup:          epoll-based (not polling)
Portability:     CO-RE/BTF relocations (kernel 5.8+)
Compilation:     #![no_std] crate, target bpfel-unknown-none
Loader:          Aya (pure Rust, no libbpf/clang/BCC dependency)
Container-aware: cgroup_id attached to every event
Stack limit:     512 bytes per BPF function (verifier-enforced)
Memory safety:   BPF verifier checks all programs before load

CO-RE (Compile Once, Run Everywhere) means the eBPF programs are compiled once and work across different kernel versions without recompilation. BTF (BPF Type Format) provides the type information needed to relocate struct field accesses at load time. If you are running kernel 5.8 or newer with BTF enabled (which is the default on most modern distributions), the eBPF subsystem loads and runs without any kernel-specific configuration.

Shared types: 22 event structs, zero parsing

The eBPF programs and the userspace sensor share type definitions through a dedicated crate: sensor-ebpf-types. This crate defines 22 event structs, one for each hook. Both the kernel-space eBPF code and the userspace Rust code use the same struct definitions. There is no serialization, no deserialization, no JSON parsing, no protobuf. The kernel writes a struct into the ring buffer. The sensor reads the same struct out.

Event struct inventory
// crates/sensor-ebpf-types/src/lib.rs

ExecveEvent        ConnectEvent       FileOpenEvent
PrivEscEvent       ProcessExitEvent   PtraceEvent
SetUidEvent        SocketBindEvent    MountEvent
MemfdCreateEvent   ModuleLoadEvent    DupEvent
ListenEvent        MprotectEvent      CloneEvent
UnlinkEvent        RenameEvent        KillEvent
PrctlEvent         AcceptEvent

+ SyscallKind enum (discriminant for dispatch)

Every struct carries the common fields: PID, UID, cgroup_id, timestamp, and the comm (process name). Then each struct adds syscall-specific fields. An ExecveEvent has the binary path and arguments. A ConnectEvent has the destination IP and port. A MprotectEvent has the address range and protection flags. Everything is typed. Everything is known at compile time.

What to do next