Extended Berkeley Packet Filter (eBPF) has revolutionized Linux observability, networking, and security by allowing user-space applications to safely execute custom programs directly within the kernel. Tools like Cilium, Falco, and BCC rely on eBPF to function.
However, this incredible power introduces a severe security risk. If a malicious actor gains root access, they can load a weaponized eBPF program (an eBPF rootkit) to intercept system calls, hide malicious processes, or silently drop firewall packets. Because eBPF operates in kernel space, these rootkits are completely invisible to standard user-space antivirus engines and process monitors.
To defend against this, systems administrators must maintain a strict, immutable forensic log of exactly when an eBPF program is loaded, who loaded it, and what type of program it is. This is achieved by configuring the Linux Audit Daemon (auditd) to track the bpf() system call.
Understanding eBPF Loading Architecture
All eBPF programs, regardless of whether they are tracing tools, networking filters, or rootkits, must eventually call the bpf() system call to be injected into the kernel.
The bpf() system call (syscall number 321 on x86_64 architectures) takes a command argument (e.g., BPF_PROG_LOAD to load a program, or BPF_MAP_CREATE to create an eBPF memory map). By instructing the Linux kernel’s audit subsystem to monitor this specific system call, we can generate a permanent audit trail of all eBPF activity.
Step 1: Installing and Enabling auditd
If not already present on your system, install the auditd package. It is standard on most enterprise Linux distributions.
On Ubuntu/Debian:
sudo apt update
sudo apt install auditd audispd-plugins
On RHEL/AlmaLinux:
sudo dnf install audit
Ensure the service is running and enabled at boot:
sudo systemctl enable auditd
sudo systemctl start auditd
Step 2: Crafting the auditd Rule for the bpf() Syscall
We need to create a custom audit rule that specifically targets syscall 321 (bpf) on 64-bit architectures. We only want to log successful executions of the syscall (where an eBPF program was actually loaded or modified), not failures.
Create a new rule file in the audit configuration directory:
sudo nano /etc/audit/rules.d/99-ebpf-monitor.rules
Add the following rules:
# Monitor the bpf() syscall (syscall 321 on x86_64)
-a always,exit -F arch=b64 -S bpf -F success=1 -F key=ebpf_activity
-a always,exit -F arch=b32 -S bpf -F success=1 -F key=ebpf_activity
Explanation of the rule:
-a always,exit: Log the event when the system call exits (so we know if it was successful).-F arch=b64: Apply to 64-bit architecture.-S bpf: Monitor thebpfsystem call.-F success=1: Only log if the kernel accepted and successfully executed the system call.-F key=ebpf_activity: A custom tag applied to the log entry, making it easy to search for later.
Apply the rules immediately without rebooting:
sudo augenrules --load
Step 3: Triggering and Analyzing an eBPF Audit Event
To test the rule, we can run a benign eBPF tool. If you have the bcc-tools package installed, running the execsnoop-bpfcc script will load several eBPF programs to trace process executions.
sudo execsnoop-bpfcc
Let it run for a few seconds, then cancel it (Ctrl+C). Now, query the audit daemon logs for the custom key we defined in Step 2.
sudo ausearch -k ebpf_activity
The output will contain deeply detailed forensic telemetry. You will see several SYSCALL records:
time->Fri Oct 25 14:02:11 2024
type=SYSCALL msg=audit(1698242531.123:442): arch=c000003e syscall=321 success=yes exit=4 a0=5 a1=7ffe1234abcd a2=70 a3=0 items=0 ppid=4192 pid=4193 auid=1000 uid=0 gid=0 euid=0 suid=0 fsuid=0 egid=0 sgid=0 fsgid=0 tty=pts0 ses=3 comm="python3" exe="/usr/bin/python3.10" key="ebpf_activity"
Critical Forensic Indicators:
syscall=321: Confirms it was thebpf()syscall.success=yes exit=4: The program was successfully loaded, and the kernel assigned it File Descriptor 4.auid=1000: The Audit User ID. Even though the command was run asroot(uid=0), theauidproves that it was the standard user with ID 1000 who usedsudoto execute the command. This is vital for attributing malicious activity to a compromised user account.exe="/usr/bin/python3.10": The exact binary that initiated the eBPF load. (BCC tools are Python wrappers around C code).
Step 4: Hardening the Kernel Against Unprivileged eBPF
While auditd logs the activity, you should also ensure that unprivileged users cannot load eBPF programs in the first place. By default, modern kernels restrict eBPF to root (or users with CAP_BPF or CAP_SYS_ADMIN).
Verify that unprivileged eBPF is disabled using sysctl:
sysctl kernel.unprivileged_bpf_disabled
If this returns 0 or 1 (where 1 means disabled but can be re-enabled), you should enforce strict lockdown. Edit /etc/sysctl.d/99-bpf.conf:
kernel.unprivileged_bpf_disabled=2
A value of 2 permanently disables unprivileged eBPF until the next system reboot, preventing malicious user-space applications from attempting to load simple eBPF probes.
Conclusion
As eBPF adoption grows, so does the prevalence of eBPF-based malware and rootkits. By implementing strict auditd rules targeting the bpf() system call, security teams gain immutable, kernel-level visibility into exactly which processes are interacting with the eBPF subsystem, ensuring that malicious kernel manipulation cannot occur in the dark.