eBPF 101: Your First Step into Kernel Programming

eBPF has revolutionized Linux observability and security by allowing sandboxed programs to run in the kernel without changing kernel source code or loading modules

eBPF 101: Your First Step into Kernel Programming

I. What is this eBPF? It looks scary!

Have you wanted to write programs that act as drivers for Linux? Wanted programs to run at a kernel level? Wanted to monitor events, internal resources and get better observability? All you need to know is how to make good use of Linux eBPF.

eBPF is a technology in the Linux kernel that can run sandboxed programs in a privileged context (in the OS kernel). It is used to efficiently extend the capabilities of the kernel without changing kernel source code.

An operating system kernel is hard to modify due to its central role and high requirement towards stability and security. Innovation at the operating system level is lower compared to functionality implemented outside of the operating system. And developing drivers is difficult in general (I have tried that in Windows and failed).

image
link : https://ebpf.io/what-is-ebpf/

eBPF changes this formula fundamentally. It allows sandboxed programs to run within the operating system, which means that application developers can run eBPF programs to add additional capabilities to the operating system at runtime. The operating system then guarantees efficiency as if natively compiled with the aid of a Just-In-Time (JIT) compiler and verification engine.

This has led to a wave of eBPF-based projects covering a wide array of use cases, improving networking, observability, and security spaces.

Let's dive right into some practical scenario where we will build a simple firewall to block traffic from a particular ip like 8.8.8.8. And counts the incoming packets transfered each second. Follow through is you have an Ubuntu machine ready.

II. Developing with eBPF made Simple.

We need 2 files for a simple ePBF program.

  1. A Python user space script for interacting with eBPF
  2. A C code that uses eBPF functions and modules (core logic)

Let's download the requirements and setup a python virtual environment for smooth workflow.

Initial setup for ubuntu:

sudo apt-get update && sudo apt-get install -y bpfcc-tools libbpfcc-dev

Create a python virtual environment.

➜   python3 -m venv venv
➜   source venv/bin/activate

Here's what the 2 files that we are going to create:

  1. probe.c:

    • eBPF program that runs in the Linux kernel
    • Counts all incoming packets on a network interface
    • Drops packets destined for IP 8.8.8.8 (Google DNS)
  2. runner.py:

    • Python control program that:
      • Loads and compiles the eBPF program
      • Attaches it to a network interface
      • Monitors and prints packet counts per second
      • Handles graceful shutdown on SIGTERM/Ctrl+C
      • Prints debug messages when packets are dropped

To find the network interface try this command.

$ ip link show | grep -Po '(?<=: ).*(?=: <)'
lo
wlp0s20f3
docker0

The runner.py script is the user-space controller for our eBPF firewall. It's responsible for loading the eBPF program into the kernel, monitoring its activity, and cleaning up when it's done.

First, we import the necessary Python libraries. bcc is the core library that lets us interact with eBPF, while the others help with handling signals, time, file paths, and network data structures.

from bcc import BPF
from time import sleep
from pathlib import Path
import signal
import ctypes
import socket
import struct

To ensure the firewall can be shut down cleanly, we set up a custom signal handler. The TerminateSignal exception and handle_sigterm function work together to catch termination signals (like SIGTERM), allowing the script to proceed to the cleanup steps instead of stopping abruptly.

class TerminateSignal(Exception):
    pass

# Signal handler for SIGTERM
def handle_sigterm(signum, frame):
    raise TerminateSignal("Received SIGTERM, terminating...")

Loading and Managing the eBPF Program

The eBPF logic itself is written in C in probe.c. The load_bpf_program function reads this C code, and the BCC library compiles it into eBPF bytecode and loads it into the kernel. Once loaded, attach_xdp_program hooks the compiled code to a network interface using XDP (eXpress Data Path), allowing it to process packets at the earliest possible point in the network stack.

# Load and compile the eBPF program from the source file
def load_bpf_program():
    bpf_source = Path('probe.c').read_text()
    bpf = BPF(text=bpf_source)
    return bpf

# Attach the eBPF program to the specified interface
def attach_xdp_program(bpf, interface):
    xdp_fn = bpf.load_func("xdp_packet_counter", BPF.XDP)
    bpf.attach_xdp(interface, xdp_fn, 0)
    return bpf

When the script terminates, detach_xdp_program safely removes the eBPF program from the interface, ensuring the system returns to its normal state.

# Detach the eBPF program from the specified interface
def detach_xdp_program(bpf, interface):
    bpf.remove_xdp(interface, 0)

Monitoring and Event Handling

The main function orchestrates the entire process. It starts by registering the signal handler and defining the network interface to monitor (wlp0s20f3).

# Main function to execute the script
def main():
    # Register the signal handler for SIGTERM
    signal.signal(signal.SIGTERM, handle_sigterm)

    # Define the network interface to monitor
    INTERFACE = "wlp0s20f3"

Next, it loads and attaches the eBPF program. It then gains access to the packet_count_map (a shared data structure for counting packets) and opens a perf_buffer to receive real-time debug events from the kernel, such as notifications about dropped packets.

    # Load the eBPF program and attach it to the network interface
    bpf = load_bpf_program()
    attach_xdp_program(bpf, INTERFACE)

    # Access the BPF map and open the perf buffer for debug events
    packet_count_map = bpf.get_table("packet_count_map")
    bpf["debug_events"].open_perf_buffer(print_debug_event)

The print_debug_event function is a callback that processes these events. When the eBPF program drops a packet, this function formats the data and prints a message to the console.

def print_debug_event(cpu, data, size):
    dest_ip = ctypes.cast(data, ctypes.POINTER(ctypes.c_uint32)).contents.value
    print(f"Packet to {socket.inet_ntoa(struct.pack('!L', dest_ip))} dropped")

The script then enters an infinite loop to monitor packet counts. Every second, it reads the total count from the packet_count_map, calculates the packets-per-second rate, and prints it. It also polls for any new debug events.

    try:
        print("Counting packets, press Ctrl+C to stop...")
        prev_total_packets = 0
        while True:
            sleep(1)
            total_packets = sum(counter.value for counter in packet_count_map.values())
            
            packets_per_second = total_packets - prev_total_packets
            prev_total_packets = total_packets
            print(f"Packets per second: {packets_per_second}")
            bpf.perf_buffer_poll(1)

Graceful Shutdown

The try...except...finally block ensures that the program can be stopped cleanly with Ctrl+C or a SIGTERM signal. The finally block guarantees that the eBPF program is always detached from the network interface, preventing resource leaks.

    except (KeyboardInterrupt, TerminateSignal) as e:
            print(f"\n{e}. Interrupting eBPF runner.")
    finally:
        print("Detaching eBPF program and exiting.")
        detach_xdp_program(bpf, INTERFACE)

Finally, the if __name__ == "__main__": guard ensures the main function runs only when the script is executed directly.

# Execute the main function when the script is run directly
if __name__ == "__main__":
    main()

Next, the probe.c file contains the eBPF program that runs inside the Linux kernel. It uses XDP (eXpress Data Path) to inspect and filter network packets at the earliest possible point—right in the network driver—making it extremely fast.

Kernel-Space Setup

First, we include kernel headers that provide access to eBPF helpers and network data structures. We then define two key BPF maps:

  • BPF_ARRAY: A single-element array named packet_count_map to store a global packet counter.
  • BPF_PERF_OUTPUT: A perf buffer named debug_events to send notifications about dropped packets to the user-space script.
#include <uapi/linux/bpf.h>
#include <uapi/linux/if_ether.h>
#include <uapi/linux/if_packet.h>
#include <uapi/linux/ip.h>
#include <linux/in.h>
#include <bcc/helpers.h>

BPF_ARRAY(packet_count_map, __u64, 1);
BPF_PERF_OUTPUT(debug_events);

The Main XDP Program

The xdp_packet_counter function is the entry point for our eBPF program. It runs for every single packet that arrives on the attached network interface.

Its first job is to increment the global packet counter. It looks up the counter from packet_count_map and atomically increments it. Using an atomic operation is crucial to prevent race conditions when multiple CPU cores process packets simultaneously.

int xdp_packet_counter(struct xdp_md *ctx) {
    __u32 key = 0;
    __u64 *counter;

    counter = packet_count_map.lookup(&key);
    if (!counter)
        return XDP_ABORTED; // Abort if map lookup fails

    // Atomically increment the counter
    __sync_fetch_and_add(counter, 1);

    // Define the blocked IP and call the filtering function
    __be32 blocked_ip = (8 << 24) | (8 << 16) | (8 << 8) | 8;
    return drop_packet_to_destination(ctx, blocked_ip);
}

Packet Filtering Logic

The drop_packet_to_destination function contains the firewall's core logic. It carefully inspects the packet to decide whether to drop it or let it pass.

  1. Parse Headers: It starts by getting pointers to the packet's data and performs bounds checks to ensure the Ethernet and IP headers are safely accessible within the packet's memory region. This prevents the eBPF verifier from rejecting the program.

  2. Check Protocol: It checks if the packet is an IP packet. If not, it's immediately passed through with XDP_PASS.

static int drop_packet_to_destination(struct xdp_md *ctx, __be32 blocked_ip) {
    void *data_end = (void *)(long)ctx->data_end;
    void *data = (void *)(long)ctx->data;
    struct ethhdr *eth = data;

    // Safety check: ensure Ethernet header is within packet bounds
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;

    // Pass non-IP packets
    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    struct iphdr *iph = (struct iphdr *)(data + ETH_HLEN);
    // Safety check: ensure IP header is within packet bounds
    if ((void *)(iph + 1) > data_end)
        return XDP_PASS;

    // If the destination IP matches the blocked IP, drop the packet
    if (iph->daddr == blocked_ip) {
        __be32 daddr_copy = iph->daddr;
        debug_events.perf_submit(ctx, &daddr_copy, sizeof(daddr_copy));
        return XDP_DROP;
    }

    return XDP_PASS;
}

The code for this tutorial is taken from this beautiful talk. I recommend you to check it out.

Together they form a simple eBPF firewall that counts packets and blocks traffic to a specific IP address. The Python script manages the eBPF program lifecycle while the C code does the actual packet processing in kernel space.

$ sudo python3 runner.py 

Results after running the program.

image

Conclusion

Many tech giants Netflix, Dropbox, Yahoo, LinkedIn, Alibaba, Datadog, Shopify, DoorDash use eBPF for network observability, infrastructure debugging, pod networking/security in Kubernetes, intrusion detection. Its widely used in security monitoring and Incident Response.

It will be a big miss if you did not adopt or at least know something about it. I hope this article bridges the gap. For more articles follow the newsletter.

LiveReview helps you get great feedback on your PR/MR in a few minutes. Saves hours on every PR by giving fast, automated first-pass reviews. If you're tired of waiting for your peer to review your code or are not confident that they'll provide valid feedback, here's LiveReview for you.

image