The BPF token: delegating BPF into containers
Loading BPF has always demanded capabilities in the init namespace — all or nothing for a container. The BPF token (kernel 6.9) breaks that open: a privileged runtime mounts a bpffs with four delegation axes, a trusted unprivileged container derives a token from it, and the kernel checks the token instead of init-namespace capabilities. Learn the capability problem it solves, the delegation model, and where it plugs into Aya.
There’s a question this book has quietly assumed away in every chapter: the
loader runs as root. Each demo.sh deploys a binary to the lab VM and runs it
with sudo, because loading a program and creating maps needs BPF
capabilities — and those capabilities live in the init namespace. That
assumption falls apart the moment you want to run an eBPF tool inside an
unprivileged container: an observability agent in a pod, a sidecar that wants
to attach a socket filter. Until kernel 6.9 the only answers were bad ones. The
BPF token is the kernel’s real answer, and it’s the natural bridge from
this part into operating eBPF: a way to hand a container exactly the BPF
powers it needs and nothing more.
The code is in examples/53-bpf-token/. ./demo.sh mounts a delegated bpffs
and shows the delegation policy the kernel will enforce; its README.md has
the details.
The capability problem
When eBPF arrived it required CAP_SYS_ADMIN — an enormous, do-anything
capability. Kernel 5.8 split out CAP_BPF for finer control, but loading
real programs still needs more: tracing programs (kprobe, tracepoint) want
CAP_BPF + CAP_PERFMON; networking programs (XDP, TC) want CAP_BPF +
CAP_NET_ADMIN. These are meaningful only in the init user namespace.
Now put that in a container. A container typically runs in its own user
namespace, where it may look like it has capabilities — but those caps are
namespaced, and BPF affects the whole machine: a program loaded in a pod sees
every packet and every task on the host, not just the pod’s. There is, as the
kernel maintainers put it, no way to build a “mechanically verifiable
namespace-aware CAP_BPF” — a capability that means “BPF, but only for your
container,” because BPF isn’t scoped to a namespace. So the kernel can’t simply
honor CAP_BPF inside a userns.
That left two options before 6.9, both bad:
- Give the container real init-namespace privilege (run it privileged, map its root to host root). Now a compromised container owns the host.
- Give it nothing, and run all your eBPF tooling as privileged host agents — losing the isolation containers exist to provide.
What the token delegates
The BPF token replaces that binary choice with delegation. The model has two parties and a deliberately narrow channel between them:
A privileged runtime in the init namespace (a container manager like systemd, LXD/Incus, or a Kubernetes runtime) mounts a special, userns-bound bpffs instance, configured with four delegation axes as mount options:
delegate_cmds— whichbpf()commands are allowed (e.g.prog_load:map_create:btf_load:link_create).delegate_maps— which map types may be created (e.g.ringbuf:hash:array).delegate_progs— which program types may be loaded (e.g.socket_filter:xdp).delegate_attachs— which attach types are allowed (e.g.cgroup_inet_ingress).
Each axis is a bitmask; a bit that isn’t set is denied even with a token in
hand, and any opts into all of an axis. This is the security policy — the
host decides, per container, exactly which slice of the BPF subsystem is on the
table.
Inside the container, a trusted unprivileged app opens that bpffs and calls
bpf(BPF_TOKEN_CREATE) to derive a token — a file descriptor that
carries the delegated permission set. From then on, when the app issues
bpf(BPF_PROG_LOAD), bpf(BPF_MAP_CREATE), and friends, it passes the
token fd in the call. The kernel performs its permission check against
the token, not against the process’s init-namespace capabilities. The app can
load programs and create maps — but only the types that were delegated.
Two properties make this safe rather than a privilege-escalation hole:
- It’s bounded to the user namespace. Both the bpffs instance and the tokens derived from it are bound to their owning userns, so a token can’t escape the container it was minted for.
- Trust is explicit, not assumed. As the maintainers stress, this is not
unconditional unprivileged BPF —
CAP_BPFis still checked in the container’s own userns at token-creation time, and the host only delegates to containers it has chosen to trust. The kernel provides the mechanism; the surrounding infrastructure decides who is trusted and with what.
How runtimes wire it up
The raw setup is genuinely fiddly — fsopen/fsconfig with
FSCONFIG_CMD_CREATE, fsmount, passing the mount fd into the container — so
in practice you never do it by hand. Container managers expose it as
configuration:
- LXD / Incus:
security.delegate_bpf=trueplussecurity.delegate_bpf.prog_types,.map_types,.cmd_types,.attach_types(Incus usessecurity.bpffs.delegate_*). - systemd services can request a delegated bpffs; container runtimes are growing equivalent knobs.
The application inside simply finds a pre-delegated bpffs mounted at a known
path and derives its token from there. With libbpf, even that is
transparent — point it at the bpffs (bpf_token_path in the open options) and
it derives and threads the token through every bpf() call for you.
Where Aya fits
Aya is on the same trajectory libbpf walked: the kernel mechanism is the
token_fd field on the relevant bpf() commands, and a loader needs to (1)
derive a token from a delegated bpffs and (2) thread its fd through program
load, map creation, and link creation. In libbpf that’s a one-line
bpf_token_path; in Aya the equivalent loader option is emerging rather
than settled, so the example shows where it plugs into EbpfLoader and is
candid that you may be ahead of the released API. The important part for now is
conceptual: nothing about your program changes — the token is a property of
how it’s loaded, supplied by the loader, so the same Aya program you wrote in
Part 1 can run in a delegated container once the loader threads a token.
Build, deploy, observe
cd examples/53-bpf-token && ./demo.sh
The demo does the privileged half on the lab VM, which is the part you can
see plainly: it mounts a bpffs with a tight delegation policy
(delegate_cmds=prog_load:map_create, delegate_progs=socket_filter,
delegate_maps=ringbuf) and prints the mount so the four axes are visible.
That mount is the policy a container would derive a token from. There’s no
Grafana panel here — this is a control-plane feature, not a data source — so
the “observation” is reading the delegation the kernel will enforce.
Cross-check
[vm]$ uname -r # need >= 6.9 for BPF token
[vm]$ mount | grep 'type bpf' # the delegate_* options on the mount
[vm]$ sudo mount -t bpf -o delegate_cmds=bogus bpffs /mnt # rejected → the kernel enforces the policy
[vm]$ grep BPF_TOKEN_CREATE /usr/include/linux/bpf.h # the token ABI is present
The delegate_* options visible on the mount are the cross-check: they’re the
exact set of commands, maps, programs, and attach types a token derived from
this bpffs will permit — and nothing outside that set will load, token or not.
That a bogus axis is rejected (rather than silently ignored) is the proof
the kernel is actually parsing and enforcing the delegation. Note bpftool
feature probe (v7.6.0) does not print a “token” line even though support is
present — the delegated mount, not that probe, is the capability check.
What you learned
- Loading BPF needs capabilities (
CAP_BPF+CAP_PERFMON/CAP_NET_ADMIN) in the init namespace, which a userns container can’t safely hold — BPF isn’t namespace-scoped, so there’s no namespacedCAP_BPF. - The BPF token (kernel 6.9) delegates a subset of BPF functionality: a
privileged runtime mounts a bpffs with
delegate_cmds/maps/progs/attachs, a trusted container derives a token fd, and the kernel checks the token instead of init-ns caps — bounded to the userns, trust explicit. - The token is a property of how a program is loaded, not of the program; runtimes (LXD/Incus/systemd) wire it via config, libbpf threads it transparently, and Aya’s loader support for it is emerging.
Next, Chapter 54 turns to BPF that schedules its own deferred work — timers and workqueues running inside the kernel.
Verification status: verified (privileged half) — Fedora 44, kernel 7.1.3.
On the lab VM, bpffs accepts delegate_cmds/delegate_maps/delegate_progs
and shows them in mount; a bogus axis is rejected, proving the kernel
enforces the policy; and BPF_TOKEN_CREATE is present in the kernel ABI.
(bpftool feature probe v7.6.0 shows no “token” line — a bpftool gap, not a
kernel one.) The Aya EbpfLoader token option is confirmed still emerging:
against the pinned aya 0.14.0 there is no token_path/token method on the
loader, so illustrative/loader_with_token.rs shows the intended shape, not a
shipped API. libbpf threads it via bpf_token_path today.