Report bpf(2) access to our maps by strangers - #425
nicholasberlin wants to merge 1 commit into
Conversation
1c3c517 to
5901dc3
Compare
8fec073 to
f80f684
Compare
5901dc3 to
a2145d3
Compare
f80f684 to
7053a98
Compare
a2145d3 to
65c6502
Compare
65c6502 to
a0b547d
Compare
|
why not monitor all bpf() syscalls? I think we had an idea (or Protections had) to do just that - event on every bpf() for observability purposes |
a0b547d to
d21177e
Compare
9040906 to
b3231bb
Compare
d21177e to
5e02bd7
Compare
A root process that can reach the trusted pids map can suppress
observations of itself: insert its tgid, act, remove the entry. The
map can't be hidden from root and read-only-for-programs does not
cover the syscall route, so make that route loud instead. The same
goes for every other map: freezing them stops syscall writes, but a
stranger's BPF program can still write them, and to reference one it
first has to fetch an fd through bpf(2), which is observable.
Add QQ_TAMPER and QUARK_EV_TAMPER. Two tracepoints on bpf(2) entry
and exit watch for any process other than the consumer reaching one
of our maps: element lookups, updates, deletes and get_next_key, the
batch variants, freeze, get_info_by_fd, and get_fd_by_id. The fd is
resolved through the caller's file table at entry to the map's kernel
id; the event is emitted at exit so it carries the kernel's answer.
The key, a tgid, is copied when the command has one. The consumer's
own updates are never reported, the existing consumer_pid rodata
identifies it. Arguments and return value are read through
SYSCALL_ENTER_ARG()/SYSCALL_EXIT_RET(), never the record layout, see
Which ids are ours is a small hash map, elastic_ebpf_events_map_ids,
that userspace fills right after load with the id of every created
map, itself included. It is created read-only for programs and frozen
with the rest, so nobody can edit the set; on kernels without freeze
an edit is at least reported, since the set contains its own id.
Whether an fd is a map at all is decided from the name of its
anonymous inode's dentry, "bpf-map": every bpf object carries its kind
there, exactly and without a kernel symbol. Guessing from the struct
layout misfires, a btf header keeps its length where a map keeps its
id, so a bpftool run on a host whose map id 24 happened to be ours
would have reported every BTF lookup as tampering.
quark_event{} gets a tamper member with map_id, map_name (owned by
the queue), cmd (enum bpf_cmd), flags, key and ret. quark-mon gets
-a. Manual pages and CHANGES are updated. The Go bindings are left
for a follow up, as was done for mprotect.
t_tamper checks the consumer's own add and reset produce nothing,
then forks a child that does what the published bypass does: walk
map ids, take an fd to the trusted map, insert its tgid, delete it,
then try to write sk_to_tgid, then ask for info on every BTF object
on the host. The walks touch every one of our maps, so every reported
map must be nameable and only reconnaissance commands may appear on
maps the child did not write. On the trusted map it expects
reconnaissance, then MAP_UPDATE_ELEM and MAP_DELETE_ELEM with the
child's tgid as key and a zero return, then the sk_to_tgid write with
EPERM where the kernel freezes maps, and nothing at all from the BTF
walk. sk_to_tgid is located before the trusted map is touched, so the
test does not depend on the order the kernel handed out map ids.
Verified in the krun VMs on Fedora 43, RHEL 8.5, RHEL 9.3 and Ubuntu
22.04. With the ring buffer write removed from the exit hook the test
times out, so it pins the kernel side; the userspace plumbing cannot
be reverted independently without breaking the build.
b3231bb to
8a2c26f
Compare
This is just me attempting to mitigate map tampering, haven't spoken with anyone on protections beyond the initial discussion when the blog dropped. The bot's reaction is that bpf is a high volume syscall, but that doesn't seem right to me. Adding a tamper check to more generic bpf events would be straight forward. I'll spin up some research/POCs |
|
@stanek-michal had the bot POC a bpf event PR: #435 My first take: it's a lot of code. |
Stacked on #424: it reuses that PR's
find_trusted_map()test helper. Merge #424 first, then I will retarget this one to main.Motivation
A root process that can reach the trusted pids map can suppress observations of itself: insert its tgid, act, remove the entry. See https://matheuzsecurity.github.io/hacking/elastic-trusted-pid-bypass/
The map can't be hidden from root, and read-only-for-programs does not cover the
bpf(2)route. So make that route loud instead: every time a process other than the consumer reaches one of quark's maps throughbpf(2), emit an event saying who, which map, which command, which key, and what the kernel answered. Root can still do it, just not quietly.This covers every map, not only the trusted one. #424 freezes the state maps against syscall writes, but a stranger's BPF program can still write them, and to reference a map it first has to fetch an fd through
bpf(2). That fetch is now reported.Change
New
QQ_TAMPERflag andQUARK_EV_TAMPERevent, EBPF only.Which ids are ours is a small hash map,
elastic_ebpf_events_map_ids, that userspace fills right after load with the id of every created map, itself included. It is created read-only for programs and frozen with the rest, so nobody can edit the set. On kernels without freeze an edit is at least reported, since the set contains its own id.Kernel side, two tracepoints on
bpf(2)entry and exit inProcess/Probe.bpf.c:MAP_LOOKUP_ELEM,MAP_UPDATE_ELEM,MAP_DELETE_ELEM,MAP_GET_NEXT_KEY,MAP_LOOKUP_AND_DELETE_ELEM, the four*_BATCHvariants,MAP_FREEZE,OBJ_GET_INFO_BY_FD, andMAP_GET_FD_BY_IDwith the map's id.bpf-map: every bpf object carries its kind there, exactly and without needing a kernel symbol. Only then is the map's kernel id read and looked up in the id set. The event is emitted at exit so it carries the return value. The key is copied when the command has one.consumer_pidrodata identifies it.SYSCALL_ENTER_ARG()andSYSCALL_EXIT_RET()from Read syscall tracepoint arguments through CO-RE #422, not a hand written record layout.private_dataregardless of kind and argued a wrong-kind read was random garbage. It is not: astruct btfkeeps its header length, always 24, where astruct bpf_mapkeeps its id on 6.2+ kernels, so anybpftoolrun on a host whose map id 24 happened to be quark's would have reported every BTF lookup as tampering. The dentry name check replaces that.Library side:
quark_event{}gets atampermember withmap_id,map_name(owned by the queue, valid until close),cmd(enum bpf_cmd),flags,keyandret.quark-mongets-a. Manual pages andCHANGESupdated.Not in this PR: Go bindings, left for a follow up as was done for mprotect. Also not covered: map handles obtained other than through
bpf(2)on this host, for example an fd inherited or passed over a socket; and access to our programs rather than our maps, for exampleOBJ_GET_INFO_BY_FDon a program fd, which is how the published bypass found the map ids in the first place.Test
t_tamperchecks the consumer's own add and reset produce nothing, then forks a child that does what the published bypass does: walk map ids, take an fd to the trusted map, insert its tgid, delete it, then try to writesk_to_tgid, then ask for info on every BTF object on the host. The walks touch every one of our maps, so every reported map must be nameable and onlyMAP_GET_FD_BY_IDandOBJ_GET_INFO_BY_FDmay appear on maps the child did not write. On the trusted map it expects reconnaissance, thenMAP_UPDATE_ELEMandMAP_DELETE_ELEMwith the child's tgid as key and a zero return, then thesk_to_tgidwrite returningEPERMwhere the kernel freezes maps, and nothing at all from the BTF walk.sk_to_tgidis located before the trusted map is touched, so the test does not depend on the order the kernel handed out map ids.Verification
krun VMs,
quark-test -v t_tamper:Negative: with the ring buffer write removed from the exit hook, the test times out in
drain_for_pid, so it pins the kernel side. The userspace plumbing cannot be reverted independently without breaking the build.Full eBPF suite (
quark-test -b) and a fullmakeincluding the Go bindings pass on Fedora 43.