eBPF programs aren’t just simple scripts; they’re compiled bytecode that runs in a highly secure, sandboxed virtual machine within the Linux kernel itself.

Let’s see eBPF in action by writing a program that counts how many times the sys_enter_execve syscall is invoked. This is a fundamental building block for security monitoring and performance analysis.

First, we need a way to compile our eBPF program. We’ll use clang with a specific target for eBPF.

# Install clang if you don't have it
# sudo apt-get update && sudo apt-get install clang llvm

# Save the following as execve_counter.bpf.c
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>

struct {
    __uint(type, BPF_MAP_TYPE_ARRAY);
    __uint(max_entries, 1);
    __type(key, u32);
    __type(value, u64);
} execve_count_map SEC(".maps");

SEC("kprobe/sys_enter_execve")
int kprobe_execve_entry(struct pt_regs *ctx) {
    u32 key = 0;
    u64 *count;
    count = bpf_map_lookup_elem(&execve_count_map, &key);
    if (count) {
        __sync_fetch_and_add(count, 1);
    }
    return 0;
}

char _license[] SEC("license") = "GPL";

This C code is written to be compiled into eBPF bytecode.

  • vmlinux.h provides kernel data structures.
  • bpf/bpf_helpers.h gives us access to eBPF helper functions.
  • execve_count_map is an eBPF map, a key-value store that eBPF programs can use to share data with userspace or other eBPF programs. Here, it’s an array map with a single entry to store our counter.
  • SEC("kprobe/sys_enter_execve") marks the kprobe_execve_entry function to be attached to the sys_enter_execve kernel tracepoint. A kprobe allows us to hook into the entry point of a kernel function.
  • Inside the probe, we look up our counter in the map. If it exists, we atomically increment it using __sync_fetch_and_add.
  • _license is mandatory for eBPF programs.

Now, let’s compile it.

clang -target bpf -O2 -g -c execve_counter.bpf.c -o execve_counter.bpf.o

This compiles the C code into an LLVM IR object file, which is the format bpftool can understand.

We’ll need a userspace loader to load this eBPF object file into the kernel and to read the counter.

# Install bpftool if you don't have it
# sudo apt-get update && sudo apt-get install linux-tools-common linux-tools-$(uname -r)

# Save the following as loader.c
#include <stdio.h>
#include <unistd.h>
#include <signal.h>
#include <sys/resource.h>
#include <bpf/libbpf.h>
#include "execve_counter.skel.h" // Generated by bpftool

static volatile sig_atomic_t stop;

void sig_handler(int sig) {
    stop = 1;
}

int main(int argc, char **argv) {
    struct execve_bpf_bpf_obj *skel;
    int err;
    FILE *f;
    char line[64];
    unsigned int key = 0;
    u64 value;

    // Increase RLIMIT_MEMLOCK to allow BPF maps to be created
    struct rlimit rlim = {RLIM_INFINITY, RLIM_INFINITY};
    if (setrlimit(RLIMIT_MEMLOCK, &rlim)) {
        fprintf(stderr, "Failed to set RLIMIT_MEMLOCK: %d\n", err);
        return 1;
    }

    // Open, load and verify BPF application
    skel = execve_bpf_bpf__open();
    if (!skel) {
        fprintf(stderr, "Failed to open BPF skeleton\n");
        return 1;
    }

    err = execve_bpf_bpf__load(skel);
    if (err) {
        fprintf(stderr, "Failed to load and verify BPF skeleton\n");
        goto cleanup;
    }

    // Attach tracepoint handler
    err = execve_bpf_bpf__attach(skel);
    if (err) {
        fprintf(stderr, "Failed to attach BPF skeleton\n");
        goto cleanup;
    }

    printf("Successfully started! Running, press Ctrl+C to stop.\n");
    printf("%-20s %s\n", "EXECVE CALL COUNT", "VALUE");

    signal(SIGINT, sig_handler);
    signal(SIGTERM, sig_handler);

    while (!stop) {
        sleep(1);
        // Read the counter from the map
        if (bpf_map_lookup_elem(skel->maps.execve_count_map, &key, &value) == 0) {
            printf("\r%-20s %llu", "execve", value);
            fflush(stdout);
        }
    }

cleanup:
    execve_bpf_bpf__destroy(skel);
    return err < 0 ? -err : 0;
}

This C code uses libbpf, a library that simplifies the process of loading and interacting with eBPF programs.

  • execve_counter.skel.h is a header file generated by bpftool from our .bpf.o file. It provides functions to open, load, attach, and destroy the eBPF program and its associated maps.
  • We increase RLIMIT_MEMLOCK because eBPF maps require memory to be locked in RAM.
  • execve_bpf_bpf__open(), execve_bpf_bpf__load(), and execve_bpf_bpf__attach() handle the boilerplate of loading the eBPF bytecode into the kernel, verifying it for safety, and attaching it to the specified kprobe.
  • The while (!stop) loop continuously reads the value from our execve_count_map and prints it.

To generate the skeleton header and compile the loader:

# Generate the skeleton header
bpftool gen skeleton execve_counter.bpf.o > execve_counter.skel.h

# Compile the loader (requires libbpf-dev or equivalent)
# sudo apt-get install libelf-dev
gcc loader.c -o loader -lbpf -lelf

Finally, run the loader with root privileges:

sudo ./loader

Now, in another terminal, run some commands that involve execve, like ls, pwd, or echo hello. You should see the "EXECVE CALL COUNT" increment in the loader’s output.

The most surprising thing about eBPF is that it allows you to safely run arbitrary code within the kernel without modifying kernel source code or loading kernel modules. The verifier acts as a static analysis tool that guarantees your eBPF program will not crash the kernel, leak information, or enter infinite loops.

The eBPF program itself is just the bytecode. The libbpf library in userspace is responsible for:

  1. Loading: Transferring the eBPF bytecode and map definitions to the kernel.
  2. Verification: The kernel’s eBPF verifier checks the bytecode for safety. If it fails, the program is rejected.
  3. Attachment: Hooking the eBPF program to a specific event (like a kprobe or a tracepoint).
  4. Map Management: Creating and managing the eBPF maps defined in the program.
  5. Userspace Interaction: Providing an interface for userspace applications to read from and write to eBPF maps, and to receive data via perf buffers or ring buffers.

To understand how bpf_map_lookup_elem works, it’s not just a simple memory lookup. The kernel manages these maps. When your eBPF program calls bpf_map_lookup_elem, the kernel translates the provided key into an index within the map’s underlying data structure and returns a pointer to the value, ensuring that the access is within the map’s bounds and that no other process can interfere with the map’s integrity. The __sync_fetch_and_add operation is crucial for concurrency; it ensures that even if multiple eBPF programs (or multiple instances of the same program) try to update the counter simultaneously, the increment operation is atomic and correct.

The kprobe/sys_enter_execve hook is just one of many available. You could also use kretprobe to hook the exit of a function, tracepoint for predefined kernel tracepoints, or even uprobe to hook user-space functions.

The next step is to explore how to send data from the eBPF program to userspace, not just read from maps, using perf buffers or ring buffers.

Want structured learning?

Take the full Ebpf course →