Advanced

eBPF: Kernel-Level Security Monitoring and the Attack Surface It Creates

A technical deep dive into eBPF for security engineers: syscall tracing with kprobes and tracepoints, XDP packet filtering, how Falco and Tetragon use eBPF, and how to detect and defend against malicious eBPF programs like BPFdoor.

Security monitoring has a fundamental problem: the deeper in the stack you instrument, the better your visibility — but traditionally, instrumenting the kernel required either writing kernel modules (one bug causes a kernel panic) or accepting the overhead and limitations of user-space tools that processes can work around.

eBPF (Extended Berkeley Packet Filter) resolves this by allowing safe, sandboxed code to run in the kernel without loading kernel modules. It’s the technology behind Cilium, Falco, Tetragon, and a significant portion of modern cloud-native security tooling. It’s also a capability that, if abused by an attacker with sufficient privileges, provides deeply powerful persistence and evasion options.

This article covers how eBPF works, how to use it for security monitoring, how production tools like Falco and Tetragon implement it, what malicious use looks like (specifically BPFdoor), and how to audit and restrict eBPF usage in your environment.


How eBPF Works

eBPF programs are written in a restricted subset of C, compiled to eBPF bytecode using LLVM/Clang, and loaded into the kernel via the bpf() syscall. Before execution, the kernel’s eBPF Verifier performs static analysis to guarantee safety:

  • No null pointer dereferences or out-of-bounds memory access
  • The program terminates (loops must have verifiable bounds, supported since kernel 5.3)
  • Memory access is restricted to authorized locations and types

After verification, the kernel’s JIT compiler converts eBPF bytecode to native machine code (x86_64, ARM64). The program is then attached to a hook point — a specific location in the kernel where it executes whenever that code path is reached.

Modern eBPF development uses CO-RE (Compile Once, Run Everywhere) via BTF (BPF Type Format), which allows a single compiled eBPF binary to work across kernel versions without recompilation. The libbpf library provides the user-space API for loading and managing programs. Tools like bpftrace provide a high-level scripting interface for quick kernel instrumentation without writing a full C program.

Key hook types:

Hook TypeWhat it InstrumentsSecurity Use Case
kprobe / kretprobeEntry/exit of any kernel functionSyscall argument inspection
tracepointStable kernel trace eventsProcess creation, network events
uprobeUser-space function entryLibrary-level monitoring
XDPNIC driver — before sk_buff allocationDDoS mitigation, packet filtering
TC (Traffic Control)After sk_buff, ingress/egressNetwork policy enforcement
LSM hooksLinux Security Module integrationPolicy enforcement at security boundaries

Syscall Tracing for Threat Detection

Everything a process does that affects the system requires a syscall. File access, process creation, network connections — all of these go through the kernel. An eBPF program attached to the right tracepoint sees every one of these operations before or as they complete.

The following traces execve — the syscall used to launch processes. This is the signal for detecting shells spawned from unexpected parent processes, a common indicator of exploitation:

trace_execve.bpf.c
#include <linux/bpf.h>
#include <linux/ptrace.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
struct event {
u32 pid;
u32 ppid;
char comm[16]; /* current process name */
char parent_comm[16]; /* parent process name */
};
/* Perf event array: kernel → user-space ring buffer */
struct {
__uint(type, BPF_MAP_TYPE_PERF_EVENT_ARRAY);
__uint(max_entries, 256);
} events SEC(".maps");
SEC("tracepoint/syscalls/sys_enter_execve")
int trace_execve(struct trace_event_raw_sys_enter *ctx)
{
struct event evt = {};
struct task_struct *task;
evt.pid = bpf_get_current_pid_tgid() >> 32;
bpf_get_current_comm(&evt.comm, sizeof(evt.comm));
/* Get parent process information */
task = (struct task_struct *)bpf_get_current_task();
evt.ppid = BPF_CORE_READ(task, real_parent, tgid);
BPF_CORE_READ_STR_INTO(&evt.parent_comm, task, real_parent, comm);
bpf_perf_event_output(ctx, &events, BPF_F_CURRENT_CPU,
&evt, sizeof(evt));
return 0;
}
char _license[] SEC("license") = "GPL";

The user-space component reads from the perf buffer and applies detection logic:

execve_monitor.py
from bcc import BPF
import ctypes
SUSPICIOUS_PARENTS = {"nginx", "apache2", "node", "python3", "java"}
SHELL_NAMES = {"sh", "bash", "zsh", "dash"}
class Event(ctypes.Structure):
_fields_ = [
("pid", ctypes.c_uint32),
("ppid", ctypes.c_uint32),
("comm", ctypes.c_char * 16),
("parent_comm", ctypes.c_char * 16),
]
b = BPF(src_file="trace_execve.bpf.c")
def handle_event(cpu, data, size):
evt = ctypes.cast(data, ctypes.POINTER(Event)).contents
comm = evt.comm.decode(errors='replace').strip('\x00')
parent = evt.parent_comm.decode(errors='replace').strip('\x00')
# Alert: shell spawned from web/app process — likely RCE
if comm in SHELL_NAMES and parent in SUSPICIOUS_PARENTS:
print(f"[ALERT] Shell spawned from {parent} (ppid={evt.ppid}) "
f"→ {comm} (pid={evt.pid})")
b["events"].open_perf_buffer(handle_event)
print("Monitoring execve events... Ctrl-C to stop")
while True:
b.perf_buffer_poll()

Because the hook runs in kernel context, it can’t be bypassed by statically compiled binaries, memory-only payloads, or LD_PRELOAD tricks. Every process creation goes through execve, which goes through this hook.


XDP: Packet Filtering Before the Kernel Stack

Standard iptables/netfilter operate after the kernel has allocated a socket buffer (sk_buff) and parsed the packet headers — significant overhead per packet. Under a volumetric DDoS attack, this overhead can exhaust CPU before any drop decision is made.

XDP (eXpress Data Path) hooks execute directly in the NIC driver, before sk_buff allocation. An XDP program that returns XDP_DROP discards the packet immediately, consuming minimal resources. Benchmarks consistently show XDP dropping tens of millions of packets per second on commodity hardware — comparable to dedicated hardware firewalls.

xdp_drop_syn.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/tcp.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>
/* Drop TCP SYN packets from a specific source IP (network byte order) */
#define BLOCK_SRC_IP 0x0100000A /* 10.0.0.1 in network byte order */
SEC("xdp")
int xdp_syn_filter(struct xdp_md *ctx)
{
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) return XDP_PASS;
if (bpf_ntohs(eth->h_proto) != ETH_P_IP) return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end) return XDP_PASS;
if (ip->protocol != IPPROTO_TCP) return XDP_PASS;
struct tcphdr *tcp = (void *)ip + (ip->ihl * 4);
if ((void *)(tcp + 1) > data_end) return XDP_PASS;
if (ip->saddr == BLOCK_SRC_IP && tcp->syn && !tcp->ack)
return XDP_DROP;
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";

Load with: ip link set dev eth0 xdp obj xdp_drop_syn.o sec xdp

For production use, Cilium provides a higher-level abstraction over XDP and TC hooks, managing network policy enforcement across Kubernetes pods without requiring custom XDP code per deployment.


Falco and Tetragon: Production eBPF Security

Falco

Falco (CNCF project, originated at Sysdig) deploys an eBPF probe that hooks into kernel syscalls and events, feeding data to a user-space rules engine. Detection works asynchronously: the eBPF program captures event telemetry, sends it to user space via a ring buffer, and the rules engine evaluates it against YAML rules.

The asynchronous model means the malicious action has occurred by the time the alert fires — Falco is a detection tool, not a prevention tool. But it provides rich contextual telemetry (container name, pod, namespace, command, parent process, network connection details) that maps directly to investigation workflows.

Key Falco rules for common attack patterns:

# Shell spawned in container (RCE indicator)
- rule: Terminal shell in container
condition: >
spawned_process and container and
shell_procs and proc.tty != 0
output: >
Shell in container (user=%user.name container=%container.name
image=%container.image.repository command=%proc.cmdline)
priority: WARNING
# Reading sensitive files
- rule: Read sensitive file by non-trusted program
condition: >
open_read and sensitive_files and
not proc.name in (trusted_file_readers)
output: >
Sensitive file read (user=%user.name command=%proc.cmdline
file=%fd.name container=%container.name)
priority: ERROR
# Unexpected outbound network connection
- rule: Unexpected outbound connection
condition: >
outbound and container and
not proc.name in (allowed_network_procs) and
not fd.sport in (80, 443)
output: >
Unexpected connection (command=%proc.cmdline
connection=%fd.name container=%container.name)
priority: WARNING

Tetragon

Tetragon (Cilium/Isovalent) goes beyond detection to in-kernel enforcement. Using bpf_send_signal(), a Tetragon policy can terminate a process from within the kernel’s execution path — before the syscall completes. This is the key distinction: Falco observes after the fact; Tetragon can prevent.

# Kill any process that reads private keys outside of expected services
apiVersion: cilium.io/v1alpha1
kind: TracingPolicy
metadata:
name: block-private-key-access
spec:
kprobes:
- call: "security_file_open"
syscall: false
args:
- index: 0
type: "file"
selectors:
- matchArgs:
- index: 0
operator: "Postfix"
values:
- ".pem"
- "id_rsa"
- "id_ecdsa"
matchNamespaces:
- operator: NotIn
values:
- "cert-manager" # cert-manager legitimately handles keys
matchActions:
- action: Sigkill

eBPF as an Attack Surface: BPFdoor and Detection

eBPF’s kernel-level access makes it attractive not just for defenders but for attackers who gain sufficient privileges. The real-world example is BPFdoor, a Linux backdoor attributed to a Chinese APT group (tracked as Red Menshen), publicly documented by Sandfly Security and PricewaterhouseCoopers in 2022.

BPFdoor’s mechanism: instead of opening a listening port (visible to netstat and port scanners), it attaches a raw socket or XDP filter to inspect every inbound packet. When a packet containing a specific sequence is detected, the filter triggers a backdoor connection — without any open port appearing in the network stack. The server appears to have no unusual network listeners.

Detection approach:

BPFdoor and similar eBPF-based implants are detectable through eBPF program auditing:

Terminal window
# List all loaded eBPF programs with their type and attach points
bpftool prog list
# Show detailed info including bytecode
bpftool prog dump xlated id <ID>
# List all eBPF maps (used for data passing between kernel and user space)
bpftool map list
# Show which programs are attached to network interfaces
bpftool net list

Any unexpected eBPF programs — especially those of type xdp, socket_filter, or raw_tracepoint loaded by unusual processes — warrant immediate investigation. Baseline what eBPF programs are normally loaded on your systems (typically: Cilium, Falco, monitoring agents) and alert on deviations.

Capability control:

Loading eBPF programs requires either CAP_SYS_ADMIN (broad kernel admin capability) or CAP_BPF (introduced in kernel 5.8, scoped specifically to eBPF). CAP_NET_ADMIN is additionally required for network hook types. Audit which processes run with these capabilities:

Terminal window
# Find processes with CAP_SYS_ADMIN or CAP_BPF
ps aux | while read line; do
pid=$(echo $line | awk '{print $2}')
caps=$(cat /proc/$pid/status 2>/dev/null | grep CapEff)
echo "PID $pid: $caps"
done | grep -v "CapEff: 0000000000000000"
# More targeted: check for eBPF-relevant capabilities
getpcaps <pid>

Restrict CAP_SYS_ADMIN aggressively. In Kubernetes:

securityContext:
capabilities:
drop:
- ALL
add:
- NET_BIND_SERVICE # only what's needed
# Never grant CAP_SYS_ADMIN or CAP_BPF to workload containers

Kernel lockdown mode: Linux kernel 5.4+ supports lockdown mode (kernel.lockdown=integrity or confidentiality in kernel command line), which prevents loading unsigned kernel modules and restricts raw memory access. This doesn’t block eBPF directly but limits the combination attacks (eBPF + kernel module loading) that sophisticated implants use.


Practical Starting Point

For a team looking to operationalize eBPF-based security monitoring:

  1. Deploy Falco for behavioral detection across containers and hosts. Start with the community rules, tune to reduce false positives in your environment, and route alerts to your SIEM.

  2. Add Tetragon for policy enforcement on critical workloads — file integrity on sensitive paths, kill-on-exec for unexpected shells in production pods.

  3. Use bpftool as part of your incident response toolkit. Unusual eBPF programs are an indicator of sophisticated compromise — include eBPF program listing in your standard IR runbook.

  4. Audit eBPF capabilities in your container security policies. No production workload container should have CAP_BPF or CAP_SYS_ADMIN unless it’s explicitly a security tooling container (Falco, Tetragon, Cilium).

  5. Use bpftrace for ad-hoc investigation. Single-line queries like bpftrace -e 'tracepoint:syscalls:sys_enter_execve { printf("%s %s\n", comm, str(args->filename)); }' give immediate visibility without writing a full eBPF program.

The security value of eBPF comes from its position in the kernel — it sees everything, before user-space tools can interfere. The risk is the same position in the wrong hands. Controlling who can load eBPF programs is as important as using eBPF for monitoring.

V

Author

Varkin Academy

Tags

#eBPF #linux kernel #cyber security #networking #Falco #Tetragon #XDP #threat detection