$RodHat_
MOTD

The eBPF verifier is 30,000 lines of C, and it has the privilege-escalation CVEs to show for it

Published by

The eBPF verifier is 30,000 lines of C, and it has the privilege-escalation CVEs to show for it
Photo: AI-generated — no human photographer / RodHat AI Cover

eBPF programs run in the kernel. That sentence is doing a lot of work, and a fair number of the people deploying Cilium or Datadog or Falco in their clusters have not sat with it long enough.

The original BPF, Berkeley Packet Filter, went into the kernel in 1997 as a way to express packet filters the kernel could evaluate in place, without copying packets to userspace. tcpdump(1) is the interface most people recognize. The filter language was intentionally tiny because tiny meant provably safe: bounded loops, no pointer arithmetic, a simple static analysis pass that could verify halting in linear time.

Extended BPF, merged in Linux 3.18 in 2014, kept the name and replaced nearly everything else. It added general-purpose registers, a 512-byte stack, 64-bit arithmetic, a proper verifier, and a map system for sharing data between kernel and userspace. Over the following decade it picked up more than a dozen program types: socket filters, XDP, tc egress and ingress, kprobes, tracepoints, LSM hooks, cgroup BPF, perf events. It can attach to almost any point in the kernel. It is also now JIT-compiled to native code on x86, arm64, and most other supported architectures.

The verifier’s job is to make this safe. When you call bpf(2) with BPF_PROG_LOAD, the verifier runs the program symbolically before the kernel accepts it. It tracks register types and value ranges, checks every memory access against provable bounds, and rejects any program that could loop without a bound or touch kernel memory outside permitted ranges. If the verifier accepts the program, the JIT compiles it and the kernel runs it. If the verifier rejects it, you get EPERM and the program never executes.

The verifier is roughly 30,000 lines of C in kernel/bpf/verifier.c.

That is one of the largest single files in the kernel tree. The analysis it performs is conservative but not complete: conservative means false positives, programs the verifier rejects that would have been safe. Not complete means false negatives, programs the verifier accepts that are not. The false negatives have a CVE list.

What the bugs look like

The verifier has to reason about value ranges for register contents as they flow through arithmetic. If a register holds an offset into a BPF map value, the verifier needs to prove that offset + access_size <= map_value_size at every point the register is used. Getting the range narrowing logic right across all combinations of arithmetic operations, conditional branches, and signed versus unsigned comparisons has been wrong in multiple ways.

The pattern that has appeared most often: the verifier proves a register is in range X at one point in the program flow, the JIT compiles the instruction assuming that range, and a carefully constructed input can produce a state where the JIT’s assumption holds but the proven range did not actually constrain the register’s value in the way the verifier believed. Out-of-bounds kernel memory access follows. The access is at kernel privilege because that is where the program runs.

Pointer type confusion is a separate category. The verifier tracks whether a register holds a pointer to kernel memory, a pointer to a map value, a pointer to the BPF stack, a scalar, or some other type. Getting that typing wrong means an unprivileged program can construct what looks to the verifier like a harmless scalar operation and produce a kernel pointer it can dereference arbitrarily.

Speculative execution adds a third category. The same class of attacks that produced Spectre in 2018 applies to the JIT-compiled eBPF programs because they run speculatively with the rest of the kernel. The kernel added speculative masking in the JIT backends to limit what speculative paths can access, but the mitigation adds overhead and the correctness argument is not trivial.

Every one of these vulnerability classes is a local privilege escalation from any unprivileged user who can call bpf(2). That is the constraint. The verifier is not broken in the sense that the model is wrong. The implementation is hard enough that it has been wrong repeatedly, and it is growing in complexity with every new program type and helper the kernel adds.

What production distributions have done

The enterprise distros disabled unprivileged BPF in their kernel builds. Debian added kernel.unprivileged_bpf_disabled = 1 to their default sysctl configuration. Ubuntu has shipped with the sysctl set to 1 for years. The kernel itself added CONFIG_BPF_UNPRIV_DEFAULT_OFF as a compile-time option in Linux 5.10, so distributions can make the restriction a build default rather than a runtime afterthought.

The sysctl has three values. Value 0 is the original behavior: any process can load BPF programs. Value 1 restricts BPF_PROG_LOAD to processes with CAP_BPF (added in Linux 5.8 to split off BPF privilege from the old CAP_SYS_ADMIN requirement) or CAP_SYS_ADMIN. Value 2 disables BPF program loading entirely.

Most production distributions ship value 1. Check yours:

sysctl kernel.unprivileged_bpf_disabled

If it returns 0 and you cannot name a process that specifically needs unprivileged BPF access, set it:

sysctl -w kernel.unprivileged_bpf_disabled=1
echo 'kernel.unprivileged_bpf_disabled = 1' >> /etc/sysctl.d/99-bpf-harden.conf

The production tooling is not affected

Every production eBPF tool that you would actually deploy on a server runs as root or acquires CAP_BPF explicitly. Cilium’s operator and agent run with the required capabilities. Falco’s kernel module and eBPF probe both require privilege. The Datadog agent acquires CAP_SYS_PTRACE and CAP_BPF at startup. bpftrace used from a terminal requires root or CAP_BPF.

Setting kernel.unprivileged_bpf_disabled=1 does not break any of them. It prevents a local user without capabilities from running bpftrace or loading a custom eBPF program. On a production server, that is the correct behavior. Root and the right capability are not unreasonable requirements for software that compiles code and executes it in the kernel.

The useful parts remain useful

eBPF is real infrastructure. XDP at the driver level can drop and forward packets faster than any iptables chain you will ever write, because it runs before the kernel networking stack allocates an sk_buff. The seccomp-bpf syscall filtering reference covers the older classic BPF subset used in seccomp filters, which has a completely different and much simpler verifier, and has not had this class of problem. The kernel observability you can get from bpftrace and perf-BPF programs is genuinely better than what was possible before 2014.

None of that changes the calculus on unprivileged access. Requiring a capability to execute code in the kernel is not an unreasonable restriction. The surprise is that value 0 was the upstream default for as long as it was.

The pattern is consistent

Two days ago the io_uring restriction post covered what happens when a powerful kernel interface accumulates a security track record: production fleets restrict it, distributions change the default, and the kernel adds a gate. Yesterday’s Landlock post was the mirror image: a kernel security interface that nobody adopted because adoption required per-project effort with no central forcing function.

eBPF sits in the io_uring column. It got adopted because it is useful and performance-relevant. It earned the restriction because the verifier is doing something genuinely hard and has gotten it wrong enough times that the default should not be permissive. The tools that need it still work. The CVE surface for local users who should not have kernel code execution is gone.

Check the sysctl. Set it to 1 if it is not already. Then actually look at which processes on your system are loading BPF programs and whether the programs they load at startup are the programs they are loading in month six of a production deployment.

Sources

  1. bpf(2) Linux man page (Michael Kerrisk / man7.org)
  2. Linux kernel BPF documentation (Linux kernel project)