BTF-assisted uprobe
Read a struct argument from a uprobe by following a pointer into the target's memory, and understand how BTF recovers a struct's layout so you can probe binaries you didn't write without hardcoding offsets.
Chapter 14 read a scalar argument — a u64 in a register. Real
functions take pointers to structs, and reading fields out of them
brings back a problem you met in the kprobe chapter: you need to know
the struct’s layout, and hardcoding offsets is fragile. This chapter
reads a struct argument the robust way, and explains how BTF —
the same type-information format that powers kernel CO-RE — lets you do
it even for binaries whose source you don’t have.
The code is in examples/15-btf-uprobe/. ./demo.sh there builds, deploys, and runs it; its README.md covers what it does and how to drive it.
Reading a struct argument
The target, process_order(const Order *order), is passed a pointer to
an Order { id, amount_cents, status }. A uprobe gets the pointer from
arg(0), but the pointer is just an address in the target process’s
memory — you can’t dereference it directly (the verifier forbids it,
same as kernel pointers). You copy the struct out:
let order_ptr: *const Order = ctx.arg(0).ok_or(0)?;
let order: Order = unsafe { bpf_probe_read_user(order_ptr)? };
bpf_probe_read_user::<Order>(ptr) copies size_of::<Order>() bytes
from the target’s address space into a local Order. The whole struct
comes across in one read; then you have a normal value to pull fields
from. That’s the mechanic — and it’s solid and works today.
The catch: layout has to match
bpf_probe_read_user::<Order> copies bytes and reinterprets them as
an Order. If your program’s idea of Order’s layout — field order,
sizes, padding — doesn’t match the target’s, you read garbage. In this
example we sidestep the problem: the target app, the eBPF program, and
user space all use the same Order definition from
btf-uprobe-common, with #[repr(C)] so the layout is fixed and
shared. Correct by construction.
But that only works because we wrote the target. The interesting real-world case is probing a binary you didn’t write — a database, a language runtime, a service whose source you don’t have. You can’t share its struct definition. So how do you know the layout?
BTF: the layout, recovered
BTF (BPF Type Format) is compact type information describing
structs, their fields, and their offsets. You met it as the kernel’s
/sys/kernel/btf/vmlinux in Chapter 2, and as what makes fentry’s typed
arguments and CO-RE’s relocations work (Chapters 5, 8). The same idea
applies to user-space binaries: if a binary carries BTF, it tells you
exactly how its structs are laid out.
Most binaries ship with DWARF debug info rather than BTF, but you can
convert: pahole -J (from Fedora’s dwarves package) reads a binary’s
DWARF and writes a .BTF section into it, which bpftool can then
dump. On the target VM:
[vm]$ sudo dnf install -y dwarves
[vm]$ pahole -J /home/fedora/target-app
[vm]$ bpftool btf dump file /home/fedora/target-app | grep -iA4 order
That dump shows Order with its members and their byte offsets. That
is the ground truth you’d use to write a correct #[repr(C)] mirror for
a target you don’t control — and, with the emerging user-space CO-RE
support, the relocation source that lets the loader fix up offsets at
attach time so your probe keeps working even if the target’s struct
changes between versions.
Scope note. Kernel CO-RE (relocating against
vmlinuxBTF) is mature and turnkey in Aya. User-space CO-RE — relocating against a target binary’s BTF — is newer and less push-button. The reliably-works-today technique is the one in this chapter: a#[repr(C)]mirror (shared when you control the source, or generated from the target’s BTF when you don’t), read withbpf_probe_read_user. BTF is what makes that mirror correct and lets you detect when a target’s layout has drifted out from under you. The full relocation story is the CO-RE deep-dive in Part 9.
Build, deploy, observe
cd examples/15-btf-uprobe && ./demo.sh
The demo ships target-app to the VM (built with debug info so it
carries DWARF), starts it submitting an order every half second, and
attaches the uprobe. You’ll see the struct fields, decoded:
PID ID AMOUNT STATUS
12345 1000 $0.00 received
12345 1001 $9.99 paid
12345 1002 $19.98 shipped
The status enum, the amount_cents integer formatted as currency —
all read live out of the struct the target passed, by a probe in the
kernel. ebpf_events_total{program="btf-uprobe",status=…} breaks the
orders down by status in Grafana.
In Grafana (127.0.0.1:3000 → Explore), filter to the ebpf-btf-uprobe service and graph sum by (program) (rate(ebpf_events_total[1m])) — probed calls with their decoded struct fields as a live rate, the same events your terminal lists, now plotted over time.
Cross-check
[vm]$ bpftool btf dump file /home/fedora/target-app | grep -iA4 order
If the offsets in the BTF dump match how your #[repr(C)] Order lays
out (id at 0, amount_cents at 8, status at 16), your reads are
reading the right bytes. When they don’t match — different compiler,
different version, #[repr(Rust)] reordering — that mismatch is exactly
what BTF exists to catch.
What you learned
- Read a struct argument by taking the pointer from
arg(0)and copying it withbpf_probe_read_user::<T>— never dereference target pointers directly. - The copy is only correct if your
#[repr(C)]layout matches the target’s; sharing the definition is the easy case. - BTF recovers a binary’s struct layout (
pahole -J+bpftool btf dump), which is how you build a correct mirror — and eventually relocate offsets — for targets you don’t control.
Next, the part turns to language runtimes and userspace targets in
earnest (Java/Python bootstrap, then sslsniff, funclatency).
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.