DEV Community

Adam Weber
Adam Weber

Posted on

eBPF

After spending the last few posts working through character drivers and proc entries, I decided it was time to take the plunge into eBPF. I'd heard the name thrown around in every security and observability discussion, but honestly had no idea what it actually was or why everyone seemed so excited about it. This post documents my first real encounter with eBPF and what I learned building a minimal process execution tracer.

What Even Is eBPF

eBPF stands for extended Berkeley Packet Filter, which is a confusing name because it does way more than just filter packets now. The core idea is that you can inject code into the running kernel without writing a traditional kernel module. That code runs in a sandboxed environment where the kernel verifies it's safe before loading it. If your BPF program would do something dangerous, it just fails to load instead of kernel panicking your system.

Coming from kernel modules where a bad pointer dereference means instant death, this felt almost too good to be true. You get kernel-level visibility and performance without the danger.

The Two-Part Architecture

What caught me off guard initially was that an eBPF program isn't just one file. It's actually two separate programs working together:

The kernel-side BPF program, written in restricted C and compiled to BPF bytecode. This is the code that runs inside the kernel when events happen.

The userspace loader, which is a normal C program that loads the BPF bytecode, attaches it to kernel hooks, and reads the data back out.

This split makes sense after I thought about it. The kernel-side code has to be heavily restricted for safety, so you can't just printf() or write to files from kernel space. The userspace program handles all the normal I/O and orchestration.

Building the Execve Tracer

I started with the simplest useful thing I could think of: tracing process execution. Every time a process calls execve() to start a new program, I wanted to see it.

The kernel-side code hooks into a tracepoint called tp/syscalls/sys_enter_execve. When that fires, my BPF program grabs the process ID and command name, then writes them to a ring buffer. The ring buffer is basically shared memory between kernel and userspace that both sides can access safely.

The userspace program loads this BPF code into the kernel, polls the ring buffer for events, and prints them to the terminal. Simple enough in theory.

The Compile Process

This is where things got interesting. The BPF program gets compiled with Clang to BPF bytecode, producing a .bpf.o file. That's not a normal executable, it's an ELF object file containing BPF instructions. The section names in that ELF file tell the loader what type of program it is and where to attach it.

For example, SEC("tp/syscalls/sys_enter_execve") creates an ELF section with that exact name. When the userspace program opens the .bpf.o file, libbpf parses those section names to figure out "oh, this is a tracepoint program that should attach to sys_enter_execve."

It's a clever way to encode metadata using standard ELF features instead of inventing something new. Listen to me I sound like I know what I'm talking about.

Testing It

After getting everything compiled, I ran the tracer with sudo (BPF programs need root to load). Then in another terminal I just ran normal commands: ls, ps, cat. They mostly all showed up as bash, until you run something like vim.

What really hit me was how lightweight this felt compared to kernel modules. No rebooting. No risking a kernel panic. If the BPF program had a bug, it just wouldn't load (believe me I found out). The kernel's verifier checks every instruction before allowing it to run.

Ring Buffers and Communication

The ring buffer deserves its own mention because it's the bridge between kernel and userspace. It's a fixed-size circular buffer that prevents memory issues. When it fills up, old events get overwritten. This ensures you're never allocating unbounded kernel memory or writing to invalid addresses.

The kernel-side code reserves space, writes the event, and submits it. The userspace code polls for new events and processes them. The whole thing is lock-free for most operations, which keeps it fast.

Why This Matters

eBPF is the foundation of modern system observability and security tools. Every major EDR (Endpoint Detection and Response) product uses it. Tracing tools like bpftrace are built on it. Understanding how to write BPF programs means understanding how these tools work under the hood.

For GhostScope specifically, this opens up the ability to watch syscalls, trace file operations, monitor network connections, and generally observe system behavior in ways that weren't practical before. All without maintaining a risky kernel module.

What's Next?
Not quite sure, stay tuned to find out.

Top comments (0)