eBPF XDP (eXpress Data Path) Line-Rate Packet Filtering: Dropping Volumetric DDoS & Malicious Scanners at the NIC Ring Buffer

Standard iptables and nftables choke under multi-gigabit SYN floods and brute-force scans. Harness eBPF XDP programs to inspect and discard packets directly at the network card driver before OS kernel allocation.

The Anatomical Failure of the Linux Network Stack Under Volumetric Load

When an ingress packet arrives at a physical network interface card (NIC), the standard Linux networking subsystem performs an elaborate series of memory allocations and kernel handoffs:

  1. The NIC driver receives the packet into a hardware Direct Memory Access (DMA) ring buffer.
  2. The kernel allocates a socket buffer structure—the ubiquitous sk_buff—a heavy C struct containing over 200 bytes of metadata tracking routing state, protocol headers, and device pointers.
  3. The packet enters the Linux Netfilter subsystem, traversing connection tracking (conntrack) tables, iptables/nftables chains, route table lookups, and TCP stack state machines before finally being copied to a user-space application socket.

Under volumetric Distributed Denial of Service (DDoS) attacks, SYN floods, or aggressive brute-force vulnerability scans, this pipeline collapses. The CPU burns massive cycles simply allocating and freeing sk_buff structures. Furthermore, Netfilter's connection tracking table quickly exhausts its maximum bucket capacity (nf_conntrack_max), causing the kernel to drop legitimate user connections.

Under synthetic testing on a 10Gbps link, standard Linux iptables begins saturating CPU cores at approximately 1.2 to 1.8 million packets per second (Mpps). An attacker sending 5Gbps of small 64-byte UDP or SYN packets will easily render the host completely unreachable—long before reaching physical network saturation.

The eXpress Data Path (XDP) Architecture: Intercepting at the NIC Ring Buffer

XDP (eXpress Data Path) fundamentally transforms Linux packet processing by introducing a safe execution environment for eBPF byte code directly inside the network driver layer, before the kernel allocates an sk_buff.

XDP operates across three distinct operational modes:

  • Offloaded Mode: The eBPF program is compiled down to native silicon instructions and executed directly on the SmartNIC hardware processor (zero host CPU utilization).
  • Native / Driver Mode (Most Common): The eBPF program executes in the network driver's RX ring polling loop (NAPI poll) on the host CPU, immediately after DMA transfer and before any kernel memory allocations.
  • Generic Mode: Executes after sk_buff allocation as a fallback testing mode for drivers lacking native XDP hooks.

Upon inspecting a packet, an XDP program returns an integer verdict determining its fate in under 10 nanoseconds:

  • XDP_DROP: Immediately reclaims the packet's DMA buffer and discards the packet. No allocations, no context switches, zero Netfilter overhead.
  • XDP_PASS: Passes the packet forward into the normal Linux networking stack.
  • XDP_TX: Bounces the packet back out the same interface it arrived on (ideal for high-performance load balancers).
  • XDP_REDIRECT: Forwards the packet to another NIC, a CPU core, or an AF_XDP user-space socket.

Designing a High-Throughput Packet Filter in Restricted C

eBPF code executed inside the kernel must pass the Linux eBPF verifier, which enforces strict memory safety: all pointer offsets must be checked against packet boundary buffers (data and data_end) before dereferencing.

Below is a production-grade eBPF XDP filter that parses IPv4 packets, extracts source IP addresses, and checks them against an in-kernel LRU Hash Map populated with malicious IP ranges:

// xdp_filter.c
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/in.h>
#include <bpf/bpf_helpers.h>

// BPF Map storing blacklisted IPv4 addresses (key: __u32, value: drop count)
struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 100000);
    __type(key, __u32);
    __type(value, __u64);
} blacklist_map SEC(".maps");

SEC("xdp")
int filter_malicious_traffic(struct xdp_md *ctx) {
    void *data = (void *)(long)ctx->data;
    void *data_end = (void *)(long)ctx->data_end;

    // 1. Boundary check Ethernet header
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;

    // Only process IPv4 traffic
    if (eth->h_proto != __constant_htons(ETH_P_IP))
        return XDP_PASS;

    // 2. Boundary check IPv4 header
    struct iphdr *ip = (void *)(eth + 1);
    if ((void *)(ip + 1) > data_end)
        return XDP_PASS;

    __u32 src_ip = ip->saddr;

    // 3. Fast lookup in BPF LRU hash map
    __u64 *drop_counter = bpf_map_lookup_elem(&blacklist_map, &src_ip);
    if (drop_counter) {
        // Increment drop metric atomically and discard at the NIC driver
        __sync_fetch_and_add(drop_counter, 1);
        return XDP_DROP;
    }

    return XDP_PASS;
}

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

Building the User-Space Control Plane in Python / BCC

While the packet filtering program runs inside kernel space, your application control plane (written in Python, Go, or Rust) manages dynamic blacklists by modifying the BPF map at runtime. There is no need to recompile or reload the XDP program when adding or removing banned IPs.

# control_plane.py
import socket
import struct
import time
from bcc import BPF

# 1. Compile and attach XDP filter to network interface eth0
b = BPF(src_file="xdp_filter.c", cflags=["-w"])
fn = b.load_func("filter_malicious_traffic", BPF.XDP)
interface = "eth0"
b.attach_xdp(interface, fn, 0)
print(f"[+] XDP Packet Filter actively attached to {interface}")

blacklist = b.get_table("blacklist_map")

def ban_ip(ip_address: str):
    # Convert dotted-quad IP string to 32-bit network byte order integer
    packed_ip = struct.unpack("I", socket.inet_aton(ip_address))[0]
    initial_count = 0
    blacklist[blacklist.Key(packed_ip)] = blacklist.Leaf(initial_count)
    print(f"[BLOCKED] Injected {ip_address} into in-kernel eBPF blacklist map")

try:
    # Dynamically inject known scanner IPs
    ban_ip("198.51.100.42")
    ban_ip("203.0.113.88")
    
    while True:
        time.sleep(5)
        # Periodically dump drop statistics
        for k, v in blacklist.items():
            ip_str = socket.inet_ntoa(struct.pack("I", k.value))
            if v.value > 0:
                print(f"[STATS] Source IP: {ip_str:15s} | Dropped Packets: {v.value}")
finally:
    # Clean detachment on shutdown
    b.remove_xdp(interface, 0)
    print(f"[-] Successfully detached XDP from {interface}")

Layered Edge Defense: Combine line-rate packet dropping with zero-downtime origin protection described in Zero-Downtime TLS Certificate Hot-Reloading & OCSP Stapling in Nginx, and profile socket metrics with eBPF-Powered Kernel Observability: Profiling Socket Drops and TCP Retransmits.

Benchmark Comparisons and Production Throughput

By dropping malicious traffic before memory allocation occurs, XDP unlocks unprecedented throughput gains on commodity Linux instances:

Mechanism          | Max Throughput (64B packets) | CPU Utilization (1 Core)
-------------------|------------------------------|-------------------------
Linux iptables     | 1.4 Mpps (~0.9 Gbps)         | 100% (Softirq saturated)
nftables           | 1.9 Mpps (~1.2 Gbps)         | 100% (Softirq saturated)
XDP Generic        | 3.8 Mpps (~2.4 Gbps)         | 100% (CPU bound)
XDP Native (Driver)| 14.8 Mpps (Line Rate 10Gbps) | 18% (Sub-millisecond)

Operating at 14.8 million packets per second per single core, native XDP enables edge servers to shrug off volumetric DDoS floods, aggressive credential stuffing bots, and automated vulnerability scanners without disturbing user-space application performance.

All Insights
Chat on WhatsApp