Background: When iptables Just Isn’t Fast Enough
I manage the network for a 50-person office and a small datacenter — and I’ve witnessed firsthand a WordPress server completely taken down by a UDP flood hitting around 800Mbps. CPU spiked to 100%, iptables kept trying to parse each packet through the kernel stack while services were dying one by one. That incident pushed me to dig into XDP, and it turned out to be something I wish I had known about years earlier.
The core problem: iptables and nftables both process packets after they’ve already traversed the entire kernel network stack — socket buffers, netfilter hooks, routing tables. Under normal traffic, nobody notices. But when a flood hits millions of packets per second, every single one of those steps devours CPU resources.
XDP (eXpress Data Path) solves this by processing packets directly at the NIC driver layer — before they even reach the kernel stack. Packets get dropped in nanoseconds. CPU consumption drops by 80–90% compared to iptables under the same traffic conditions.
XDP vs. Other Approaches
- iptables/nftables: Processes packets after the kernel stack — easy to configure but slow under high traffic
- XDP: Processes at the driver level, 10–100x faster, seamlessly integrates with the existing kernel stack
- DPDK: As fast as XDP but bypasses the kernel entirely — requires custom drivers and is difficult to integrate into a running system
In practice, XDP sits in the sweet spot between these two extremes: fast enough to handle large-scale DDoS attacks, without needing to rebuild your entire network stack like DPDK requires.
XDP runs on top of eBPF — the same technology used by Cilium, Cloudflare’s firewall, and bpftrace. Each XDP program returns one of four actions: XDP_DROP (discard the packet immediately), XDP_PASS (let it through the kernel stack normally), XDP_TX (send it back through the same interface), or XDP_REDIRECT (forward it to another interface). For DDoS mitigation, XDP_DROP is by far the most commonly used action.
Setting Up the Environment
Kernel 4.8+ includes basic XDP support, but I recommend using kernel 5.10+ for the full feature set. Check first:
uname -r
# Example: 5.15.0-91-generic
Installing Dependencies on Ubuntu/Debian
sudo apt update
sudo apt install -y clang llvm libelf-dev libbpf-dev \
linux-headers-$(uname -r) iproute2 bpftool gcc make
Installing on RHEL/AlmaLinux/Rocky
sudo dnf install -y clang llvm elfutils-libelf-devel \
libbpf-devel kernel-devel bpftool iproute
Verify libbpf is ready:
pkg-config --libs libbpf
# Output: -lbpf
This step is often skipped but it matters: check whether your NIC supports XDP native mode. Native mode is significantly faster than generic mode — typically 3–5x on the same hardware:
ethtool -i eth0 | grep driver
# driver: ixgbe → native XDP supported
# driver: i40e → native XDP supported
# driver: virtio_net → generic mode only (virtual machines)
Step-by-Step Setup: Writing an XDP Program to Block DDoS
Organize the project in its own directory for easy management:
mkdir ~/xdp-filter && cd ~/xdp-filter
Writing the XDP Program in C
Create a file called xdp_block.c. This is an eBPF program written in restricted C — a subset of C that runs directly on the eBPF VM inside the kernel, passing through the verifier for safety checks before execution:
// xdp_block.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <bpf/bpf_helpers.h>
// BPF Map: stores the IP blocklist (key = IP uint32, value = 1)
struct {
__uint(type, BPF_MAP_TYPE_HASH);
__type(key, __u32);
__type(value, __u8);
__uint(max_entries, 65536);
__uint(pinning, LIBBPF_PIN_BY_NAME);
} blocked_ips SEC(".maps");
// BPF Map: counts dropped packets (per-CPU to avoid lock contention)
struct {
__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
__type(key, __u32);
__type(value, __u64);
__uint(max_entries, 1);
__uint(pinning, LIBBPF_PIN_BY_NAME);
} drop_counter SEC(".maps");
SEC("xdp")
int xdp_block_ip(struct xdp_md *ctx) {
void *data_end = (void *)(long)ctx->data_end;
void *data = (void *)(long)ctx->data;
// Parse Ethernet header — bounds check required by the eBPF verifier
struct ethhdr *eth = data;
if ((void *)(eth + 1) > data_end)
return XDP_PASS;
// Only process IPv4
if (eth->h_proto != __constant_htons(ETH_P_IP))
return XDP_PASS;
// Parse IP header
struct iphdr *ip = (void *)(eth + 1);
if ((void *)(ip + 1) > data_end)
return XDP_PASS;
// Look up the source IP in the map
__u8 *blocked = bpf_map_lookup_elem(&blocked_ips, &ip->saddr);
if (blocked && *blocked == 1) {
__u32 key = 0;
__u64 *cnt = bpf_map_lookup_elem(&drop_counter, &key);
if (cnt) (*cnt)++;
return XDP_DROP;
}
return XDP_PASS;
}
char _license[] SEC("license") = "GPL";
Compiling the XDP Program
clang -O2 -g -Wall -target bpf \
-I/usr/include/$(uname -m)-linux-gnu \
-c xdp_block.c -o xdp_block.o
# Verify the output
file xdp_block.o
# xdp_block.o: ELF 64-bit LSB relocatable, eBPF, version 1 (SYSV)
Loading XDP into the Kernel and Attaching to an Interface
# Mount the BPF filesystem if not already mounted
sudo mount -t bpf bpf /sys/fs/bpf 2>/dev/null || true
sudo mkdir -p /sys/fs/bpf/xdp-filter
# Check the actual interface name on your machine
ip link show
# Load XDP in native mode (NIC driver supports it — fastest option)
sudo ip link set eth0 xdpdrv obj xdp_block.o sec xdp \
pinpath /sys/fs/bpf/xdp-filter
# If the NIC doesn't support native mode, use generic mode
sudo ip link set eth0 xdpgeneric obj xdp_block.o sec xdp \
pinpath /sys/fs/bpf/xdp-filter
Managing the IP Blocklist — No Restart Required
The beauty of BPF Maps: you can add or remove IPs from the blocklist in real time without reloading or restarting the program. XDP keeps running in the kernel — you’re just updating the data underneath it:
#!/usr/bin/env python3
# manage_block.py
import socket
import subprocess
import sys
MAP_PATH = "/sys/fs/bpf/xdp-filter/blocked_ips"
def ip_to_hex(ip):
packed = socket.inet_aton(ip)
return ' '.join(f'{b:02x}' for b in packed)
def block_ip(ip):
key = ip_to_hex(ip)
subprocess.run(
f"bpftool map update pinned {MAP_PATH} key hex {key} value hex 01",
shell=True, check=True
)
print(f"Blocked: {ip}")
def unblock_ip(ip):
key = ip_to_hex(ip)
subprocess.run(
f"bpftool map delete pinned {MAP_PATH} key hex {key}",
shell=True, check=True
)
print(f"Unblocked: {ip}")
if __name__ == "__main__":
if len(sys.argv) != 3:
print("Usage: sudo python3 manage_block.py [block|unblock] <IP>")
sys.exit(1)
action, ip = sys.argv[1], sys.argv[2]
if action == "block":
block_ip(ip)
elif action == "unblock":
unblock_ip(ip)
# Block an attacking IP
sudo python3 manage_block.py block 203.0.113.50
# Unblock
sudo python3 manage_block.py unblock 203.0.113.50
# View the full current blocklist
sudo bpftool map dump pinned /sys/fs/bpf/xdp-filter/blocked_ips
Testing & Monitoring
Confirming XDP Is Attached Successfully
ip link show eth0
# On success, you'll see: xdp/id:42 or xdpgeneric/id:42
# View details of the running program
sudo bpftool prog list | grep xdp
sudo bpftool prog show id 42
Monitoring Dropped Packet Counts
# View drop counter from the BPF Map
sudo bpftool map dump pinned /sys/fs/bpf/xdp-filter/drop_counter
# View XDP stats from the driver (if the NIC supports it)
ethtool -S eth0 | grep -i xdp
# Monitor in real time every 1 second
watch -n 1 'sudo bpftool map dump pinned /sys/fs/bpf/xdp-filter/drop_counter'
Real-World Testing with hping3
# From a test machine — send a SYN flood
hping3 -S --flood -V -p 80 <server-ip>
# On the server — observe CPU usage and packet stats
watch -n 1 'cat /proc/net/dev | grep eth0'
# rx packets rise quickly but CPU stays low → XDP is dropping at the driver level
Removing XDP When No Longer Needed
# Detach XDP
sudo ip link set eth0 xdpdrv off
# or
sudo ip link set eth0 xdpgeneric off
# Clean up BPF maps from the pinned filesystem
sudo rm -rf /sys/fs/bpf/xdp-filter
Real Production Results
After deploying XDP on the datacenter’s gateway server, the numbers spoke for themselves: under the same ~800Mbps flood, iptables pushed CPU to 85–90% with latency spiking wildly. Switching to XDP native mode on an Intel i40e card brought CPU usage down to 15–20%. Services behind it were completely unaffected — XDP had already dropped the packets before they could touch the kernel stack.
65,536 entries in blocked_ips is more than enough in practice — even during active attacks, I’ve never needed to block more than 3,000 IPs simultaneously. The logical next step is to automate the monitoring pipeline: use bpftrace to detect anomalous patterns, then call the Python script above to add IPs to the blocklist without manual intervention. The whole thing happens in milliseconds — fast enough to get ahead of most automated attacks.

