opensnoop
Trace every file open with stable syscall tracepoints — read tracepoint arguments by offset, learn the user-vs-kernel memory-read distinction, and pair entry and exit to report which process opened which file with what result.
Chapters 7 and 8 attached to a kernel function. This chapter attaches
to tracepoints — stable, documented instrumentation points the
kernel developers maintain — and builds opensnoop, the classic tool
that shows every file open on the system: which process, which file,
what flags, success or failure. Along the way you learn to read
tracepoint arguments by offset and meet a distinction that bites
people: user memory versus kernel memory.
The code is in examples/09-opensnoop/. ./demo.sh there builds, deploys, and runs it; its README.md covers what it does and how to drive it.
Why tracepoints here
Syscalls have stable tracepoints (syscalls:sys_enter_openat,
syscalls:sys_exit_openat) with a documented argument format that the
kernel commits to keeping stable. That’s the opposite trade-off from a
kprobe: less reach (only where tracepoints exist), but no fragility
from kernel-internal struct layouts. For syscall tracing — which is
most observability work — tracepoints are the right default.
opensnoop watches openat, the syscall behind essentially all file
opens on a modern system. We pair the enter tracepoint (which has
the filename and flags) with the exit tracepoint (which has the
return value: the new fd, or a negative errno) using the same
pid_tgid HashMap bridge from Chapter 8.
Reading tracepoint arguments by offset
A tracepoint hands your program a record whose fields sit at fixed byte offsets. The kernel publishes the layout. On the target VM:
[vm]$ cat /sys/kernel/tracing/events/syscalls/sys_enter_openat/format
You’ll see fields with offset: annotations — after the 8-byte common
header and the __syscall_nr field, the openat args begin: dfd@16,
filename@24, flags@32, mode@40. The exit record has ret@16.
Aya reads them from the context:
let filename_ptr: *const u8 = ctx.read_at(24)?; // const char *filename
let flags: i32 = ctx.read_at::<i32>(32)?; // int flags
These offsets are long-stable on x86_64, but they are exactly the kind of value to verify against the format file rather than trust blindly — which is why the chapter has you read it. That’s the tracepoint discipline: stable, but confirm.
User memory vs. kernel memory
Here’s the subtlety. In Chapters 7–8 we read kernel pointers with
bpf_probe_read_kernel. But at syscall entry, the filename argument
is a pointer into the calling process’s user-space memory — the
program hasn’t copied it into the kernel yet. Reading it needs the
user variant:
bpf_probe_read_user_str_bytes(filename_ptr, &mut ev.filename);
Use the kernel reader on a user pointer (or vice versa) and you get an error or garbage. The rule of thumb: syscall arguments at entry are user memory; kernel structures are kernel memory. Getting this right is half of writing correct syscall probes.
How the code works
Two maps, two jobs
The kernel side (opensnoop-ebpf/src/main.rs) declares two maps, and the
choice of type for each is the whole design:
#[map] static INFLIGHT: HashMap<u64, OpenInfo> = HashMap::with_max_entries(10240, 0);
#[map] static EVENTS: RingBuf = RingBuf::with_byte_size(256 * 1024, 0);
INFLIGHT is a HashMap keyed by pid_tgid — scratch space that holds
what we learned at entry until the matching exit fires. EVENTS is a
RingBuf — the one-way kernel→user channel for completed opens. We
don’t emit at entry because we don’t yet know if the open succeeded; we
don’t keep state in the ring buffer because a ring buffer isn’t
addressable by key. Each map type is doing the one thing it’s good at.
Entry: stash what only entry knows
#[tracepoint]
pub fn sys_enter_openat(ctx: TracePointContext) -> u32 {
let pid_tgid = bpf_get_current_pid_tgid();
let filename_ptr: *const u8 = match unsafe { ctx.read_at(24) } { Ok(p) => p, _ => return 0 };
let flags: i32 = unsafe { ctx.read_at(32) }.unwrap_or(0);
let mut info = OpenInfo { flags, filename: [0u8; NAME_LEN] };
unsafe { let _ = bpf_probe_read_user_str_bytes(filename_ptr, &mut info.filename); }
let _ = INFLIGHT.insert(&pid_tgid, &info, 0);
0
}
Walking it: bpf_get_current_pid_tgid() returns a 64-bit value packing
the thread id (low 32) and process id (high 32) — it’s our correlation
key and it’s unique per in-flight syscall, because a thread can only be
inside one openat at a time. ctx.read_at(24) pulls the filename
argument out of the tracepoint record at the offset we read from the
format file; note we get a pointer, not the string — the string lives
in user memory (Figure 9.1), so bpf_probe_read_user_str_bytes copies it
into our stack-allocated info.filename. That helper is the user-memory
analogue of the kernel reader from Chapter 8, and it stops at the NUL or
the buffer end, so it’s bounded — which is what keeps the verifier happy.
Finally INFLIGHT.insert stashes the struct under pid_tgid. Nothing is
reported yet.
Exit: pair, decide, emit
#[tracepoint]
pub fn sys_exit_openat(ctx: TracePointContext) -> u32 {
let pid_tgid = bpf_get_current_pid_tgid();
let ret: i64 = unsafe { ctx.read_at(16) }.unwrap_or(-1);
let info = match unsafe { INFLIGHT.get(&pid_tgid) } { Some(i) => *i, None => return 0 };
let _ = INFLIGHT.remove(&pid_tgid);
if let Some(mut slot) = EVENTS.reserve::<OpenEvent>(0) {
let ev = OpenEvent {
pid: (pid_tgid >> 32) as u32,
uid: (bpf_get_current_uid_gid() & 0xffff_ffff) as u32,
ret, comm: bpf_get_current_comm().unwrap_or_default(),
flags: info.flags, filename: info.filename,
};
unsafe { *slot.as_mut_ptr() = ev; }
slot.submit(0);
}
0
}
The exit record carries the return value at offset 16 — the one
thing entry couldn’t know. INFLIGHT.get(&pid_tgid) finds the entry we
stashed; if it’s missing (we started tracing mid-syscall) we bail. We
remove immediately so the map doesn’t leak entries for syscalls we’ve
already reported. Then the emit pattern you’ll see in every event-based
chapter: EVENTS.reserve::<OpenEvent>(0) claims a slot in the ring
buffer sized for our struct (no copy, no allocation — you write straight
into kernel ring memory), we fill it — completing the record with pid,
uid, and comm that are cheap to get at exit — and slot.submit(0)
publishes it so user space can see it. If reserve returns None the
ring is full and we simply drop the event rather than block; losing an
event is always preferable to stalling the kernel.
User space: attach both, drain one
for (name, cat, tp) in [("sys_enter_openat", "syscalls", "sys_enter_openat"),
("sys_exit_openat", "syscalls", "sys_exit_openat")] {
let p: &mut TracePoint = ebpf.program_mut(name).unwrap().try_into()?;
p.load()?;
p.attach(cat, tp)?;
}
let mut ring = RingBuf::try_from(ebpf.map_mut("EVENTS").unwrap())?;
program_mut(name) looks the program up by the kernel function’s name;
the try_into::<&mut TracePoint>() is where Aya checks the program is
actually a tracepoint (a kprobe cast would fail here). load() is the
verifier gate — if the kernel rejects the program, it’s this call that
errors. attach(category, name) wires it to syscalls:sys_enter_openat.
Tracepoints need only those two strings — no symbol, no BTF — which is
the simplicity tracepoints buy you.
Reading is the same poll loop as Chapter 6, but over a RingBuf instead
of a per-CPU array:
while let Some(item) = ring.next() {
let ev: OpenEvent = unsafe { core::ptr::read_unaligned(item.as_ptr() as *const OpenEvent) };
let result = if ev.ret >= 0 { "ok" } else { "err" };
counter.add(1, &[KeyValue::new("program", "opensnoop"), KeyValue::new("result", result)]);
println!("{:<7} {:<6} {:<5} {:<16} {}", ev.pid, ev.uid, ev.ret,
cstr(&ev.comm), cstr(&ev.filename));
}
ring.next() yields each submitted record as a byte view; we
read_unaligned it back into an OpenEvent (unaligned because the ring
packs records tightly, with no padding guarantees). The sign of ret
becomes the result label, so one metric series splits cleanly into
successes and failures — ebpf_events_total{program="opensnoop",result="ok|err"}.
Build, deploy, observe
cd examples/09-opensnoop && ./demo.sh
Generate opens on the target — including a guaranteed miss so you see a
negative ret:
ssh fedora@"$(scripts/lab/vm-ip.sh ebpf-target)" 'cat /etc/hostname /etc/os-release /nope-$RANDOM 2>/dev/null; true'
A PID UID RET COMM FILE table fills, and the result label lets you
split opens into successes and failures in Grafana.
Heads up: openat is high-volume. Almost everything opens files constantly. On a busy target this tool produces a lot of events — which is realistic, and a good reason later chapters add in-kernel filtering (by PID, by filename prefix) so user space isn’t flooded. For now, watching the firehose makes the point that the kernel sees everything.
In Grafana (127.0.0.1:3000 → Explore), filter to the ebpf-opensnoop service and graph sum by (program) (rate(ebpf_events_total[1m])) — file opens, split out by program and result as a live rate, the same events your terminal lists, now plotted over time.
Cross-check
[vm]$ sudo opensnoop-bpfcc
The BCC tool is the C/libbpf implementation of the same idea. Running
it beside your Rust opensnoop and comparing output is the most direct
“is mine right?” check you can do — same events, same fields, different
language.
What you learned
- Tracepoints are stable syscall instrumentation; read their args by offset from the format file.
- Syscall arguments at entry are user memory —
bpf_probe_read_user_*, not the kernel reader. - The entry/exit
HashMapbridge from Chapter 8 generalizes cleanly to tracepoints.
Next, a shorter sibling — sigsnoop — one tracepoint, signal
tracing, and friendly name mapping.
Verification status: verified — Fedora 44, kernel 7.1.3. Built and run on the lab VM (Fedora 44, kernel 7.1.3-200.fc44): builds, loads, and attaches cleanly and runs without error. Confirmed on this kernel — attach targets and struct offsets can be version-specific.