Skip to content

Build the eBPF programs in a shape the kernel can load - #293

Merged
seanwevans merged 2 commits into
mainfrom
claude/festive-johnson-fd03cl-bpf-loadable
Sep 17, 2026
Merged

seanwevans merged 2 commits into
mainfrom
claude/festive-johnson-fd03cl-bpf-loadable

Conversation

@seanwevans

Copy link
Copy Markdown
Owner

Second of a series of independent PRs. Orthogonal to #292 — no overlapping files.

The problem

The three programs under pyisolate/bpf/ could not be loaded on any kernel. The tests covering them asserted that certain strings appeared in the .bpf.c sources, which passes equally well for a program the verifier rejects:

assert 'SEC("lsm/file_open")' in src
assert "bpf_get_current_cgroup_id" in src

Three defects, each independently fatal to a load.

1. No BTF. The compile command omitted -g. All three programs use BTF-defined maps (struct { ... } name SEC(".maps")), which are described entirely by their BTF type — libbpf cannot parse the .maps section of an object built without it. LSM programs additionally need BTF to resolve their attach target.

2. LSM handlers read an uninitialised register. Every handler declared typed parameters without libbpf's BPF_PROG wrapper to unpack them:

int BPF_PROG_filter_file_open(void *file, int ret)   /* the name is not the macro */

An LSM program is called with one argument — a pointer to the hook's argument array — so ret compiled to a read of r2, which the caller never sets. Disassembling the old object:

0000000000000000 <BPF_PROG_filter_file_open>:
       0:  bf 20 ...   r0 = r2          <-- uninitialised

The verifier rejects that with R2 !read_ok. All nine hooks had it.

3. Placeholder maps. resource_guard.bpf.c declared two struct { int dummy; } entries in .maps. Those are not map definitions libbpf can parse, and one is enough to reject the whole object.

The fix

  • -g moved into a single BPFManager.COMPILE_FLAGS / _compile_command so the flag cannot drift between the three call sites.
  • Handlers take the context array and unpack by index, with the offset of the prior LSM decision named per hook (PYI_RET_socket_connect 3) so it can be checked against lsm_hook_defs.h. The entry now compiles to r0 = *(u64 *)(r1 + 0x8).
  • socket_connect copies sa_family with bpf_probe_read_kernel instead of dereferencing a kernel pointer that is not BTF-typed.
  • The placeholder maps and their two dead no-op programs (emit_breach, on_cpu) are gone. Per-cgroup CPU accounting was already done for real by account_sched_switch.

This also removes the comment claiming the five-register limit forced sb_mount to drop its last argument — indexing the context array has no such limit.

Testing

The tests now compile the sources with the manager's own command and inspect the resulting ELF:

  • .BTF / .BTF.ext present on both objects;
  • every LSM hook emits its own program section;
  • each LSM program's first instruction is a load through r1;
  • no program entry reads r2–r5 (checked at entry points only — inside .text that same move is an ordinary call argument);
  • the resource guard's .maps entries all carry a real BPF_MAP_TYPE_*;
  • test_load_runs_toolchain derives its expectation from _compile_command, so the -g flag cannot silently disappear again.

Full suite: 548 passed, 15 skipped. The one failure in my container, test_apply_confinement_installs_seccomp_and_allows_normal_syscalls, is pre-existing and environmental (seccomp unavailable) and reproduces on unmodified main. pre-commit run --all-files passes.

What this does not claim

I could not load these against a live kernel here — no bpftool, not root, and no BPF-LSM in this container. This makes the objects loadable-shaped and regression-tested, not verified end-to-end. A new root-gated test_verifier_accepts_the_filter_programs runs the compiled filter past the real verifier, but like the existing live test it needs PYISOLATE_LIVE_BPF_TESTS=1 and CI does not run it. Worth a privileged CI job; I've noted it in the changelog's known gaps rather than implying more coverage than exists.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse


Generated by Claude Code

The three programs under pyisolate/bpf/ could not be loaded on any kernel, and
the tests covering them asserted that certain strings appeared in the .bpf.c
sources, which passes equally well for a program the verifier rejects.

Three defects, each independently fatal to a load:

* The compile command omitted -g, so the objects carried no BTF. All three
  programs use BTF-defined maps, which are described entirely by their BTF
  type, so libbpf cannot parse the .maps section of an object built without it;
  LSM programs additionally need BTF to resolve their attach target.

* Every lsm/* handler declared typed parameters -- `int h(void *file, int ret)`
  -- without libbpf's BPF_PROG wrapper to unpack them. An LSM program is called
  with one argument, a pointer to the hook's argument array, so `ret` compiled
  to a read of r2, a register the caller never sets. Disassembly of the old
  lsm/file_open starts `r0 = r2`; the verifier rejects that with "R2 !read_ok".

* resource_guard.bpf.c declared two `struct { int dummy; }` placeholders in
  .maps. Those are not map definitions libbpf can parse, and one is enough to
  reject the whole object.

Handlers now take the context array and unpack it by index, with the offset of
the prior LSM decision named per hook so it can be checked against
lsm_hook_defs.h. socket_connect copies sa_family with bpf_probe_read_kernel
rather than dereferencing a kernel pointer that is not BTF-typed. The
placeholder maps and their two dead no-op programs are gone; per-cgroup CPU
accounting was already done for real by account_sched_switch.

The tests now compile the sources with the manager's own command and inspect
the resulting ELF: BTF sections are present, every LSM hook emits its own
program, each one enters with a load through r1, and no program entry reads an
uninitialised argument register. A root-gated test runs the compiled filter
past the real verifier.

Loading and attaching on a live kernel is still only covered by the
PYISOLATE_LIVE_BPF_TESTS=1 tests, which need root, bpftool and BPF-LSM; CI does
not run them. This makes the objects loadable-shaped and regression-tested, not
verified end-to-end.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fs1hTtmF4Hm9h617AG9Gse
@seanwevans
seanwevans merged commit d7e2dc3 into main Sep 17, 2026
10 of 22 checks passed
@seanwevans
seanwevans deleted the claude/festive-johnson-fd03cl-bpf-loadable branch September 17, 2026 01:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants