eBPF lets you run sandboxed programs directly in the Linux kernel, letting you see what’s happening without modifying kernel code.

Let’s see how we can use eBPF to monitor runtime security events. Imagine we have a malicious process trying to do something it shouldn’t, like creating a file in /etc or attempting to open a sensitive network port. We want to catch this before it causes damage.

Here’s a simple setup using cilium/ebpf and a Go program. We’ll focus on detecting open system calls.

First, we need an eBPF program that hooks into the tracepoint/syscalls/sys_enter_open event. This program will run every time a process attempts to open a file.

// bpf/open.bpf.c
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>

struct event {
    pid_t pid;
    char comm[TASK_COMM_LEN];
    char filename[256];
};

struct {
    __uint(type, BPF_MAP_TYPE_RINGBUF);
    __uint(max_entries, 256 * 1024);
} rb SEC(".maps");

SEC("tp/syscalls/sys_enter_open")
int BPF_KPROBE(sys_enter_open, struct pt_regs *ctx, int dfd, const char *filename) {
    struct event *e;
    e = bpf_ringbuf_reserve(&rb, sizeof(*e), 0);
    if (!e) {
        return 0;
    }

    e->pid = bpf_get_current_pid_tgid() >> 32;
    bpf_get_current_comm(&e->comm, sizeof(e->comm));
    bpf_probe_read_user_str(&e->filename, sizeof(e->filename), filename);

    bpf_ringbuf_submit(e, 0);
    return 0;
}

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

This C code defines a struct event to hold process ID, command name, and the filename being opened. It then uses BPF_KPROBE to attach to the sys_enter_open tracepoint. Inside the hook, it reserves space in a ring buffer (rb), populates it with information about the open call (PID, command, filename), and submits it.

Now, let’s write the Go program to load and run this eBPF code and process the events.

package main

import (
	"bytes"
	"encoding/binary"
	"log"
	"os"
	"os/exec"
	"strings"
	"time"

	"github.com/cilium/ebpf"
	"github.com/cilium/ebpf/link"
	"github.com/cilium/ebpf/perf"
	"golang.org/x/sys/unix"
)

// $ go install github.com/cilium/ebpf/cmd/bpf2go@latest
//go:generate bpf2go -cc clang -no-strip -target amd64,arm64 bpf bpf/open.bpf.c -- -I../headers

type Event struct {
	Pid      uint32
	Comm     [16]byte
	Filename [256]byte
}

func main() {
	stopper := make(chan os.Signal, 1)
	signal.Notify(stopper, syscall.SIGINT, syscall.SIGTERM)

	objs := bpf.Objects{}
	if err := bpf.Load(&objs); err != nil {
		log.Fatalf("loading BPF objects: %v", err)
	}
	defer objs.Close()

	kp, err := link.Tracepoint("syscalls", "sys_enter_open", objs.BpfPrograms["sys_enter_open"], nil)
	if err != nil {
		log.Fatalf("creating tracepoint link: %v", err)
	}
	defer kp.Close()

	rd, err := perf.NewReader(objs.BpfMaps["rb"], 4096) // Use a reasonable buffer size
	if err != nil {
		log.Fatalf("creating perf event reader: %v", err)
	}
	defer rd.Close()

	log.Println("Listening for open() syscalls. Press Ctrl+C to stop.")

	go func() {
		for {
			record, err := rd.Read()
			if err != nil {
				if err == perf.ErrClosed {
					return
				}
				log.Printf("reading from perf reader: %v", err)
				continue
			}

			var event Event
			if err := binary.Read(bytes.NewReader(record.RawSample), binary.LittleEndian, &event); err != nil {
				log.Printf("parsing event: %v", err)
				continue
			}

			// Security check: Is this process trying to open a sensitive file?
			comm := strings.TrimRight(string(event.Comm[:]), "\x00")
			filename := strings.TrimRight(string(event.Filename[:]), "\x00")

			// Example sensitive operations
			if strings.HasPrefix(filename, "/etc/") || strings.Contains(filename, "shadow") || strings.HasPrefix(filename, "/root/.ssh") {
				log.Printf("[SECURITY ALERT] PID %d (%s) attempted to open sensitive file: %s", event.Pid, comm, filename)
			}
		}
	}()

	<-stopper
	log.Println("Received signal, stopping.")
}

The Go program uses bpf2go to embed the eBPF C code and generate Go bindings. It loads the eBPF objects, attaches the program to the sys_enter_open tracepoint using link.Tracepoint, and creates a perf.Reader to get events from the ring buffer. The go func() then continuously reads from the reader, decodes the Event struct, and performs a simple security check: if the opened filename starts with /etc/, contains shadow, or starts with /root/.ssh, it logs a security alert.

To run this:

  1. Make sure you have clang, llvm, and libelf-dev installed.
  2. Install bpf2go: go install github.com/cilium/ebpf/cmd/bpf2go@latest
  3. Save the C code as bpf/open.bpf.c and the Go code as main.go.
  4. Generate the Go bindings: go generate
  5. Build and run: sudo go build -o monitor . && sudo ./monitor

Now, try opening a sensitive file from another terminal:

sudo cat /etc/shadow

You should see an output like:

2023/10/27 10:00:00 [SECURITY ALERT] PID 12345 (cat) attempted to open sensitive file: /etc/shadow

This demonstrates how eBPF allows you to tap into kernel events with minimal overhead and without modifying the kernel itself. You can extend this to monitor other system calls like execve (for new process execution), connect (for network connections), chmod, unlink, etc., to build a comprehensive runtime security monitoring system.

The real power comes from correlating these events. For example, you might want to flag a process that executes a shell script (execve) and then attempts to open sensitive configuration files (open). eBPF programs can maintain state across events using eBPF maps, enabling sophisticated detection logic directly within the kernel.

One thing that often trips people up is the interaction between userspace and the kernel. eBPF programs run in the kernel, but they need to communicate results back to userspace for analysis, logging, or triggering alerts. Ring buffers and perf events are the primary mechanisms for this. Ring buffers are generally preferred for high-throughput event streaming because they offer lower latency and more predictable performance than perf events, which can sometimes suffer from increased overhead when dealing with very frequent events. The bpf_ringbuf_reserve and bpf_ringbuf_submit calls are crucial here, ensuring efficient data transfer.

The next step would be to explore more complex eBPF programs that use eBPF maps for state management, allowing you to detect sequences of malicious actions rather than just isolated events.

Want structured learning?

Take the full Ebpf course →