Skip to content

Lock down the eBPF maps against bpf(2) strangers - #424

Merged
nicholasberlin merged 1 commit into
mainfrom
trusted-map-rdonly-prog
Sep 23, 2026
Merged

nicholasberlin merged 1 commit into
mainfrom
trusted-map-rdonly-prog

Conversation

@nicholasberlin

@nicholasberlin nicholasberlin commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Motivation

The trusted pids map is written from userspace and only ever read by the probes, yet nothing stopped a BPF program from writing to it. A privileged process can still update the map through bpf(2), but with BPF_F_RDONLY_PROG it can no longer smuggle a writer in as BPF code: the verifier refuses any program that writes the map, ours or a stranger's.

Every other map except the ring buffer is the opposite case: written only by the probes, never by userspace. Freezing them with BPF_MAP_FREEZE means no process can plant or erase quark's state through bpf(2). Without that a stranger could pre-mark its own mprotect transitions as seen in mprotect_seen, or poison sk_to_tgid to misattribute connections.

This does not address the bpf(2) update route to the trusted map itself, which is the subject of #425.

Change

bpf_queue.c:

  • Probe once with a throwaway hash map for BPF_F_RDONLY_PROG and BPF_MAP_FREEZE. Both arrived in Linux 5.2 and older kernels reject them outright. On such kernels the maps are left as they were, with a debug log line.
  • BPF_F_RDONLY_PROG on elastic_ebpf_events_trusted_pids and on elastic_ebpf_events_init_buffer, which is only ever copied from.
  • After load, bpf_map_freeze() on every map except the trusted map and the ring buffer. libbpf's internal maps (.rodata) are skipped, libbpf freezes those itself. Maps never created because autocreate is off are skipped by that flag, since libbpf still hands them a placeholder fd.
  • Only EINVAL from the probe means an old kernel; anything else is warned about. The memlock bump now runs before the probe so a tight RLIMIT_MEMLOCK cannot silently disable the hardening.

CHANGES entry.

Test

t_trusted_map_rdonly finds the trusted map the way a stranger would, by walking every map id and matching type, sizes, entry count and name prefix (the kernel truncates names to 15 characters, so every elastic_ebpf_events_* map shares one), then checks:

  • the flag is set on the live map
  • userspace writes still work, both the library calls and a raw bpf(2) update and delete
  • a hand assembled program calling map_lookup_elem on the map loads, while the same program calling map_update_elem is refused with EACCES

t_map_freeze checks a raw bpf(2) update and delete on sk_to_tgid fail with EPERM, and that the init buffer both refuses a write and carries BPF_F_RDONLY_PROG.

Both skip on kernels without the features.

Verification

krun VMs, quark-test -v t_trusted_pid t_trusted_map_rdonly t_map_freeze:

Kernel Result
Fedora 43, 7.2.5 ok
RHEL 8.5, 4.18.0-348 ok (both backported, not skipped)
RHEL 9.3, 5.14.0-362 ok
Ubuntu 22.04, 6.8.0-138 ok

Negative: with only the trusted map flag reverted, t_trusted_map_rdonly aborts on the map_flags assertion. With only the freeze loop skipped, t_map_freeze aborts on the EPERM assertion. Each test pins its half.

Full eBPF suite (quark-test -b) passes on Fedora 43.

@nicholasberlin
nicholasberlin requested a review from a team as a code owner September 14, 2026 20:49
@nicholasberlin
nicholasberlin marked this pull request as draft September 14, 2026 20:55
@nicholasberlin nicholasberlin changed the title Make the trusted pids map read-only for programs Lock down the eBPF maps against bpf(2) strangers Sep 14, 2026
@christos68k

Copy link
Copy Markdown
Member

@nicholasberlin Ping me if this is ready for review (it's still marked draft)

@nicholasberlin

Copy link
Copy Markdown
Contributor Author

Ping me if this is ready for review (it's still marked draft)

This is ready for review @christos68k (thanks)

Comment thread bpf_queue.c
* buffer is only ever copied from, by anyone, so it gets the flag too
* and is frozen below.
*/
harden = bpf_map_harden_supported();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

comment said we want to bump memlock first, then check with probe, but here it's reversed?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch. Force pushed a fix, here's the compare link

Comment thread bpf_queue.c Outdated
Comment thread bpf_queue.c
Comment thread bpf_queue.c Outdated
christos68k
christos68k previously approved these changes Sep 23, 2026
Comment thread bpf_queue.c
Comment thread bpf_queue.c
The trusted pids map is written from userspace and only ever read by
the probes, yet nothing stopped a BPF program from writing to it. A
privileged process can still update the map through bpf(2), but with
BPF_F_RDONLY_PROG it can no longer smuggle a writer in as BPF code:
the verifier refuses any program that writes the map, ours or a
stranger's.

Every other map except the ring buffer is the opposite case: written
only by the probes, never by userspace. Freeze them after load with
BPF_MAP_FREEZE so no process can plant or erase our state through
bpf(2). Without that a stranger could pre-mark its own mprotect
transitions as seen in mprotect_seen, or poison sk_to_tgid to
misattribute connections. The init buffer is only ever copied from,
so it gets both the flag and the freeze. libbpf's internal maps
(.rodata) are skipped, libbpf freezes those itself; so are maps that
were never created, which still carry a placeholder fd.

Both mechanisms arrived in Linux 5.2 and older kernels reject them
with EINVAL, so probe with a throwaway map first and leave the maps
as they were on kernels that don't know them. Any other probe failure
is warned about rather than mistaken for an old kernel, and the
memlock bump moves ahead of the probe so a tight RLIMIT_MEMLOCK can't
fail it. The RHEL 8.5 CI kernel (4.18.0-348) carries the backports and
takes both. Freezing is best effort: the probe only proves a plain
hash map freezes and a backport could still refuse another type, so a
failed freeze is warned about and that map left as it was, rather
than refusing to open.

t_trusted_map_rdonly finds the trusted map the way a stranger would,
by walking every map id and matching type, sizes and name prefix,
then checks the flag is set, that userspace writes still work (both
the library calls and a raw bpf(2) update) and that a hand assembled
program calling map_lookup_elem loads while the same program calling
map_update_elem is refused with EACCES. t_map_freeze checks a raw
bpf(2) update and delete on sk_to_tgid and on the init buffer fail
with EPERM, and that the init buffer carries BPF_F_RDONLY_PROG. Both
skip on kernels without the features.

Verified in the krun VMs on Fedora 43, RHEL 8.5, RHEL 9.3 and Ubuntu
22.04. With only the trusted map flag reverted t_trusted_map_rdonly
aborts on the map_flags assertion; with only the freeze loop skipped
t_map_freeze aborts on the EPERM assertion, so each test pins its
half.
@nicholasberlin

Copy link
Copy Markdown
Contributor Author

@christos68k and @stanek-michal thanks for the reviews, the follow-on PRs revealed a bug in the test code. Some stack garbage needing clearing.

@nicholasberlin
nicholasberlin merged commit 2db0993 into main Sep 23, 2026
2 checks passed
@nicholasberlin
nicholasberlin deleted the trusted-map-rdonly-prog branch September 23, 2026 15:01
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.

3 participants