The bpf() Syscall
Everything eBPF does to enter, query, and wire up the kernel flows through a single, multiplexed system call:
int bpf(int cmd, union bpf_attr *attr, unsigned int size). There is nobpf_prog_load()syscall and nobpf_map_create()syscall — there is one number in the syscall table, and thecmdargument selects which of ~three dozen operations to run, whileattrcarries a giantunionwhose active arm is chosen bycmd(per the man page and the kernel’sinclude/uapi/linux/bpf.h). The two properties that make this design coherent are: (1) every kernel-side BPF object — a loaded program, a map, a link, a BTF blob — is referred to from userspace by a file descriptor, reference-counted and torn down on the lastclose()unless pinned; and (2) thesizeargument plus a tail-zero check gives the union forward/backward compatibility as the kernel grows new fields. Almost nobody callsbpf()by hand — libbpf wraps it — but understanding the raw ABI is what makes the wrappers legible.
What this note owns
The raw kernel ABI: the
enum bpf_cmdcatalog, theunion bpf_attrarms and theirCHECK_ATTRcontracts, thesize/tail-zero compatibility mechanism, the dispatch in__sys_bpf, the file-descriptor object model, and the order in which a handler validates arguments versus checking capabilities. It deliberately does not own: the userspace library that fillsbpf_attr(libbpf and the BPF Loader); what happens insideBPF_PROG_LOADafter the gate (eBPF Verifier, then BPF JIT Compiler and JIT Hardening and Constant Blinding); the map type catalog (BPF Maps); the program type catalog and its attach points (BPF Program Types); the policy of the privilege model (CAP_BPF and BPF Privilege Model, Unprivileged BPF and Its Restrictions, BPF Token and Privilege Delegation); or bpffs itself (BPF Object Pinning and Lifetime, Map Pinning and bpffs). Those are cross-links, not summaries.
Mental Model: One Door, Many Rooms
Think of bpf() as a single front door into a building with many rooms. The cmd integer is the room number; attr is a parcel you hand through the door whose contents only make sense for the room you named; size tells the doorman how big your parcel is so they know which version of the building you were built against. The doorman (__sys_bpf in kernel/bpf/syscall.c) reads the room number, validates the parcel, and routes it to the right handler — map_create, bpf_prog_load, link_create, and so on.
This is the same pattern Linux uses for ioctl(), keyctl(), seccomp(), and prctl(): rather than burn dozens of syscall numbers (a scarce, ABI-frozen resource that must be wired into every architecture’s syscall table), a single number multiplexes a whole subsystem behind a command selector. The cost is that the ABI surface is opaque to tools like strace unless they special-case the command (they do), and that every command must independently validate the union arm it cares about.
flowchart TB U["userspace: syscall(SYS_bpf, cmd, &attr, size)"] U --> ENTRY["SYSCALL_DEFINE3(bpf, ...)<br/>-> __sys_bpf(cmd, uattr, size)"] ENTRY --> TAIL["bpf_check_uarg_tail_zero(uattr, sizeof attr, size)<br/>(reject if tail beyond 'size' is non-zero)"] TAIL --> COPY["size = min(size, sizeof attr)<br/>memset(&attr,0); copy_from_bpfptr(&attr, uattr, size)"] COPY --> SEC["security_bpf(cmd, &attr, size) (LSM hook)"] SEC --> SW{"switch (cmd)"} SW -->|BPF_MAP_CREATE| MC["map_create() -> new fd"] SW -->|BPF_PROG_LOAD| PL["bpf_prog_load() -> verifier -> JIT -> new fd"] SW -->|"BPF_MAP_*_ELEM"| ME["map_lookup/update/delete_elem()"] SW -->|BPF_OBJ_PIN/GET| OP["bpf_obj_pin/get() (bpffs)"] SW -->|BPF_PROG_ATTACH| PA["bpf_prog_attach()"] SW -->|BPF_LINK_CREATE| LC["link_create() -> new fd"] SW -->|BPF_BTF_LOAD| BL["bpf_btf_load() -> new fd"] SW -->|"BPF_*_GET_FD_BY_ID"| GF["bpf_*_get_fd_by_id() -> new fd"]
The single bpf() entry point and its dispatch, as it actually appears in kernel/bpf/syscall.c at v6.12. What it shows: one syscall validates the argument’s tail, copies a bounded prefix of the union, runs an LSM hook, then switches on cmd to a per-command handler — several of which (map_create, bpf_prog_load, link_create, the *_GET_FD_BY_ID family, bpf_btf_load) mint a new file descriptor as their return value, while the map-element and attach commands return 0/-errno. The insight to take: the fd-returning commands are the ones that create kernel objects; the fd is the only handle userspace ever gets, and the object lives exactly as long as some fd (or pin) references it.
The Command Set (enum bpf_cmd)
The complete command catalog is the enum bpf_cmd in include/uapi/linux/bpf.h. Counting distinct command values (ignoring BPF_PROG_RUN, which is just an alias for BPF_PROG_TEST_RUN, and the terminator __MAX_BPF_CMD), Linux 6.12 LTS defines 37 commands — BPF_MAP_CREATE (value 0) through BPF_TOKEN_CREATE (value 36) (verified against v6.12 bpf.h). Linux 6.18 LTS defines 38, adding BPF_PROG_STREAM_READ_BY_FD after BPF_TOKEN_CREATE (verified against v6.18 bpf.h). The set only grows — values are never reused once assigned, because they are a frozen ABI contract.
The commands group naturally by what they operate on:
Map data-plane — the create-and-CRUD over the generic key/value store (see BPF Maps): BPF_MAP_CREATE (make a map, returns an fd), BPF_MAP_LOOKUP_ELEM, BPF_MAP_UPDATE_ELEM, BPF_MAP_DELETE_ELEM, BPF_MAP_GET_NEXT_KEY (iterate by asking “what comes after this key”), BPF_MAP_LOOKUP_AND_DELETE_ELEM, and BPF_MAP_FREEZE (make a map read-only from userspace, used for .rodata). The batch variants BPF_MAP_LOOKUP_BATCH, BPF_MAP_LOOKUP_AND_DELETE_BATCH, BPF_MAP_UPDATE_BATCH, BPF_MAP_DELETE_BATCH amortize syscall overhead by moving many elements per call.
Program lifecycle: BPF_PROG_LOAD (the big one — submit bytecode, run the verifier, JIT-compile, return a program fd), BPF_PROG_ATTACH / BPF_PROG_DETACH (the older attach mechanism, still used for cgroup and some networking hooks), BPF_PROG_TEST_RUN / BPF_PROG_RUN (execute a program once with synthetic input — the backbone of BPF unit tests), BPF_PROG_BIND_MAP (associate a map with a program after load), and BPF_RAW_TRACEPOINT_OPEN (the pre-link way to attach a raw tracepoint).
Object pinning (see BPF Object Pinning and Lifetime and Map Pinning and bpffs): BPF_OBJ_PIN writes an fd into the bpffs filesystem under a pathname so the object outlives its creating process; BPF_OBJ_GET re-opens a pinned object by pathname, yielding a fresh fd.
Links (the modern attachment primitive, see BPF Links and Attachment Lifecycle): BPF_LINK_CREATE (attach a program and get a link fd that owns the attachment), BPF_LINK_UPDATE (atomically swap the program behind a link), BPF_LINK_DETACH (force-detach while keeping the link object), and BPF_ITER_CREATE (instantiate a BPF iterator from a link).
Introspection / IDs: every BPF object has both an fd (process-local) and a stable global ID (system-wide, visible to bpftool). BPF_PROG_GET_NEXT_ID, BPF_MAP_GET_NEXT_ID, BPF_BTF_GET_NEXT_ID, BPF_LINK_GET_NEXT_ID walk the ID space; BPF_PROG_GET_FD_BY_ID, BPF_MAP_GET_FD_BY_ID, BPF_BTF_GET_FD_BY_ID, BPF_LINK_GET_FD_BY_ID turn an ID back into a usable fd (privilege permitting); BPF_OBJ_GET_INFO_BY_FD fills a bpf_*_info struct describing the object; BPF_PROG_QUERY lists which programs are attached at a hook; BPF_TASK_FD_QUERY asks “what BPF is behind this perf-event fd”; BPF_ENABLE_STATS turns on run-count/run-time accounting.
Type info and tokens: BPF_BTF_LOAD uploads a BTF blob (the type metadata that powers CO-RE and verifier-side struct knowledge), returning a BTF fd; BPF_TOKEN_CREATE (merged in 6.9) mints a BPF token from a bpffs mount to delegate scoped BPF privileges to an otherwise-unprivileged process (see BPF Token and Privilege Delegation).
The Complete Command Table
Grouping is how you learn the command set; the table below is how you use it. Every row was enumerated mechanically from the v6.12 sources rather than recalled — the command name and value from the enum bpf_cmd at include/uapi/linux/bpf.h L922–L961, the union bpf_attr arm from the anonymous-struct comments at L1462–L1827 of the same header, the CMD_LAST_FIELD macro and the handler function from the switch (cmd) in kernel/bpf/syscall.c L5614–L5750.
Two columns need unpacking before you read it. bpf_attr arm names the union member the command reads; most arms are anonymous structs, so their fields are reached as attr->map_type, attr->insns, and so on, while a dozen later arms are named (attr->batch.flags, attr->link_create.prog_fd) — the header switched to named arms once the anonymous namespace got crowded. CMD_LAST_FIELD is the argument to the CHECK_ATTR(CMD) macro, and it is the command’s real contract with userspace: the macro memchr_invs every byte of the union after that field and fails with EINVAL if any is non-zero (syscall.c L1045–L1050). In other words, CMD_LAST_FIELD tells you exactly how far into union bpf_attr a given command is allowed to read, and every byte past it must be zero.
| # | Command | bpf_attr arm | CMD_LAST_FIELD | Handler (syscall.c) | Returns |
|---|---|---|---|---|---|
| 0 | BPF_MAP_CREATE | anon (map) | map_token_fd | map_create() | new map fd |
| 1 | BPF_MAP_LOOKUP_ELEM | anon (BPF_MAP_*_ELEM) | flags | map_lookup_elem() | 0 |
| 2 | BPF_MAP_UPDATE_ELEM | anon (BPF_MAP_*_ELEM) | flags | map_update_elem() | 0 |
| 3 | BPF_MAP_DELETE_ELEM | anon (BPF_MAP_*_ELEM) | key | map_delete_elem() | 0 |
| 4 | BPF_MAP_GET_NEXT_KEY | anon (BPF_MAP_*_ELEM) | next_key | map_get_next_key() | 0 |
| 5 | BPF_PROG_LOAD | anon (prog) | prog_token_fd | bpf_prog_load() | new prog fd |
| 6 | BPF_OBJ_PIN | anon (BPF_OBJ_*) | path_fd | bpf_obj_pin() | 0 |
| 7 | BPF_OBJ_GET | anon (BPF_OBJ_*) | path_fd | bpf_obj_get() | new fd (prog/map/link) |
| 8 | BPF_PROG_ATTACH | anon (attach) | expected_revision | bpf_prog_attach() | 0 |
| 9 | BPF_PROG_DETACH | anon (attach) | expected_revision | bpf_prog_detach() | 0 |
| 10 | BPF_PROG_TEST_RUN | test | test.batch_size | bpf_prog_test_run() | 0 (+ retval written back) |
| 10 | BPF_PROG_RUN | — | — | — | alias for BPF_PROG_TEST_RUN |
| 11 | BPF_PROG_GET_NEXT_ID | anon (BPF_*_GET_*_ID) | next_id | bpf_obj_get_next_id() | 0 (+ next_id) |
| 12 | BPF_MAP_GET_NEXT_ID | anon (BPF_*_GET_*_ID) | next_id | bpf_obj_get_next_id() | 0 (+ next_id) |
| 13 | BPF_PROG_GET_FD_BY_ID | anon (BPF_*_GET_*_ID) | prog_id | bpf_prog_get_fd_by_id() | new prog fd |
| 14 | BPF_MAP_GET_FD_BY_ID | anon (BPF_*_GET_*_ID) | open_flags | bpf_map_get_fd_by_id() | new map fd |
| 15 | BPF_OBJ_GET_INFO_BY_FD | info | info.info | bpf_obj_get_info_by_fd() | 0 (+ filled bpf_*_info) |
| 16 | BPF_PROG_QUERY | query | query.revision | bpf_prog_query() | 0 (+ attached prog ids) |
| 17 | BPF_RAW_TRACEPOINT_OPEN | raw_tracepoint | raw_tracepoint.cookie | bpf_raw_tracepoint_open() | new link fd |
| 18 | BPF_BTF_LOAD | anon (BTF) | btf_token_fd | bpf_btf_load() | new BTF fd |
| 19 | BPF_BTF_GET_FD_BY_ID | anon (BPF_*_GET_*_ID) | btf_id | bpf_btf_get_fd_by_id() | new BTF fd |
| 20 | BPF_TASK_FD_QUERY | task_fd_query | task_fd_query.probe_addr | bpf_task_fd_query() | 0 (+ probe info) |
| 21 | BPF_MAP_LOOKUP_AND_DELETE_ELEM | anon (BPF_MAP_*_ELEM) | flags | map_lookup_and_delete_elem() | 0 |
| 22 | BPF_MAP_FREEZE | anon (BPF_MAP_*_ELEM) | map_fd | map_freeze() | 0 |
| 23 | BPF_BTF_GET_NEXT_ID | anon (BPF_*_GET_*_ID) | next_id | bpf_obj_get_next_id() | 0 (+ next_id) |
| 24 | BPF_MAP_LOOKUP_BATCH | batch | batch.flags | bpf_map_do_batch() | 0 (+ count) |
| 25 | BPF_MAP_LOOKUP_AND_DELETE_BATCH | batch | batch.flags | bpf_map_do_batch() | 0 (+ count) |
| 26 | BPF_MAP_UPDATE_BATCH | batch | batch.flags | bpf_map_do_batch() | 0 (+ count) |
| 27 | BPF_MAP_DELETE_BATCH | batch | batch.flags | bpf_map_do_batch() | 0 (+ count) |
| 28 | BPF_LINK_CREATE | link_create | link_create.uprobe_multi.pid | link_create() | new link fd |
| 29 | BPF_LINK_UPDATE | link_update | link_update.old_prog_fd | link_update() | 0 |
| 30 | BPF_LINK_GET_FD_BY_ID | anon (BPF_*_GET_*_ID) | link_id | bpf_link_get_fd_by_id() | new link fd |
| 31 | BPF_LINK_GET_NEXT_ID | anon (BPF_*_GET_*_ID) | next_id | bpf_obj_get_next_id() | 0 (+ next_id) |
| 32 | BPF_ENABLE_STATS | enable_stats | enable_stats.type | bpf_enable_stats() | new stats fd |
| 33 | BPF_ITER_CREATE | iter_create | iter_create.flags | bpf_iter_create() | new iterator fd |
| 34 | BPF_LINK_DETACH | link_detach | link_detach.link_fd | link_detach() | 0 |
| 35 | BPF_PROG_BIND_MAP | prog_bind_map | prog_bind_map.flags | bpf_prog_bind_map() | 0 |
| 36 | BPF_TOKEN_CREATE | token_create | token_create.bpffs_fd | token_create() → bpf_token_create() | new token fd |
Reading the Returns column top to bottom is the fastest way to see the object model: thirteen of the thirty-seven commands mint a file descriptor, and those thirteen are precisely the ones that either create a kernel object (MAP_CREATE, PROG_LOAD, BTF_LOAD, LINK_CREATE, RAW_TRACEPOINT_OPEN, ITER_CREATE, ENABLE_STATS) or recover a handle to one that already exists (OBJ_GET, the four *_GET_FD_BY_ID commands, TOKEN_CREATE). Everything else is an operation on an object you already hold an fd for, and returns 0 with results written back through uattr.
Note the BPF_PROG_RUN row: it is not a thirty-eighth command but a second spelling of value 10, added in 5.14 when TEST_RUN outgrew its “just for tests” name (existence-checked 2026-09-12: the line BPF_PROG_RUN = BPF_PROG_TEST_RUN, is absent from bpf.h at v5.13 and present at v5.14). Any inventory that counts enum entries rather than enum values will over-count by one for this reason, and by two if it also counts __MAX_BPF_CMD.
How the command set has grown
The enum is append-only, which makes it a clean subject for existence-checking: fetch the same header at successive tags and count. Measured 2026-09-12 by fetching include/uapi/linux/bpf.h from raw.githubusercontent.com at four tags (all HTTP 200) and counting BPF_* entries minus the BPF_PROG_RUN alias:
| Tag | Distinct bpf_cmd values | Highest-numbered command |
|---|---|---|
v6.12 (LTS, this note’s pin) | 37 (0–36) | BPF_TOKEN_CREATE = 36 |
v6.18 (LTS) | 38 (0–37) | BPF_PROG_STREAM_READ_BY_FD = 37 |
v7.1 (stable; the kernel measured below) | 39 (0–38) | BPF_PROG_ASSOC_STRUCT_OPS = 38 |
v7.2 (stable) | 40 (0–39) + the BPF_COMMON_ATTRS = 1 << 16 flag bit | BPF_COMMON_ATTRS is a flag, not a command |
The command set gained roughly one command per LTS cycle over this window. Version truth for the tags themselves is kernel.org/releases.json, fetched the same day: mainline 7.3-rc2, latest stable 7.2.5, longterm branches including 6.18.51 and 6.12.109.
The v7.2 row deserves its own paragraph, because it is the first change to the shape of the syscall since eBPF began. BPF_COMMON_ATTRS = 1 << 16 is declared inside enum bpf_cmd but after __MAX_BPF_CMD, which is the giveaway that it is not a command ordinal at all; the v7.2 __sys_bpf treats it as a bit OR-ed into cmd:
/* v7.2 kernel/bpf/syscall.c, ~L6374 */
if (cmd & BPF_COMMON_ATTRS) {
err = bpf_check_uarg_tail_zero(uattr_common,
offsetofend(struct bpf_common_attr, log_true_size),
size_common);
if (err)
return err;
cmd &= ~BPF_COMMON_ATTRS;
size_common = min_t(u32, size_common, sizeof(attr_common));
if (copy_from_bpfptr(&attr_common, uattr_common, size_common) != 0)
return -EFAULT;
}Setting that bit tells the kernel that two extra syscall arguments are present, carrying a second, command-independent attribute struct (struct bpf_common_attr, which is where the verifier log buffer moves to so that every command can report a log, not just PROG_LOAD and BTF_LOAD). Correspondingly, the syscall’s own arity changed. Existence-checked on 2026-09-12 by fetching kernel/bpf/syscall.c at five tags and grepping the SYSCALL_DEFINE macro:
| Tag | Syscall definition |
|---|---|
v6.12 | SYSCALL_DEFINE3(bpf, int cmd, union bpf_attr __user *uattr, unsigned int size) |
v6.18 | SYSCALL_DEFINE3(bpf, ...) |
v7.0 | SYSCALL_DEFINE3(bpf, ...) |
v7.1 | SYSCALL_DEFINE3(bpf, ...) |
v7.2 | SYSCALL_DEFINE5(bpf, int cmd, union bpf_attr __user *uattr, unsigned int size, struct bpf_common_attr __user *uattr_common, unsigned int size_common) |
bpf() was a three-argument syscall for its entire life through v7.1 and became a five-argument syscall in v7.2 — but compatibly: an old caller passing three arguments leaves uattr_common/size_common as garbage in the unused registers, and the kernel never looks at them unless the caller sets BPF_COMMON_ATTRS in cmd. The insight to take: the cmd argument is now a bitfield with an ordinal in its low bits, and code that validates cmd < __MAX_BPF_CMD before dispatch — including a fair amount of tracing and sandboxing tooling — will reject valid v7.2 calls. Everything else in this note describes the three-argument form, which is what v6.12 (the pin) and v6.18 implement.
The union bpf_attr — One Struct Per Command
The second argument, attr, is a union bpf_attr: a single union whose member structs each correspond to a command (or family of commands). Because it is a union, all arms overlap in memory; the active arm is implied by cmd. Userspace zero-fills the whole thing, populates only the arm it needs, and passes sizeof(union bpf_attr) (or the offset of the last field it uses) as size.
The BPF_MAP_CREATE arm is representative of how much an arm can carry:
struct { /* anonymous struct used by BPF_MAP_CREATE command */
__u32 map_type; /* one of enum bpf_map_type */
__u32 key_size; /* size of key in bytes */
__u32 value_size; /* size of value in bytes */
__u32 max_entries; /* max number of entries in a map */
__u32 map_flags; /* BPF_F_* creation flags */
__u32 inner_map_fd; /* fd of template map (for maps-of-maps) */
__u32 numa_node; /* NUMA node, if BPF_F_NUMA_NODE set */
char map_name[BPF_OBJ_NAME_LEN]; /* 16-byte name for introspection */
__u32 map_ifindex; /* netdev for offloaded maps */
__u32 btf_fd; /* fd of a loaded BTF blob */
__u32 btf_key_type_id; /* BTF type id of the key */
__u32 btf_value_type_id; /* BTF type id of the value */
__u32 btf_vmlinux_value_type_id; /* kernel struct as value (struct_ops) */
__u64 map_extra; /* per-type extra (bloom hashes, arena addr) */
__s32 value_type_btf_obj_fd;
__s32 map_token_fd; /* BPF token fd, if BPF_F_TOKEN_FD set */
};Reading it line by line: map_type selects the implementation (hash, array, ring buffer, LPM-trie, …); key_size/value_size/max_entries are the geometry; map_flags carries creation options (BPF_F_NO_PREALLOC, BPF_F_MMAPABLE, BPF_F_NUMA_NODE, BPF_F_TOKEN_FD, …); inner_map_fd makes maps-of-maps possible by giving a template; the btf_* fields attach type information so bpftool map dump can pretty-print keys and values; map_name is the 16-byte (BPF_OBJ_NAME_LEN) label you see in bpftool map list; and map_token_fd (with the BPF_F_TOKEN_FD flag) is how a delegated, unprivileged process proves it is allowed to create the map. The trailing map_token_fd and value_type_btf_obj_fd fields are newer additions to the end of the struct — which is exactly why the size mechanism matters.
The BPF_PROG_LOAD arm is the largest and most consequential. Its key fields: prog_type (selects the program type and thus the available helpers and context); insns + insn_cnt (a userspace pointer to the bytecode array and its length); license (a GPL-compatibility string — gpl_only helpers are rejected unless the program declares a GPL-compatible license); log_buf + log_size + log_level (where the verifier writes its multi-line reasoning, the text every BPF developer learns to read); prog_name; expected_attach_type; attach_btf_id / attach_btf_obj_fd (for fentry/fexit/LSM, the in-kernel BTF id of the function being hooked); func_info / line_info (BTF-keyed debug info); core_relos + core_relo_cnt (CO-RE relocation records, so the kernel can apply field relocations for some paths); fd_array (an array of fds — maps and BTF objects referenced by the program); log_true_size (output: the real log length, even if truncated); and prog_token_fd. The map-element arm, by contrast, is tiny — map_fd, a pointer to key, a union of value/next_key, and flags — yet it is the single most frequently invoked arm at runtime, because every userspace read of a map’s contents is one of these calls.
Other arms worth naming: the BPF_OBJ_* arm carries pathname + bpf_fd + file_flags + path_fd (the path_fd/BPF_F_PATH_FD pair lets pinning use an openat()-style directory fd); the BPF_PROG_ATTACH/DETACH arm carries target_fd/target_ifindex, attach_bpf_fd, attach_type, attach_flags, and replace_bpf_fd (for atomic replace at cgroup hooks); the link_create arm carries the program fd, the target, and per-type attach parameters; the BPF_BTF_LOAD arm carries the raw BTF blob plus its own log buffer; the BPF_*_GET_*_ID arm carries a start_id/prog_id/map_id/… union and a next_id output; and the compact token_create arm is just flags + bpffs_fd.
How Dispatch Actually Works (the v6.12 source)
The real entry point is SYSCALL_DEFINE3(bpf, int, cmd, union bpf_attr __user *, uattr, unsigned int, size), which immediately forwards to __sys_bpf(cmd, USER_BPFPTR(uattr), size) (the bpfptr_t wrapper lets the same code path serve both userspace callers and the in-kernel bpf_sys_bpf helper). The first thing __sys_bpf does is the compatibility check (v6.12 syscall.c):
err = bpf_check_uarg_tail_zero(uattr, sizeof(attr), size);
if (err)
return err;
size = min_t(u32, size, sizeof(attr));
memset(&attr, 0, sizeof(attr));
if (copy_from_bpfptr(&attr, uattr, size) != 0)
return -EFAULT;This four-line sequence is the forward/backward-compatibility mechanism, and it is worth dwelling on because it is what lets one libbpf binary run across kernel generations:
bpf_check_uarg_tail_zero(uattr, sizeof(attr), size)handles the case where userspace is newer than the kernel. If the caller passes asizelarger than the kernel’s ownsizeof(union bpf_attr), the kernel inspects the bytes beyond its known struct and requires them to be all zero. If they are zero, the caller simply isn’t using any field the kernel doesn’t understand, and the call proceeds. If any tail byte is non-zero, the caller is relying on a feature this kernel lacks, and the call is rejected with-E2BIG. This is why you can compile against new headers and still run on an old kernel as long as you don’t set new fields.size = min_t(u32, size, sizeof(attr))then clamps to the kernel’s struct size, andmemset+copy_from_bpfptrcopies only that bounded prefix into a zero-initialized local. This handles the case where userspace is older than the kernel: a shortsizecopies fewer bytes, and the kernel’s newer trailing fields stay zero (their documented “unset” default). New fields are therefore always designed so that zero means “old behavior.”
After the copy, __sys_bpf calls the LSM hook security_bpf(cmd, &attr, size) — this is where SELinux, AppArmor, or a BPF-LSM policy can veto a command before any work happens — and then enters the switch (cmd). Each case is a thin shim: case BPF_MAP_CREATE: err = map_create(&attr);, case BPF_PROG_LOAD: err = bpf_prog_load(...);, case BPF_LINK_CREATE: ..., and so on. The per-command handlers do their own argument validation (typically via the CHECK_ATTR macro, which memchr_invs the bytes after each command’s declared last-used field to confirm the caller zeroed everything that command doesn’t read) and their own permission checks.
A subtle but important detail surfaced in the same file: bpf_sys_bpf (exposed to BPF programs as the bpf_sys_bpf kfunc, and as kern_sys_bpf for the kernel) re-enters __sys_bpf with a kernel bpfptr_t. It is not a general back door — the v6.12 implementation whitelists exactly nine commands and returns -EINVAL for everything else:
BPF_CALL_3(bpf_sys_bpf, int, cmd, union bpf_attr *, attr, u32, attr_size)
{
switch (cmd) {
case BPF_MAP_CREATE:
case BPF_MAP_DELETE_ELEM:
case BPF_MAP_UPDATE_ELEM:
case BPF_MAP_FREEZE:
case BPF_MAP_GET_FD_BY_ID:
case BPF_PROG_LOAD:
case BPF_BTF_LOAD:
case BPF_LINK_CREATE:
case BPF_RAW_TRACEPOINT_OPEN:
break;
default:
return -EINVAL;
}
return __sys_bpf(cmd, KERNEL_BPFPTR(attr), attr_size);
}That list is precisely “everything a loader needs and nothing else”: create maps, populate and freeze them (the .rodata dance), load BTF, load programs, and attach. Conspicuously absent are BPF_PROG_ATTACH, every *_GET_NEXT_ID command, and BPF_TOKEN_CREATE. kern_sys_bpf wraps the same function with one addition — it also permits BPF_PROG_TEST_RUN, but only for BPF_PROG_TYPE_SYSCALL programs and only with every I/O field zeroed. This is the machinery the light skeleton uses to load BPF from inside the kernel during boot — the same dispatch, called from a different side of the privilege boundary.
Permission Checks Run After Argument Validation — Measured
The single most common mental model of bpf() under a hardened kernel is “unprivileged BPF is off, so bpf() returns EPERM.” The kernel’s own documentation encourages it: Documentation/admin-guide/sysctl/kernel.rst says that once unprivileged_bpf_disabled is set, “calling bpf() without CAP_SYS_ADMIN or CAP_BPF will return -EPERM” (v6.12 kernel.rst L1596–L1599). That sentence is a useful summary and a poor prediction. Most commands do not return EPERM to an unprivileged caller, because each handler validates its arguments, resolves its file descriptors, or checks filesystem permissions before it consults any capability — and whichever check fires first decides the errno.
This is worth getting right for a practical reason: when you are debugging a BPF tool that fails on a locked-down host, the errno tells you which wall you hit, and EINVAL from a permission-hardened kernel does not mean “you lack privilege.” It usually means you never got far enough to be asked.
The measurement
Measured 2026-09-12 on a Fedora 44 host running Linux 7.1.13-200.fc44.x86_64 as uid 1000 with an empty effective capability set (CapEff: 0000000000000000 in /proc/self/status). Neither bpftool nor perf is installed on this host, so every number below comes from a hand-written syscall(__NR_bpf, cmd, &attr, size) probe compiled with gcc, with no libbpf involved. The two world-readable BPF sysctls read:
$ cat /proc/sys/kernel/unprivileged_bpf_disabled
2
$ cat /proc/sys/net/core/bpf_jit_enable
1
$ grep -E 'CONFIG_BPF_UNPRIV_DEFAULT_OFF|CONFIG_BPF_JIT|CONFIG_DEBUG_INFO_BTF' /boot/config-$(uname -r)
CONFIG_BPF_JIT=y
CONFIG_BPF_JIT_ALWAYS_ON=y
CONFIG_BPF_JIT_DEFAULT_ON=y
CONFIG_BPF_UNPRIV_DEFAULT_OFF=y
CONFIG_DEBUG_INFO_BTF=y
The other three JIT sysctls are mode 0600 and refused: cat /proc/sys/net/core/bpf_jit_harden → Permission denied, and the same for bpf_jit_kallsyms and bpf_jit_limit. Their values are therefore unknown here and are not claimed; see JIT Hardening and Constant Blinding for what they control.
The probe sweeps every enum bpf_cmd value 0–36 with a fully zeroed union bpf_attr and size = sizeof(union bpf_attr), which on this host’s headers is 168 bytes. Real output, verbatim:
uid=1000 sizeof(union bpf_attr)=168
cmd name ret errno
0 BPF_MAP_CREATE -1 EINVAL
1 BPF_MAP_LOOKUP_ELEM -1 EINVAL
2 BPF_MAP_UPDATE_ELEM -1 EINVAL
3 BPF_MAP_DELETE_ELEM -1 EINVAL
4 BPF_MAP_GET_NEXT_KEY -1 EINVAL
5 BPF_PROG_LOAD -1 EPERM
6 BPF_OBJ_PIN -1 EINVAL
7 BPF_OBJ_GET -1 EFAULT
8 BPF_PROG_ATTACH -1 EINVAL
9 BPF_PROG_DETACH -1 EBADF
10 BPF_PROG_TEST_RUN -1 EINVAL
11 BPF_PROG_GET_NEXT_ID -1 EPERM
12 BPF_MAP_GET_NEXT_ID -1 EPERM
13 BPF_PROG_GET_FD_BY_ID -1 EPERM
14 BPF_MAP_GET_FD_BY_ID -1 EPERM
15 BPF_OBJ_GET_INFO_BY_FD -1 EINVAL
16 BPF_PROG_QUERY -1 EPERM
17 BPF_RAW_TRACEPOINT_OPEN -1 EINVAL
18 BPF_BTF_LOAD -1 EPERM
19 BPF_BTF_GET_FD_BY_ID -1 EPERM
20 BPF_TASK_FD_QUERY -1 EPERM
21 BPF_MAP_LOOKUP_AND_DELETE_ELEM -1 EINVAL
22 BPF_MAP_FREEZE -1 EINVAL
23 BPF_BTF_GET_NEXT_ID -1 EPERM
24 BPF_MAP_LOOKUP_BATCH -1 EINVAL
25 BPF_MAP_LOOKUP_AND_DELETE_BATCH -1 EINVAL
26 BPF_MAP_UPDATE_BATCH -1 EINVAL
27 BPF_MAP_DELETE_BATCH -1 EINVAL
28 BPF_LINK_CREATE -1 EINVAL
29 BPF_LINK_UPDATE -1 EINVAL
30 BPF_LINK_GET_FD_BY_ID -1 EPERM
31 BPF_LINK_GET_NEXT_ID -1 EPERM
32 BPF_ENABLE_STATS -1 EPERM
33 BPF_ITER_CREATE -1 EINVAL
34 BPF_LINK_DETACH -1 EINVAL
35 BPF_PROG_BIND_MAP -1 EINVAL
36 BPF_TOKEN_CREATE -1 EINVAL
Every command fails, as expected on a host with unprivileged_bpf_disabled = 2 — but only 11 of 37 fail with EPERM. The result is stable, not a sampling artefact: the sweep was run 200 times and every run hashed identically (for i in $(seq 1 200); do ./probe | tail -n +5 | md5sum; done | sort | uniq -c → 200 fa6647d7...). Errno here is a deterministic function of the arguments, not a sampled gauge.
Reading the result against the source
The errno tells you which check the handler reached first, and the v6.12 source explains every class:
| Errno | Commands | What fired first, and where |
|---|---|---|
EPERM (11) | PROG_LOAD; PROG_QUERY; BTF_LOAD; the eight ID-space commands (*_GET_NEXT_ID, *_GET_FD_BY_ID, TASK_FD_QUERY, ENABLE_STATS) | An unconditional capability test at the top of the handler. The ID-space commands literally call capable(CAP_SYS_ADMIN) as their first statement — not bpf_capable(), so CAP_BPF alone does not unlock them. bpf_btf_load calls bpf_token_capable(token, CAP_BPF); bpf_prog_query calls bpf_net_capable(). |
EINVAL (21) | MAP_CREATE; all map-element and batch commands; OBJ_PIN; PROG_ATTACH; PROG_TEST_RUN; OBJ_GET_INFO_BY_FD; RAW_TRACEPOINT_OPEN; LINK_CREATE/UPDATE/DETACH; ITER_CREATE; PROG_BIND_MAP; TOKEN_CREATE | Argument validation or object-type resolution rejected the zeroed attr before any capability was consulted. For the map-element commands the CLASS(fd, f)(attr->map_fd) lookup resolves fd 0 — the probe’s stdin — and __bpf_map_get returns -EINVAL because its f_op is not bpf_map_fops. |
EFAULT (1) | OBJ_GET | bpf_obj_do_get calls user_path_at() on attr->pathname, which is a NULL userspace pointer in a zeroed attr. |
EBADF (1) | PROG_DETACH | attach_type = 0 maps to BPF_PROG_TYPE_CGROUP_SKB, whose detach path resolves attr->target_fd as a cgroup directory fd; fd 0 is not one. |
MAP_CREATE is the cleanest demonstration, because the same command returns two different errnos depending only on a field that has nothing to do with privilege. In map_create (v6.12 syscall.c L1204 onward), the type dispatch comes first:
map_type = attr->map_type;
if (map_type >= ARRAY_SIZE(bpf_map_types))
return -EINVAL;
map_type = array_index_nospec(map_type, ARRAY_SIZE(bpf_map_types));
ops = bpf_map_types[map_type];
if (!ops)
return -EINVAL; /* <-- BPF_MAP_TYPE_UNSPEC lands here */
...
err = -EPERM;
if (sysctl_unprivileged_bpf_disabled && !bpf_token_capable(token, CAP_BPF))
goto put_token; /* <-- the permission gate, 30-odd lines later */bpf_map_types[] is a designated-initializer array built by #include <linux/bpf_types.h>, and that header has no BPF_MAP_TYPE(BPF_MAP_TYPE_UNSPEC, …) line — so index 0 is a NULL ops pointer, and a zeroed attr dies at if (!ops) with EINVAL without the kernel ever asking who you are. Set a real map type and the same call reaches the gate. Measured:
== A. BPF_MAP_CREATE: the same command, three map_type values ==
map_type=0 BPF_MAP_TYPE_UNSPEC (0) - no ops registered -> ret=-1 EINVAL
map_type=1 BPF_MAP_TYPE_HASH (1) - 'unprivileged' class -> ret=-1 EPERM
map_type=2 BPF_MAP_TYPE_ARRAY (2) - 'unprivileged' class -> ret=-1 EPERM
map_type=999999 out of ARRAY_SIZE(bpf_map_types) range -> ret=-1 EINVAL
BPF_MAP_TYPE_HASH and BPF_MAP_TYPE_ARRAY sit in the switch arm labelled /* unprivileged */ in map_create — the types that need no capability at all when unprivileged_bpf_disabled is 0. They still return EPERM here, which is exactly what the sysctl is for. The lesson is the pairing: EINVAL and EPERM from the same command, on the same kernel, as the same user, decided purely by argument validity.
BPF_PROG_LOAD is the mirror image, and it is the reason the summary “bpf() is blocked” survives: it is the one heavyweight command whose permission gate really is near the top. bpf_prog_load runs CHECK_ATTR, the prog_flags mask check, and the optional token lookup, then:
bpf_cap = bpf_token_capable(token, CAP_BPF);
err = -EPERM;
...
if (sysctl_unprivileged_bpf_disabled && !bpf_cap)
goto put_token;
if (attr->insn_cnt == 0 || attr->insn_cnt > (bpf_cap ? BPF_COMPLEXITY_LIMIT_INSNS : BPF_MAXINSNS)) {
err = -E2BIG;
goto put_token;
}The insn_cnt == 0 check that would give E2BIG is below the gate, so a zeroed attr returns EPERM, not E2BIG. And a fully valid program fares no better:
== B. BPF_PROG_LOAD: zeroed vs a fully-formed SOCKET_FILTER ==
zeroed attr (prog_type=UNSPEC, insn_cnt=0) -> ret=-1 EPERM
SOCKET_FILTER, 2 valid insns, GPL, log_buf set -> ret=-1 EPERM
verifier log bytes written: 0
KPROBE (CAP_PERFMON class), 2 valid insns -> ret=-1 EPERM
XDP (CAP_NET_ADMIN class), 2 valid insns -> ret=-1 EPERM
verifier log bytes written: 0 is the diagnostic worth memorizing. The probe passed a 64 KiB log_buf with log_level = 1; the kernel wrote nothing into it, because the EPERM fired long before bpf_check() ran. An empty log is therefore positive evidence that you never reached the verifier — the failure is a privilege or ABI problem, not a program problem. EACCES with a populated log is the verifier rejection; EPERM with an empty log is the gate. Confusing the two costs hours.
flowchart TB CALL["syscall(__NR_bpf, cmd, &attr, size)"] --> T1{"tail beyond 'size'<br/>all zero?<br/>size <= PAGE_SIZE?"} T1 -->|no| E2B["E2BIG<br/>(ABI mismatch: you set a field<br/>this kernel does not know)"] T1 -->|yes| CPY["clamp size, memset, copy prefix<br/>security_bpf() LSM hook"] CPY --> SW{"switch (cmd)<br/>known command?"} SW -->|no| EIN0["EINVAL<br/>(default: arm of the switch)"] SW -->|yes| H["per-command handler"] H --> CA{"CHECK_ATTR(CMD):<br/>every byte past CMD_LAST_FIELD zero?"} CA -->|no| EIN1["EINVAL"] CA -->|yes| AV{"argument validation<br/>(map_type / prog_flags / attach_type /<br/>flag masks, fd and path resolution)"} AV -->|"bad value"| EIN2["EINVAL"] AV -->|"bad fd"| EBF["EBADF / EBADFD"] AV -->|"bad user pointer"| EFA["EFAULT"] AV -->|"bpffs path denied"| EAC["EACCES"] AV -->|ok| PERM{"capability gate:<br/>sysctl_unprivileged_bpf_disabled<br/>&& !bpf_token_capable(CAP_BPF)?"} PERM -->|"blocked"| EPM["EPERM"] PERM -->|"allowed"| TY{"per-type gate:<br/>CAP_NET_ADMIN / CAP_PERFMON?"} TY -->|no| EPM TY -->|yes| WORK["do the work<br/>(verifier, JIT, map alloc, attach)"] WORK --> OUT["new fd, or 0, or a work-specific<br/>errno: EACCES / E2BIG / ENOENT / ENOTSUPP"]
The error-precedence ladder inside one bpf() call, drawn from the v6.12 __sys_bpf and the shape shared by its per-command handlers. What it shows: four distinct classes of rejection sit above the capability gate — the ABI tail check, the unknown-command default, CHECK_ATTR, and ordinary argument/fd/path validation — and each has its own errno. The insight to take: errno from bpf() is a position report, not a verdict. E2BIG means your headers are newer than this kernel; EINVAL means the arguments were wrong (and says nothing about privilege); EACCES means either bpffs path permissions or, from PROG_LOAD specifically, the verifier; only EPERM means “you are not allowed.” Diagnose in that order.
The size argument, demonstrated live
The forward/backward-compatibility mechanism described above is easy to state and easier to disbelieve, so here it is exercised directly. The probe declares struct big { union bpf_attr a; unsigned char tail[64]; } — a deliberately over-long argument, as a userspace built against newer headers would produce — and passes sizeof(struct big) as size:
== C. The size argument and bpf_check_uarg_tail_zero() ==
size=232 (> kernel sizeof), tail ALL ZERO -> ret=-1 EPERM
size=232 (> kernel sizeof), last tail byte = 0x01 -> ret=-1 E2BIG
size=8192 (> PAGE_SIZE), all zero -> ret=-1 E2BIG
size=20 (short: only up to max_entries) -> ret=-1 EPERM
Line by line: an over-long attr with a zero tail is accepted and the call proceeds to its normal fate (EPERM, because this is a MAP_CREATE of a valid type on a hardened kernel) — that is “new userspace, old kernel, no new fields actually used.” Flipping a single byte 64 bytes past the end of the kernel’s union bpf_attr turns the same call into E2BIG — that is “new userspace asking for a feature this kernel lacks,” and it is the entire forward-compatibility contract in one bit. A size over PAGE_SIZE is E2BIG regardless of content, the separate cap inside bpf_check_uarg_tail_zero. And a short size of 20 bytes is fine: the kernel copies the 20-byte prefix into a zeroed local, leaving every later field at its documented “unset” default — “old userspace, new kernel.”
Unknown commands are indistinguishable from rejected ones
A tempting use of this probe is feature detection: call the command and see whether you get EINVAL. It does not work, and the running kernel proves it. On this host (Linux 7.1, where the enum runs 0–38):
== D. Unknown command values ==
cmd=37 -> ret=-1 EINVAL
cmd=38 -> ret=-1 EINVAL
cmd=200 -> ret=-1 EINVAL
cmd=-1 -> ret=-1 EINVAL
Commands 37 (BPF_PROG_STREAM_READ_BY_FD) and 38 (BPF_PROG_ASSOC_STRUCT_OPS) exist on this kernel and were dispatched to real handlers, which rejected a zeroed attr with EINVAL. Commands 200 and −1 fell through to the default: err = -EINVAL; arm of the switch. The two are externally identical. Probing for command support therefore requires a minimally valid attr for that specific command — which is precisely what libbpf’s feature-probing code does, and why it is several hundred lines rather than a loop.
bpffs is a wall before BPF is
BPF_OBJ_GET against a real path shows a fourth kind of wall entirely:
== E. BPF_OBJ_GET against a real bpffs path ==
pathname="/sys/fs/bpf" -> ret=-1 EACCES
pathname="/sys/fs/bpf/does-not-exist" -> ret=-1 EACCES
Both fail identically, and neither failure comes from BPF. mount reports bpf on /sys/fs/bpf type bpf (rw,nosuid,nodev,noexec,relatime,mode=700) — the mount is mode=700 and owned by root, so user_path_at() inside bpf_obj_do_get (v6.12 kernel/bpf/inode.c) fails at ordinary VFS permission checking. ls -la /sys/fs/bpf returns the same Permission denied. This matters because pinning is often described as the way to share BPF objects with unprivileged consumers; on a default systemd host the filesystem mode alone forecloses it, before any CAP_BPF question arises. See BPF Object Pinning and Lifetime and Map Pinning and bpffs.
The Capability Model at the Syscall Boundary
Which capability unlocks what is the part of this ABI that readers most reliably get wrong, partly because the answer is four capabilities and partly because the check is spread across three layers. This note owns the syscall-boundary view: which bpf() command consults which capability, and in what order relative to argument validation. The policy questions — why CAP_BPF was split out of CAP_SYS_ADMIN, what an unprivileged program may still do, and how tokens delegate — belong to CAP_BPF and BPF Privilege Model, Unprivileged BPF and Its Restrictions, and BPF Token and Privilege Delegation respectively, and are not restated here.
The primitives are three lines of v6.12 header. In include/linux/capability.h L195–L203:
static inline bool perfmon_capable(void) { return capable(CAP_PERFMON) || capable(CAP_SYS_ADMIN); }
static inline bool bpf_capable(void) { return capable(CAP_BPF) || capable(CAP_SYS_ADMIN); }CAP_SYS_ADMIN is therefore a superset of every BPF capability by construction — “root can always do it” is true, and is implemented as an explicit || rather than as an accident. The token-aware form used throughout syscall.c adds one indirection (kernel/bpf/token.c):
bool bpf_token_capable(const struct bpf_token *token, int cap)
{
struct user_namespace *userns;
/* BPF token allows ns_capable() level of capabilities */
userns = token ? token->userns : &init_user_ns;
if (!bpf_ns_capable(userns, cap))
return false;
if (token && security_bpf_token_capable(token, cap) < 0)
return false;
return true;
}…where bpf_ns_capable is the file-local helper that re-implements the || CAP_SYS_ADMIN fallback namespace-aware:
static bool bpf_ns_capable(struct user_namespace *ns, int cap)
{
return ns_capable(ns, cap) || (cap != CAP_SYS_ADMIN && ns_capable(ns, CAP_SYS_ADMIN));
}With no token, userns is &init_user_ns, so this is a check against the initial user namespace — which is why holding CAP_BPF inside an unprivileged user namespace buys nothing: your capabilities are real in your namespace and absent in init_user_ns. A token shifts the reference namespace to the one the bpffs mount lives in, which is the whole trick of delegation. The cap != CAP_SYS_ADMIN guard avoids an infinite fallback when the requested capability is CAP_SYS_ADMIN.
| Capability | What it gates at the bpf() boundary | Notes |
|---|---|---|
CAP_BPF | BPF_PROG_LOAD for all but two program types; BPF_MAP_CREATE for the fifteen mid-privilege map types (SK_STORAGE, INODE_STORAGE, TASK_STORAGE, CGRP_STORAGE, BLOOM_FILTER, LPM_TRIE, REUSEPORT_SOCKARRAY, STACK_TRACE, QUEUE, STACK, LRU_HASH, LRU_PERCPU_HASH, STRUCT_OPS, CPUMAP, ARENA); BPF_BTF_LOAD; and, when unprivileged_bpf_disabled is non-zero, every map and program creation | The BPF-specific capability, split out of CAP_SYS_ADMIN in 5.8 (existence-checked: bpf_capable() is absent from include/linux/capability.h at v5.7, present at v5.8). On its own it is not enough for the ID-space commands. |
CAP_PERFMON | Loading the tracing program types: KPROBE, TRACEPOINT, PERF_EVENT, RAW_TRACEPOINT, RAW_TRACEPOINT_WRITABLE, TRACING (fentry/fexit), LSM, STRUCT_OPS, EXT (is_perfmon_prog_type()) | Checked in addition to CAP_BPF, not instead of it. Loading a kprobe program needs both. |
CAP_NET_ADMIN | Loading the networking program types: SCHED_CLS, SCHED_ACT, XDP, the four LWT_*, SK_SKB, SK_MSG, FLOW_DISSECTOR, the five CGROUP_*, SOCK_OPS, NETFILTER, EXT (is_net_admin_prog_type()); creating SOCKMAP/SOCKHASH/DEVMAP/DEVMAP_HASH/XSKMAP; and BPF_PROG_QUERY via bpf_net_capable() | Also in addition to CAP_BPF. Note CGROUP_SKB and SK_REUSEPORT are deliberately excluded — the source comments them /* always unpriv */ and “equivalent to SOCKET_FILTER. need CAP_BPF only”. |
CAP_SYS_ADMIN | The entire ID space: BPF_PROG_GET_NEXT_ID, BPF_MAP_GET_NEXT_ID, BPF_BTF_GET_NEXT_ID, BPF_LINK_GET_NEXT_ID, BPF_PROG_GET_FD_BY_ID, BPF_MAP_GET_FD_BY_ID, BPF_BTF_GET_FD_BY_ID, BPF_LINK_GET_FD_BY_ID, BPF_TASK_FD_QUERY, BPF_ENABLE_STATS | These call capable(CAP_SYS_ADMIN) directly, not bpf_capable(). This is the sharpest edge in the model: a CAP_BPF-only process can load programs and create maps but cannot enumerate or introspect the system’s BPF objects at all — which is why bpftool prog list needs root even where loading does not. |
The two program types that need no CAP_BPF when unprivileged_bpf_disabled is 0 are BPF_PROG_TYPE_SOCKET_FILTER and BPF_PROG_TYPE_CGROUP_SKB, spelled out directly in bpf_prog_load:
if (type != BPF_PROG_TYPE_SOCKET_FILTER &&
type != BPF_PROG_TYPE_CGROUP_SKB &&
!bpf_cap)
goto put_token;That two-type exception is the whole of “unprivileged BPF” as an ABI surface, and it is what unprivileged_bpf_disabled switches off.
What kernel.unprivileged_bpf_disabled actually changes
| Value | Effect | Reversible? |
|---|---|---|
0 | Unprivileged bpf() enabled: SOCKET_FILTER and CGROUP_SKB programs and the “unprivileged” map types load without CAP_BPF | n/a |
1 | Disabled — and latched. The handler refuses any subsequent write that is not 1, so the kernel cannot be returned to 0 without a reboot | No |
2 | Disabled, but an admin can still write 0 or 1 later | Yes |
The asymmetry is the counter-intuitive part: the larger value is the weaker lock. The mechanism is four lines of bpf_unpriv_handler in syscall.c:
int ret, unpriv_enable = *(int *)table->data;
bool locked_state = unpriv_enable == 1;
...
if (write && !capable(CAP_SYS_ADMIN))
return -EPERM;
...
if (locked_state && unpriv_enable != 1)
return -EPERM;locked_state is computed from the value already in place, so only a current value of 1 latches. The sysctl’s .extra1/.extra2 are SYSCTL_ZERO/SYSCTL_TWO, so 3 and above are rejected outright, and the variable’s initializer is IS_BUILTIN(CONFIG_BPF_UNPRIV_DEFAULT_OFF) ? 2 : 0 — which is exactly why the measured host reads 2 with CONFIG_BPF_UNPRIV_DEFAULT_OFF=y. Both the semantics and the default are stated in Documentation/admin-guide/sysctl/kernel.rst L1593–L1612, and the code agrees with the documentation on every point except the blanket -EPERM claim disproved above.
Note what the sysctl does not do. It is consulted in exactly two places in the whole file — map_create and bpf_prog_load — and both check it as a plain truthiness test (if (sysctl_unprivileged_bpf_disabled && !bpf_cap)). Values 1 and 2 are therefore identical to every code path that gates on it; they differ only in whether the sysctl can be written again. And it gates only object creation: reading a map you already have an fd for, attaching a link, or querying an object are untouched by it, which is the design note the source states in a comment — “other actions depend on fd availability and access to bpffs, so are dependent on object creation success.”
The File-Descriptor Object Model
The single most important semantic fact about bpf() is that every kernel BPF object is owned through file descriptors. BPF_MAP_CREATE, BPF_PROG_LOAD, BPF_BTF_LOAD, BPF_LINK_CREATE, and the *_GET_FD_BY_ID family all return a new fd; that fd is an anon_inode with O_CLOEXEC set automatically. The kernel keeps a reference count on each object, and the object is freed only when the last reference drops. References come from: open fds, pins in bpffs, and internal links from one object to another (a loaded program holds references to every map it uses, so the maps cannot disappear while the program is loaded).
The practical consequences:
- Programs and maps vanish when their loader exits — unless held. The man page states it plainly: “Generally, eBPF programs are loaded by the user process and automatically unloaded when the process exits” (bpf(2)). When your process dies, the kernel closes its fds, drops the refcounts, and reaps any object whose count hits zero. This is the canonical confusion for newcomers: a program “disappears” the moment the loader process ends. To outlive the process you must take another reference — by pinning to bpffs, or by handing the fd to a longer-lived holder, or (for attachments) by relying on the attachment itself to hold a reference.
- fds are inheritable and shareable. After
fork(2), the child shares the parent’s fds and therefore the same objects; the object survives as long as either holds it. fds can also be passed acrossSCM_RIGHTSUnix sockets, which is how privileged “BPF manager” daemons hand pre-loaded program fds to unprivileged consumers. - fd vs. ID. The fd is process-local and meaningless in another process; the ID is the system-wide stable handle (
bpftool prog show id 42). Turning an ID back into an fd (BPF_PROG_GET_FD_BY_ID) is itself a privileged operation — this is the boundary that stops an unprivileged process from grabbing another’s programs by guessing IDs.
Concretely, map_create initializes two counters — atomic64_set(&map->refcnt, 1); atomic64_set(&map->usercnt, 1); — and closing a map fd runs bpf_map_release → bpf_map_put_with_uref(), which drops both. refcnt is the true lifetime counter and is also taken by every loaded program that references the map; usercnt counts only userspace holders and is what lets the kernel free a map’s memory-hungry parts once no process can reach it any more, even while a running program still holds a refcnt. The final atomic64_dec_and_test(&map->refcnt) frees the id, then hands the map to RCU or a workqueue for teardown.
stateDiagram-v2 [*] --> Created: BPF_MAP_CREATE / BPF_PROG_LOAD /<br/>BPF_BTF_LOAD / BPF_LINK_CREATE<br/>returns an anon_inode fd (O_CLOEXEC)<br/>refcnt = 1, usercnt = 1, global id assigned Created --> Shared: fork() — child inherits the fd<br/>SCM_RIGHTS — fd sent over a unix socket<br/>dup() — same file, same refs Shared --> Created: every extra fd closed Created --> Pinned: BPF_OBJ_PIN(path)<br/>bpffs dentry takes a reference Pinned --> Created: unlink(path) on bpffs Pinned --> Pinned: BPF_OBJ_GET(path) mints a fresh fd<br/>(process-local, same object) Created --> Referenced: a loaded program references<br/>this map (fd_array at PROG_LOAD),<br/>or a link holds this program Referenced --> Created: the referencing object is freed Created --> Reaped: last fd closed, no pin,<br/>no in-kernel reference<br/>refcnt hits 0 Pinned --> Pinned: process exits — fd closes,<br/>the pin keeps the object alive Referenced --> Referenced: process exits —<br/>the attachment keeps it alive Reaped --> [*]: bpf_map_free_id(), then<br/>RCU / workqueue teardown note right of Reaped This is the newcomer trap: "my program disappeared when the loader exited" is not a bug, it is refcnt reaching zero. end note
The lifetime of any bpf()-created object as a state machine over its reference count, drawn from map_create, bpf_map_release, and bpf_map_put in v6.12 kernel/bpf/syscall.c. What it shows: there is no “unload” command anywhere in the thirty-seven — an object leaves the kernel only when its reference count falls to zero, and references come from exactly three sources: open file descriptors (including inherited and SCM_RIGHTS-passed ones), bpffs pins, and other BPF objects. The insight to take: the three arrows back from Pinned and Referenced to Created are the only ways to reduce a reference you cannot see in your own fd table, which is why bpftool reporting a live program you cannot find an owner for is normal — the owner is a pin or an attachment, not a process.
This fd-centric model is why BPF links were introduced: the older BPF_PROG_ATTACH left an attachment that wasn’t owned by any fd (it lived in, say, a cgroup), so cleanup was ad hoc and racy. A link is an fd that owns an attachment and detaches it on close(), folding attachment into the same refcounted-fd discipline as everything else.
Failure Modes and Error Semantics
bpf() returns a new fd or 0 on success and -1 with errno set on failure. The errors are dense but legible once you know the model:
EACCESfromBPF_PROG_LOADis the verifier rejection — the program could not be proven safe. The why is in yourlog_buf; if you forgot to pass a log buffer you getEACCESwith no explanation, which is the single most common “the verifier hates me and won’t say why” trap. Always pass a log buffer (libbpf does, and re-loads with a bigger one on truncation).EPERMis a privilege failure — you lackCAP_BPF(orCAP_NET_ADMIN/CAP_PERFMONfor the relevant program/map type, orCAP_SYS_ADMINfor the ID-space commands), or unprivileged BPF is disabled. See CAP_BPF and BPF Privilege Model and Unprivileged BPF and Its Restrictions; note that the man page’s “CAP_SYS_ADMIN required pre-4.4” framing is historical — the current 6.12 model is the split-capability one (bpf_capable(),is_net_admin_prog_type(),is_perfmon_prog_type()gate distinct subsets). The converse does not hold: the absence ofEPERMis not evidence that you had privilege, because most handlers validate arguments first — see the measured sweep above, where only 11 of 37 commands reached a capability check at all.E2BIGmeans either the program exceeds the instruction limit or — at the syscall boundary — yourattrtail-zero check failed because you set a field this kernel doesn’t know (you compiled against a newer header than the running kernel supports for that feature). Confirmed against the v6.12 source:bpf_check_uarg_tail_zeroreturns-E2BIGboth whenactual_size > PAGE_SIZEand when the bytes past the kernel’sexpected_sizeare not all zero (v6.12syscall.c).EINVALis the catch-all for a malformedattr: an unknowncmd, an out-of-rangemap_type, a non-zeroed reserved field thatCHECK_ATTRcaught, or asizethat doesn’t make sense.ENOENTfrom the map-element commands means the key wasn’t found (or, forGET_NEXT_KEY, you were already at the last key).EEXIST/ENOENTfromUPDATE_ELEMreflect theBPF_NOEXIST/BPF_EXISTflags.E2BIGfromUPDATE_ELEMmeans a fixed-capacity map is full.EBADFis a stale or wrong fd;EFAULTis a bad userspace pointer (e.g. akey/valuepointer outside your address space).
Why Nobody Calls It Directly
In principle you can drive bpf() with syscall(SYS_bpf, BPF_PROG_LOAD, &attr, sizeof(attr)) and hand-assembled bytecode — and the kernel selftests do exactly that to exercise edge cases. In practice this is almost never done, for three reasons. First, the union is a moving target: getting every field, flag, and size right by hand, across kernel versions, is exactly the toil that the tail-zero mechanism exists to paper over for a library, not for you. Second, bytecode authorship is miserable: real programs are written in C and compiled by Clang, and the compiled ELF needs map creation, relocation, and BTF handling before the BPF_PROG_LOAD arm can even be filled in. Third, the wrappers are excellent: libbpf provides both thin one-call wrappers (bpf_map_create(), bpf_prog_load(), bpf_link_create() in bpf.h, each a direct shim over one cmd) and the full high-level object/skeleton API. So the realistic stack is: your code calls libbpf → libbpf fills bpf_attr and calls bpf(). The raw syscall is the foundation everyone stands on and almost no one touches.
Uncertain
Verify: that no
bpf_cmdwas renamed or semantically changed (not merely added) between 6.12 and 6.18 beyond the singleBPF_PROG_STREAM_READ_BY_FDaddition, and that no command’sCMD_LAST_FIELDmoved. Reason: the enum was counted precisely atv6.12,v6.18,v7.1andv7.2(2026-09-12, all HTTP 200), and theSYSCALL_DEFINEarity was existence-checked across the same span, but only the enum tail and the syscall prototype were diffed — not every command’sbpf_attrarm. To resolve: diffinclude/uapi/linux/bpf.hbetweenv6.12andv6.18in full and re-derive theCMD_LAST_FIELDtable at the newer tag.#uncertain
Uncertain
Verify: the exact first-failing check for
BPF_PROG_ATTACH(value 8) andBPF_OBJ_GET_INFO_BY_FD(value 15) under a zeroed attr. Reason: both were measured to returnEINVALon Linux 7.1.13, and both handlers begin withCHECK_ATTR, but the zeroed attr passesCHECK_ATTRby construction, so theEINVALmust come from a later validation that was not traced line by line in the v6.12 source. To resolve: readbpf_prog_attachandbpf_obj_get_info_by_fdend to end at the measured kernel’s tag (v7.1), not only at thev6.12pin.#uncertain
The author’s own rationale is recoverable. lore.kernel.org returns HTTP 403 to plain curl from this environment, but git.kernel.org’s cgit is a working substitute: ?qt=grep&q=BPF_COMMON_ATTRS on the torvalds tree returns a real 4,133-byte commit list, and /patch/?id=<full 40-char sha> returns the full message. Commit f28771c0691bcb7f477a0f35550b17b88c32dea8, “bpf: Extend BPF syscall with common attributes support” by Leon Hwang (2026-05-12, applied by Alexei Starovoitov), states the intent directly:
Add generic BPF syscall support for passing common attributes.
The initial set of common attributes includes:
'log_buf': User-provided buffer for storing logs.'log_size': Size of the log buffer.'log_level': Log verbosity level.'log_true_size': Actual log size reported by kernel.The common-attribute pointer and its size are passed as the 4th and 5th syscall arguments. A new command bit,
'BPF_COMMON_ATTRS'('1 << 16'), indicates that common attributes are supplied.This commit adds syscall and uapi plumbing. Command-specific handling is added in follow-up patches.
So the motivation is exactly the one the shape suggests: the verifier log buffer was a per-arm field, duplicated in PROG_LOAD and BTF_LOAD and unavailable to every other command, and BPF_COMMON_ATTRS promotes it to a syscall-wide facility so any command can report a diagnostic log. The same commit widens sys_bpf in include/linux/syscalls.h from three parameters to five.
See Also
- libbpf and the BPF Loader — the userspace library that fills
bpf_attrand callsbpf()for you; the practical reason this raw ABI is rarely touched - BPF Object Pinning and Lifetime — how
BPF_OBJ_PIN/BPF_OBJ_GETand bpffs let fd-owned objects outlive their creating process - BPF Links and Attachment Lifecycle —
BPF_LINK_CREATE/UPDATE/DETACHand why links fold attachment into the refcounted-fd model - BPF Maps — the objects created by
BPF_MAP_CREATEand driven by theBPF_MAP_*_ELEMcommands - eBPF Verifier — what
BPF_PROG_LOADtriggers before a program is accepted - BTF (BPF Type Format) — uploaded via
BPF_BTF_LOAD; referenced by map and program arms - CAP_BPF and BPF Privilege Model · BPF Token and Privilege Delegation — who may issue these commands;
BPF_TOKEN_CREATE - BPF JIT Compiler · JIT Hardening and Constant Blinding — what happens to a program after the verifier accepts it; the
net.core.bpf_jit_*sysctls this note could only partially read - BPF Program Types — the
prog_typevalues that decide which ofCAP_BPF/CAP_PERFMON/CAP_NET_ADMINaBPF_PROG_LOADneeds - kprobe and uprobe BPF Programs — the
CAP_PERFMONclass in practice; built on kprobes and uprobes - BPF Skeletons and bpftool — the generated wrappers around these commands, and why
bpftool prog listneedsCAP_SYS_ADMIN - Linux eBPF MOC — parent map of content