eBPF Packet Filtering: Write Custom Network Rules in Linux
What Is eBPF and Why It Matters for Networking
Extended Berkeley Packet Filter (eBPF) is a Linux kernel technology that lets you run sandboxed programs inside the kernel without modifying kernel source code or loading kernel modules. Originally descended from the classic BPF used in tools like tcpdump, eBPF has evolved into a general-purpose in-kernel virtual machine capable of far more than simple packet inspection.
For networking, eBPF is transformative. It allows engineers to implement custom packet filtering logic, traffic classification, load balancing, and intrusion detection directly in the kernel data path — with near-zero overhead and no need to copy packets to user space for every decision. Projects like Cilium, Katran, and Falco have proven eBPF's production readiness at massive scale.
Core Attachment Points: XDP, TC, and Socket Filters
eBPF programs attach to specific kernel hooks. Choosing the right hook determines both the capabilities available and the performance characteristics of your filter.
XDP (eXpress Data Path) is the earliest possible hook, running inside the network driver before the kernel allocates an sk_buff. This makes it the fastest option for eBPF packet filtering — ideal for DDoS mitigation and high-throughput filtering where every microsecond matters. XDP programs return verdicts: XDP_PASS, XDP_DROP, XDP_TX, or XDP_REDIRECT.
TC (Traffic Control) hooks run slightly later, after sk_buff creation, giving access to richer metadata. TC hooks support both ingress and egress filtering and are used heavily for policy enforcement and packet mangling.
Socket filters operate at the socket level and are the classic BPF attachment point, useful for per-socket traffic inspection and application-layer filtering.
Writing Your First eBPF Packet Filter
eBPF programs are written in a restricted subset of C, then compiled with Clang/LLVM targeting the BPF bytecode architecture. The kernel verifier checks every program before loading, ensuring no infinite loops, out-of-bounds memory access, or unsafe operations.
A minimal XDP program that drops all incoming UDP traffic on port 53 looks like this:
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/udp.h>
#include <bpf/bpf_helpers.h>
SEC("xdp")
int drop_dns(struct xdp_md *ctx) {
void *data = (void *)(long)ctx->data;
void *data_end = (void *)(long)ctx->data_end;
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end) return XDP_PASS;
if (eth->h_proto != __constant_htons(ETH_P_IP)) return XDP_PASS;
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end) return XDP_PASS;
if (ip->protocol != IPPROTO_UDP) return XDP_PASS;
struct udphdr *udp = (void *)(ip + 1);
if ((void *)(udp + 1) > data_end) return XDP_PASS;
if (udp->dest == __constant_htons(53)) return XDP_DROP;
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
Compile with clang -O2 -target bpf -c filter.c -o filter.o, then load it using ip link set dev eth0 xdp obj filter.o sec xdp. The program is now active in the kernel's fast path.
Using BPF Maps for Dynamic Rule Management
Static filters are useful but limited. BPF maps bridge the gap between kernel-space eBPF programs and user-space control planes, enabling dynamic eBPF packet filtering rules without reloading programs.
A BPF_MAP_TYPE_HASH map keyed on source IP address lets you maintain a blocklist that user-space tooling can update in real time. Your XDP program performs a map lookup on each packet's source IP; if found, it drops the packet. Your control plane daemon — written in Go, Python, or C using libbpf — inserts and removes entries as threat intelligence changes.
More advanced map types like BPF_MAP_TYPE_LPM_TRIE support longest-prefix matching, enabling CIDR-based network rules with O(log n) lookup complexity inside the kernel.
Observability: Inspecting Traffic in Real Time
eBPF excels at non-disruptive traffic inspection. Using BPF_MAP_TYPE_PERF_EVENT_ARRAY or the newer BPF_MAP_TYPE_RINGBUF, your kernel program can stream packet metadata — timestamps, source/destination IPs, ports, byte counts — to a user-space consumer with minimal overhead.
Tools like bpftrace and bcc make ad-hoc inspection straightforward. A one-liner like bpftrace -e 'tracepoint:net:netif_receive_skb { @bytes = hist(args->len); }' produces a histogram of received packet sizes across all interfaces in real time — invaluable for capacity planning and anomaly detection in production networking environments.
Integration with pfq and High-Performance Stacks
In high-throughput environments, eBPF packet filtering integrates well with frameworks like PFQ that manage multi-queue NICs and kernel-bypass architectures. eBPF can act as the first-pass filter at XDP, dropping unwanted traffic before it reaches the more expensive PFQ steering and distribution layer. This layered approach — XDP for coarse filtering, PFQ for fine-grained distribution across cores — delivers the best combination of flexibility and raw throughput for demanding networking workloads.
For teams building custom networking tools on Linux, understanding where eBPF fits relative to AF_XDP sockets, DPDK, and ring-buffer based capture frameworks is essential for making sound architectural decisions.
Getting Started: Tools and Resources
The fastest path to productive eBPF packet filtering development is the libbpf library combined with the BPF CO-RE (Compile Once, Run Everywhere) approach, which produces portable BPF object files that adapt to different kernel versions at load time. The bpftool utility handles program loading, map inspection, and BTF (BPF Type Format) management from the command line.
Kernel version matters: XDP generic mode is available from 4.8, native driver support from 4.14+, and features like BPF ring buffers require 5.8+. Always verify your target kernel's BPF feature support with bpftool feature probe before committing to a design that depends on newer primitives.