Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

CapabilityGrantsNotes
CAP_BPFload programs, create maps, most attach pointsthe modern catch-all (5.8+)
CAP_PERFMONtracing programs (kprobes, tracepoints), perf eventsobservability privilege
CAP_NET_ADMINattach to network hooks (XDP, tc, sockmap, cgroup)the networking data path
CAP_SYS_ADMINlegacy fallback (pre-5.8) for the aboveavoid 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:

  1. kernel.unprivileged_bpf_disabled (and sysctl hardening): the unprivileged path is off by default on virtually all distributions.
  2. The verifier hardened repeatedly: speculation barriers, precise value tracking, the “speculative execution” mitigations that made the Spectre class expensive to exploit.
  3. 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: bpftool enumerates 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.