Understanding eBPF
What is eBPF?
If you’ve ever wanted to peek inside a running Linux kernel without recompiling it, patching it, or crossing your fingers and loading a sketchy out-of-tree module, eBPF is probably the tool you were looking for. The name is short for “extended Berkeley Packet Filter,” which is a bit of a historical accident: the original BPF, from the late 1980s, was a tiny virtual machine built to do one job, deciding whether a network packet matched a filter (think tcpdump). It was small, fast, and deliberately dumb, which made it safe to run inside the kernel.
Over the last decade or so, Linux developers realized that the same core idea, a restricted, verifiable bytecode VM running in kernel space, could be generalized way beyond packet filtering. So the instruction set was extended (hence “extended BPF”), new registers and instructions were added, and the whole thing grew into a general-purpose framework for observing and even altering kernel behavior on the fly. Today eBPF programs can trace function calls, filter and redirect network traffic, enforce security policies, and profile performance, all without touching a single line of kernel source code or reloading a module.
The reason this matters is safety plus performance in the same package. Traditionally, if you wanted deep visibility into what the kernel was doing, you had two bad options: write a kernel module (powerful, but a crash or bug there takes down the whole machine), or rely on slow, high-overhead mechanisms like ptrace. eBPF gives you a third option: you write a small program, the kernel mathematically proves it can’t do anything dangerous before it ever runs, and then it executes at near-native speed inside the kernel itself.
Why people reach for eBPF:
- Safety. Every eBPF program passes through a verifier before the kernel will load it. The verifier walks every possible execution path the program could take and rejects anything that might dereference an invalid pointer, loop forever, or otherwise misbehave. You get kernel-level power without kernel-level blast radius.
- Performance. Once verified, the bytecode is JIT-compiled into native machine instructions for your CPU architecture and runs directly in kernel space. There’s no context switch back and forth to userspace for every event, which is exactly what makes older techniques like
ptrace-based tracing so slow in comparison. - Flexibility. The same underlying machinery powers wildly different use cases: network load balancers, intrusion detection tools, performance profilers, container security policies, and the debugging one-liners we’ll build below.
How eBPF Programs Work
An eBPF program’s life starts as C code, but not quite the C you’re used to. You write it in a deliberately restricted subset: no arbitrary function calls (only a fixed set of kernel-provided “helper functions” are allowed), no unbounded loops (until fairly recently, loops were banned outright; the verifier now tolerates provably bounded ones), and strict limits on stack size and program complexity. This isn’t an arbitrary inconvenience, it’s what makes the safety guarantee possible in the first place. If the compiler let you call any kernel function or write to any address, the verifier would have no way of proving your program can’t crash the system.
That restricted C gets compiled by LLVM/Clang, targeting a special bpf backend, into an ELF object file full of eBPF bytecode. A loader (we’ll get to the tools shortly) then reads that object file, creates any maps the program declared, and hands the bytecode to the kernel through the bpf() syscall with the BPF_PROG_LOAD command. This is the moment the verifier does its work: it builds a control flow graph of your program, tracks what every register could possibly contain at every point in the program, and refuses to load anything it can’t prove is safe. Historically the verifier capped programs at around 4096 instructions; that limit has since been raised into the hundreds of thousands for privileged loads, precisely because verification got smarter about pruning equivalent states instead of exhaustively walking every path.
Once a program clears verification, it gets JIT-compiled into native machine code (rather than being interpreted, which would defeat much of the performance benefit) and attached to a hook. From that point on, whenever the kernel hits that hook, your compiled program runs inline, in kernel context, with access only to what the verifier allowed it to touch.
eBPF Hooks
A “hook” is just a point in the kernel where execution can be diverted, briefly, into your eBPF program before continuing on its way. Which hook you choose depends entirely on what you’re trying to observe or influence, and each type comes with its own tradeoffs around stability, performance, and how invasive it is.
- Kprobes are dynamic probes that can attach to almost any exported (and non-inlined) kernel function, at entry or at return (the latter are technically “kretprobes”). Under the hood, the kernel patches a breakpoint instruction into the function’s machine code and routes execution through the
ftraceinfrastructure when it’s hit. The upside is enormous flexibility: you can trace practically any internal kernel function you want. The downside is fragility: kprobes attach to internal, non-ABI-stable symbols, so a kernel version bump that inlines, renames, or restructures a function can silently break your probe. You’re coupling your tool to kernel internals, not a public interface. - Uprobes are the userspace mirror image of kprobes: the same breakpoint-and-trap trick, but applied to a function inside a user process’s binary rather than the kernel’s. They let you trace application-level function calls (say, a specific libc or OpenSSL function) from the kernel, which is powerful for things like decrypting TLS traffic at the point of encryption, before it hits the wire.
- Tracepoints are the more stable cousin of kprobes. Instead of dynamically patching arbitrary code, tracepoints are static hooks that kernel developers deliberately compile into specific, meaningful locations using the
TRACE_EVENTmacro. Because they’re an intentional, documented part of the kernel’s tracing ABI, they’re far less likely to disappear or change shape between kernel versions. The tradeoff is coverage: you only get the events kernel developers decided to expose, not “anything with a symbol.” - XDP (eXpress Data Path) hooks run at the earliest possible point in the network receive path, before the kernel has even allocated an
sk_bufffor the incoming packet. That’s what makes XDP so blazingly fast: programs attached here can drop, redirect, or pass packets before most of the normal networking stack overhead has even happened. It’s the technology behind high-performance DDoS mitigation and load balancing at companies operating at serious scale. - cgroup-bpf hooks attach eBPF programs to a specific cgroup, letting you scope policy enforcement, syscall filtering, socket operation control, and resource accounting to a particular container or group of processes. This is a big part of how modern container security tooling enforces “this container may only make these syscalls” style policies without needing a heavier sandboxing layer.
Stitch a program to one of these hooks and you’ve got a lightweight, safely sandboxed piece of code that runs inside the kernel and can observe, or in some cases modify, real system behavior as it happens, in real time, with minimal overhead. That combination (kernel-level visibility without kernel-module-level risk) is really the whole pitch for eBPF.
Tools for Developing eBPF Hooks
LLVM/Clang
LLVM and Clang do the actual translation from your restricted C source into eBPF bytecode, targeting the special bpf backend rather than the usual x86 or ARM targets. This backend understands the constraints we talked about above (no arbitrary calls, no unbounded loops, limited stack) and encodes your program using eBPF’s own instruction set and calling convention.
clang -O2 -target bpf -c my_ebpf_program.c -o my_ebpf_program.oBCC (BPF Compiler Collection)
BCC wraps the whole compile-load-attach pipeline in a friendlier Python (or Lua) interface. You write your eBPF C snippet as a string or a small file, call BPF(src_file=...), and BCC invokes an embedded Clang/LLVM toolchain at runtime to compile and load it for you. This is fantastic for quick prototyping and one-off diagnostic scripts, which is exactly what our syscall counter below is. The tradeoff is weight: BCC ships an entire compiler toolchain as a runtime dependency, so scripts have real startup latency and the target machine needs kernel headers matching its running kernel. That portability problem is a big reason the ecosystem has been steadily shifting toward libbpf plus CO-RE for anything meant to run reliably across a fleet of different kernel versions.
bpftool
bpftool is the Swiss-army knife for introspecting whatever eBPF state is currently loaded on a machine: it can list and dump loaded programs (bpftool prog), inspect and dump map contents (bpftool map), show cgroup attachments, and even dump a program’s translated or JIT-compiled instructions so you can see exactly what the verifier and JIT actually produced from your source. When something is attached and you’re not sure what, or a program refuses to load and you want to understand why, bpftool is usually the first place to look.
libbpf
libbpf is the low-level C library underneath most of the modern eBPF tooling. Its big contribution is CO-RE (Compile Once, Run Everywhere): by leaning on BTF (BPF Type Format) debug metadata baked into the kernel, a libbpf-based program compiled on one machine can adjust its field offsets and structure layouts to match whatever kernel it actually runs on, at load time. That solves BCC’s portability headache: you compile once, ship a single binary, and it works across kernel versions without needing headers or a compiler on the target box at all.
Example: Counting System Calls with eBPF
Let’s make this concrete with something small but genuinely useful: a program that counts how many times each process invokes a given syscall, using an in-kernel hash map to keep tally.
eBPF Program: syscall_count.c
#include <uapi/linux/ptrace.h>
#include <linux/sched.h>
BPF_HASH(syscall_count, u32);
int count_syscalls(struct pt_regs *ctx) {
u32 pid = bpf_get_current_pid_tgid();
u64 *count = syscall_count.lookup(&pid);
if (!count) {
u64 init_val = 1;
syscall_count.update(&pid, &init_val);
} else {
(*count)++;
syscall_count.update(&pid, count);
}
return 0;
}BPF_HASH(syscall_count, u32) is a BCC macro that declares an in-kernel hash map keyed by a u32, with a default u64 value type. Maps are the primary way eBPF programs share state, both across invocations of the same program and with userspace, since the program itself is stateless and short-lived: it runs, does its thing, and exits every single time the hook fires. Without a map, you’d have nowhere to accumulate a running count.
bpf_get_current_pid_tgid() is worth pausing on, because it’s a classic source of confusion. It returns a 64-bit value packing two different IDs: the thread group ID (what userspace calls the “process ID”) in the upper 32 bits, and the actual per-thread ID in the lower 32 bits. This program assigns the full 64-bit return value directly into a u32 pid, which truncates it down to just the lower bits. That means what’s actually being keyed on here is the thread ID, not the process ID as the variable name implies. For a single-threaded process, thread ID and process ID are the same number, so the example still works, but the moment you point this at a multi-threaded process, you’ll see counts fragmented across threads instead of rolled up per process. If you actually want the process-level ID, you’d shift right by 32 (pid_tgid >> 32) before truncating. It’s a small thing, but it’s exactly the kind of subtlety that trips people up the first time they write kernel tracing code.
There’s a second, subtler issue worth knowing about even though it doesn’t show up in casual testing: the lookup-then-update sequence here isn’t atomic. If this probe fires concurrently on two different CPUs for the same PID (entirely possible with a hot syscall on a multi-core box), both invocations could read the same old count before either writes back the incremented value, and you’d lose an update. For a rough diagnostic script that’s rarely a big deal, but if you needed rock-solid accuracy you’d want a per-CPU map (BPF_MAP_TYPE_PERCPU_HASH) that eliminates the race entirely by giving each CPU its own private slot, aggregated only when userspace reads it out.
Compiling the eBPF Program
clang -O2 -target bpf -c syscall_count.c -o syscall_count.oAttaching the eBPF Program Using BCC (Python)
from bcc import BPF
import time
b = BPF(src_file="syscall_count.c")
b.attach_kprobe(event="__x64_sys_execve", fn_name="count_syscalls")
while True:
print("=== Syscall Counts ===")
for k, v in b["syscall_count"].items():
print(f"PID {k.value}: {v.value} syscalls")
time.sleep(5)attach_kprobe is doing the heavy lifting here: it tells the kernel to patch a breakpoint into __x64_sys_execve and route execution into count_syscalls whenever that function is entered. That symbol name is worth noting because it’s architecture- and version-specific: on x86_64 kernels with the syscall wrapper feature enabled, entry points get an arch-qualified prefix like __x64_sys_, which won’t match on other architectures or older kernels. If portability matters to you, attaching to the syscalls:sys_enter_execve tracepoint instead is generally the more robust choice, since tracepoint names are part of a stable, documented ABI rather than an internal symbol that happens to exist today.
The polling loop at the bottom is simple and works fine for a demo, but it hints at a real operational problem: this map only ever grows. Nothing here ever calls .delete() on an entry once a process exits, so if you let this run for a long time on a busy machine with a lot of process churn, you’ll accumulate stale entries for PIDs that no longer exist, forever, until the map’s fixed capacity fills up and further insertions start failing. A production-grade version of this tool would either explicitly clean up entries when it notices a process has exited, or swap BPF_HASH for an LRU-based map that automatically evicts old entries once it fills up. It’s a good reminder that eBPF maps are just fixed-size kernel memory: they don’t garbage collect themselves, so leaking key/value pairs is entirely something you can do to yourself if you’re not careful.
Debugging and Performance Tuning
bpf_trace_printk is usually the first debugging tool people reach for, since it works almost exactly like printf: sprinkle it through your program, then read the output with cat /sys/kernel/debug/tracing/trace_pipe. It’s genuinely useful for quick sanity checks, but it has real limits worth knowing before you rely on it too heavily. It only accepts three arguments and truncates format strings, and more importantly, the trace pipe is a single global, shared buffer: if anything else on the system is also writing to it (and on a busy box, something usually is), your output gets interleaved with unrelated noise, or gets dropped entirely when the ring buffer fills faster than userspace can drain it. For anything beyond quick-and-dirty debugging, streaming structured events out through a perf event array (BPF_PERF_OUTPUT in BCC) or, on newer kernels, the dedicated BPF_MAP_TYPE_RINGBUF map type is a much better fit: both are built specifically for efficiently getting structured data out of the kernel and into a userspace consumer without the noisy shared-buffer problem.
bpftool earns its keep again here: bpftool prog dump xlated shows you the verifier’s translated version of your program, and bpftool prog dump jited shows the actual native machine code the JIT produced, which is invaluable when you’re trying to understand why a program’s performance doesn’t match your expectations, or why the verifier rejected something you thought was obviously safe. bpftool map dump lets you inspect the live contents of a map directly from the command line, without writing a single line of code, which is often the fastest way to sanity-check that your program is actually populating the data you think it is.
BCC’s Python interface layers nicely on top of all this for iterative development: since maps are exposed as regular Python dict-like objects (as you saw with b["syscall_count"].items()), you can inspect, filter, and print state interactively while you’re still figuring out whether your hook logic is even correct, before you invest in building a more permanent ring-buffer-based pipeline.
Where This Cuts Both Ways
It’s worth stepping back for a second, especially given how often eBPF shows up in security tooling. Everything that makes eBPF a great platform for defenders (deep, low-overhead visibility into syscalls, network activity, and process behavior, all without a kernel module) makes it just as attractive to attackers. A malicious actor with sufficient privilege can use the exact same kprobe-on-execve-and-getdents64 pattern shown above not to log activity, but to hide it: filtering a process out of readdir results before they reach userspace, or quietly suppressing log lines and network connections from monitoring tools, all from inside the kernel where traditional userspace detection can’t see the interference happening.
That dual-use nature is exactly why unprivileged eBPF program loading is disabled by default on modern kernels (controlled by the kernel.unprivileged_bpf_disabled sysctl), and why kernel lockdown mode further restricts which hooks even a privileged process can attach to. If you’re building or evaluating security tooling that leans on eBPF, it’s worth treating “this system can load eBPF programs” as a meaningful trust boundary in its own right, not just a debugging convenience.
