Chapter 15: Security - The eBPF Attack Surface
“eBPF is the safest way to run code in the kernel, and the most dangerous thing you can attach to the kernel if you are wrong about who controls it. Both sentences are true, and the security model is the difference between them.”
Every chapter has assumed a trusted operator loading trusted programs. This
chapter examines what that trust means: the capabilities and lockdown
mechanisms that gate bpf(), the unprivileged path and its history, what
an attacker with a loaded program can and cannot do, and how eBPF is
itself the defense - the runtime-security agents (Falco, Tetragon, the
Cilium security stack) that watch the kernel from inside. The eBPF security
model is the verifier of Chapter 2 plus the capability system of the
kernel, and this chapter is where you learn to reason about both at once.
15.1 The Privilege Model: Capabilities, Not Root
Loading eBPF programs and creating maps requires capabilities - the kernel’s fine-grained privilege tokens - not blanket root. The relevant set, on modern kernels:
| Capability | Grants | Notes |
|---|---|---|
CAP_BPF | load programs, create maps, most attach points | the modern catch-all (5.8+) |
CAP_PERFMON | tracing programs (kprobes, tracepoints), perf events | observability privilege |
CAP_NET_ADMIN | attach to network hooks (XDP, tc, sockmap, cgroup) | the networking data path |
CAP_SYS_ADMIN | legacy fallback (pre-5.8) for the above | avoid in new designs |
The consequence for deployment is the one Chapter 14 stated: the loader needs the capabilities; the runtime (after attach) does not. Production systems run the loader in a privileged init container, drop capabilities after load where possible, and never give the data path’s consumers more than they need. This is the same least-privilege discipline as any systems service - eBPF just makes the boundary explicit and inspectable.
15.2 Unprivileged BPF: The Attack Surface That Was
Older kernels allowed unprivileged users to load a restricted class of BPF
programs (socket filters, and later a few more) - the legacy of the 1992
Berkeley Packet Filter, which was designed for exactly that. The history
since is a list of CVEs: Spectre-class side channels through the verifier’s
value tracking (CVE-2017-16995 was the famous one), JIT bugs, map
accounting bugs. The kernel’s response was layered:
kernel.unprivileged_bpf_disabled(andsysctlhardening): the unprivileged path is off by default on virtually all distributions.- The verifier hardened repeatedly: speculation barriers, precise value tracking, the “speculative execution” mitigations that made the Spectre class expensive to exploit.
- Lockdown mode (
lockdown=confidentiality):bpf()is gated entirely when the kernel is in lockdown, closing the last paths.
The lesson for the systems engineer is not the CVE list; it is the default: assume unprivileged BPF is unavailable, design the loader to need capabilities, and treat “can this user load BPF?” as a security question on the same level as “can this user load a kernel module?” - the answer is checked by the same trust boundary.
15.3 What a Loaded Program Can Do: The Trust Model, Precisely
A loaded eBPF program is kernel code - but kernel code constrained by the verifier. The precise trust model:
- Can: read and write its maps (bounded), call the approved helpers, read the packet/skb it was given, inspect kernel memory only through BTF-typed read access in tracing programs (bounded by the verifier’s provenance checks).
- Cannot: call arbitrary kernel functions, dereference arbitrary pointers, read/write kernel memory outside its maps and context, loop unboundedly, or escape into kernel control flow.
The honest caveat: “cannot” is “the verifier proved it cannot, as of this kernel version”. The verifier is the attack surface - which is why its bugs are kernel security bugs, why the subsystem is one of the most audited in the kernel, and why your own programs should be written to reduce the verifier’s work (simple shapes, no pointer games). A program that needs an exotic verifier feature is a program that should be rewritten - for security reasons, not just style.
The second half of the trust model is what the program’s operator can do, and that is the deployment question: a program attached to the right hooks with the right maps can see every packet, every socket operation, every syscall of every process. Runtime-security agents exist precisely to deploy that power as defense.
15.4 eBPF as Defense: The Runtime Security Stack
The flip side of the attack surface is the defensive capability: eBPF programs attached to syscall tracepoints and cgroup hooks can watch everything without modifying the target processes - no ptrace, no injection, no performance cliff. This is the architecture of the modern runtime security agents:
- Falco (the pioneer): kprobes/tracepoints on syscalls and container lifecycle events, rules evaluated in userspace, alerts on suspicious behaviour - the observability pattern of Chapter 7, productised as detection.
- Tetragon (Cilium’s security agent): policy enforcement in the kernel - the policy map of Chapter 12 applied to process execution and file access, with the enforcement happening in the BPF program itself, not after a userspace round trip.
- The Cilium security stack: the network policies of Chapter 12 (identity-based), plus the Hubble flow log as the audit trail.
The pattern is the book’s pattern one more time: the kernel produces
events; the control plane decides; the data path enforces. The
difference is the subject: instead of packets, the subject is processes
and syscalls - the cgroup hooks of Chapter 9 and the tracepoints of
Chapter 7, applied to execve, open, connect, write.
Source: code/ch15_security/kernel/src/main.rs
#![allow(unused)]
fn main() {
// code/ch15_security/kernel/src/main.rs
// A minimal runtime-security probe: stream execve events to the ring.
// The Falco/Tetragon shape in miniature - kernel produces, userspace
// decides (Ch.15.4).
#![no_std]
#![no_main]
use aya_bpf::{
macros::{map, tracepoint},
maps::RingBuf,
programs::TracePointContext,
};
const PID_OFF: usize = 0; // sched/sched_process_exec argument block
const COMM_OFF: usize = 16;
#[repr(C)]
pub struct ExecEvent {
pub pid: u32, // pid of the exec'ing process
pub uid: u32, // user id (filled by the real agent from the task)
pub comm: [u8; 16], // process name
}
#[map]
static EXECS: RingBuf = RingBuf::with_max_entries(8192, 0);
#[tracepoint]
pub fn sched_process_exec(ctx: TracePointContext) -> i32 {
let Ok(ev) = try_exec(&ctx) else {
return 0; // truncated tracepoint block: skip
};
let Ok(entry) = EXECS.reserve::<ExecEvent>(0) else {
return 0; // ring full: drop the event
};
unsafe { entry.write(ev) };
entry.submit(0);
0
}
fn try_exec(ctx: &TracePointContext) -> Result<ExecEvent, i32> {
let pid: u32 = unsafe { ctx.read_at(PID_OFF)? };
let comm: [u8; 16] = unsafe { ctx.read_at(COMM_OFF)? };
Ok(ExecEvent { pid, uid: 0, comm })
}
#[panic_handler]
fn panic(_: &core::panic::PanicInfo) -> ! {
unsafe { core::hint::unreachable_unchecked() }
}
}
The security property that makes this defense rather than surveillance: the program is read-only, bounded, and attached by a trusted agent whose policy decides what the stream means. The same program shape in the wrong hands is espionage; in the right hands it is the runtime firewall of the next decade. The difference is the trust boundary - capabilities, policy, and audit - which is exactly what Sections 15.1-15.3 described.
15.5 The Audit Trail: eBPF Programs as Evidence
One more property completes the security picture: eBPF is inspectable.
bpftool prog list, the pinned programs under /sys/fs/bpf/, and the
BTF info mean that a machine’s loaded programs are enumerable - the
forensic question “what eBPF is running on this host?” has an answer, and
it is a bpftool one-liner. Security systems built on eBPF should
therefore ship their own identity: a pinned program, a version in a map,
an audit log in the ring. When the capstone (Chapter 16) deploys its
programs, it will pin them under /sys/fs/bpf - making them visible,
named, and accountable, the way production eBPF should be.
Hands-On Lab
# 1. Load the exec probe and stream events.
cd code/ch15_security/kernel && cargo build --release --target bpfel-unknown-none
sudo bpftool prog loadall <elf> /sys/fs/bpf/sec 2>&1 || echo "need CAP_BPF/CAP_PERFMON"
# (or attach through the userspace loader of Chapter 3)
# 2. The privilege story, made concrete.
cat /proc/sys/kernel/unprivileged_bpf_disabled # 2 = locked down
capsh --print | grep cap_bpf # what the loader holds
# 3. The audit story: eBPF is inspectable by design.
sudo bpftool prog list | head
ls /sys/fs/bpf/ # pinned programs ship their identity
The security model is two halves: the verifier proves what a program CANNOT do (Ch.2), and capabilities decide WHO loads it (Ch.15.1). Both halves are checkable on a live host with the commands above.
Summary
- eBPF loading is gated by capabilities, not root:
CAP_BPF,CAP_PERFMON,CAP_NET_ADMIN; the loader needs them, the runtime does not. - Unprivileged BPF is the historical attack surface: disabled by default, hardened verifier, lockdown mode - assume it is unavailable.
- A loaded program is kernel code constrained by the verifier: maps and helpers yes, arbitrary memory no - and the verifier itself is the attack surface, which is why simple program shapes are a security property.
- eBPF as defense: Falco and Tetragon watch syscalls and enforce policy in the kernel - the book’s pattern (kernel produces, userspace decides, data path enforces) applied to processes.
- eBPF is inspectable by design:
bpftoolenumerates loaded programs; production deployments pin and name them for audit.
Next: Part VI. Chapter 16 assembles everything - the capstone service-mesh data path in Rust, running against a real cluster.