XDP and eBPF Packet Filtering: Mitigating Multi-Gigabit DDoS at the Driver Layer

Article Hero

Technical Overview & Threat Model#

Volumetric Distributed Denial of Service (DDoS) attacks pose a fundamental resource exhaustion problem for Linux edge routers, container ingress gateways, and application hosts. High-rate SYN floods, UDP amplification, and DNS reflection vectors routinely generate tens of millions of packets per second (Mpps). At these volumes, host failure rarely stems from raw network bandwidth saturation; rather, the Linux operating system exhausts CPU cycles inside the core networking subsystem.

In standard Linux network packet processing, every incoming frame received by a Network Interface Card (NIC) follows a computationally intensive path:

  1. Ring Buffer Ingestion & Interrupt Handling: The NIC places packets into a circular Direct Memory Access (DMA) ring buffer and triggers a hard interrupt (IRQ), followed by a SoftIRQ (NET_RX_SOFTIRQ) executed by the napi_poll() routine.
  2. Socket Buffer (sk_buff) Allocation: The kernel allocates a complex, memory-heavy struct sk_buff (approximately 240 bytes plus packet payload buffers) to represent the packet.
  3. Subsystem Traversal: The packet traverses the netfilter connection tracker (conntrack), route table lookups, and packet filter chains (iptables/nftables).

Under a flood of 15–30 Mpps, memory allocation overhead for sk_buff structures and spinlock contention across conntrack hash tables consume 100% of CPU time, dropping legitimate traffic before firewall rules can even evaluate the packet headers.

sequenceDiagram
    autonumber
    participant NIC as Network Interface Card (NIC)
    participant Driver as Device Driver Ring
    participant XDP as eXpress Data Path (eBPF)
    participant KernelNet as Linux Kernel Network Stack (sk_buff)
    participant App as Application Socket

    NIC->>Driver: Receive Raw Ethernet Frame (DMA)
    Driver->>XDP: Execute In-Driver eBPF Hook (xdp_buff)
    alt Packet Matched in Blocklist / Rate Limit Exceeded
        XDP-->>Driver: Return XDP_DROP
        Driver-->>NIC: Recycle RX Buffer Immediately (Zero sk_buff Overhead)
    else Legitimate Inbound Traffic
        XDP->>Driver: Return XDP_PASS
        Driver->>KernelNet: Allocate sk_buff & Forward to IP/TCP Stack
        KernelNet->>App: Deliver to Userspace Socket (epoll/read)
    end

The eXpress Data Path (XDP) eliminates this bottleneck by executing verified eBPF bytecode directly inside the network driver subsystem before sk_buff allocation occurs. Operating directly on raw page buffers (struct xdp_buff), XDP enables drop rates exceeding 20–35 million packets per second per CPU core on commodity 100GbE hardware.


Architecture & Kernel Packet Flow#

XDP operates across three distinct deployment modes depending on hardware capabilities and virtualization layers:

  • Offloaded XDP: The eBPF program is compiled into native NPU/NIC microcode and runs directly on a SmartNIC processor, achieving zero CPU host utilization.
  • Native / Driver XDP: The program executes inside the device driver's main receive path prior to sk_buff allocation (supported natively in modern drivers such as mlx5, ixgbe, virtio_net).
  • Generic XDP: A fallback software mode running after initial sk_buff allocation in netif_receive_skb(), intended for testing and unsupported legacy hardware.
flowchart TD
    subgraph Hardware [Physical Network Hardware]
        PhysicalNIC[NIC PHY / MAC Layer]
        DMARing[DMA Receive Ring Buffer]
    end

    subgraph DriverLayer [Driver & XDP Execution Boundary]
        DriverPoll[NAPI Poll Loop - Driver Layer]
        XDPHook{XDP eBPF Program}
        BPFMap[(eBPF Hash Map: IP Blocklist & Token Buckets)]
    end

    subgraph CoreKernel [Standard Linux Network Subsystem]
        AllocSKB[Allocate struct sk_buff]
        Conntrack[Netfilter conntrack Evaluation]
        RoutingTable[IP Routing & Socket Dispatch]
        UserApp[Userspace Application Daemon]
    end

    PhysicalNIC --> DMARing
    DMARing --> DriverPoll
    DriverPoll --> XDPHook
    XDPHook <--> BPFMap
    XDPHook -->|XDP_DROP| DMARing
    XDPHook -->|XDP_PASS| AllocSKB
    AllocSKB --> Conntrack
    Conntrack --> RoutingTable
    RoutingTable --> UserApp

The table below contrasts traditional packet filtering technologies against XDP processing modes:

Technology Hook Location Processing Unit Drop Performance State Memory Cost Common Bottleneck
iptables (Netfilter) Kernel IP Stack (NF_INET_PRE_ROUTING) Full sk_buff 1.5 – 3.0 Mpps/core High (~240B + overhead) conntrack table locks, slab allocations
nftables Kernel IP Stack (netfilter) Full sk_buff 2.5 – 4.5 Mpps/core High (~240B + overhead) Memory allocation, SoftIRQ context switches
TC BPF (cls_bpf) Traffic Control Ingress/Egress Full sk_buff 5.0 – 9.0 Mpps/core Moderate (post-allocation) Pre-allocated skb manipulation overhead
XDP (Generic) Device Ingress Trampoline sk_buff pointer 4.0 – 8.0 Mpps/core Moderate (post-allocation) Software interrupt queue backlog
XDP (Native Driver) Driver RX Descriptor Ring Raw xdp_buff 25.0 – 40.0 Mpps/core Negligible (zero heap alloc) PCIe bus throughput, CPU memory bandwidth

Technical Walkthrough: In-Driver eBPF Filter Implementation#

The implementation below provides an in-driver XDP packet filter written with libbpf and BPF CO-RE. It validates Ethernet and IP headers, performs zero-copy lookups against an eBPF LRU hash map containing malicious IP addresses, and applies token-bucket rate limiting against TCP SYN floods.

C
#include "vmlinux.h"
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

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

#define ETH_P_IP 0x0800
#define IPPROTO_TCP 6
#define IPPROTO_UDP 17

struct rate_limit_t {
    u64 last_seen_ns;
    u32 packet_count;
};

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 256000);
    __type(key, u32);
    __type(value, u8);
} blocked_ips SEC(".maps");

struct {
    __uint(type, BPF_MAP_TYPE_LRU_HASH);
    __uint(max_entries, 128000);
    __type(key, u32);
    __type(value, struct rate_limit_t);
} rate_limit_map SEC(".maps");

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

    // Bounds check: Ethernet Header
    struct ethhdr *eth = data;
    if ((void *)(eth + 1) > data_end)
        return XDP_PASS;

    if (eth->h_proto != bpf_htons(ETH_P_IP))
        return XDP_PASS;

    // Bounds check: IPv4 Header
    struct iphdr *iph = (void *)(eth + 1);
    if ((void *)(iph + 1) > data_end)
        return XDP_PASS;

    u32 src_ip = iph->saddr;

    // Direct Hash Table Lookup for Known Malicious IP
    u8 *blocked = bpf_map_lookup_elem(&blocked_ips, &src_ip);
    if (blocked)
        return XDP_DROP;

    // Inspect TCP Layer for SYN Flood Mitigation
    if (iph->protocol == IPPROTO_TCP) {
        struct tcphdr *tcph = (void *)iph + (iph->ihl * 4);
        if ((void *)(tcph + 1) > data_end)
            return XDP_PASS;

        if (tcph->syn && !tcph->ack) {
            u64 now = bpf_ktime_get_ns();
            struct rate_limit_t *rl = bpf_map_lookup_elem(&rate_limit_map, &src_ip);

            if (rl) {
                // Window: 1 second (1,000,000,000 ns)
                if (now - rl->last_seen_ns < 1000000000ULL) {
                    if (rl->packet_count > 50) {
                        return XDP_DROP;
                    }
                    rl->packet_count++;
                } else {
                    rl->last_seen_ns = now;
                    rl->packet_count = 1;
                }
            } else {
                struct rate_limit_t new_rl = {
                    .last_seen_ns = now,
                    .packet_count = 1,
                };
                bpf_map_update_elem(&rate_limit_map, &src_ip, &new_rl, BPF_ANY);
            }
        }
    }

    return XDP_PASS;
}

Deployment & Verification Workflow#

Attaching and managing XDP programs on production network interfaces requires native tooling via iproute2 or specialized eBPF management daemons.

The unified CLI workflow below demonstrates compilation with clang, binding the compiled bytecode to a physical network interface in native driver mode, dynamically populating the IP blocklist map, and monitoring live packet drops via bpftool:

BASH
## 1. Compile eBPF source into ELF bytecode object
clang -O2 -g -target bpf -D__TARGET_ARCH_x86 -c xdp_filter.c -o xdp_filter.o

## 2. Attach program to network interface in native driver mode (eth0)
## Use 'xdpgeneric' if the interface driver does not support native XDP
ip link set dev eth0 xdpoff
ip link set dev eth0 xdpdrv obj xdp_filter.o sec xdp

## 3. Verify driver binding and obtain loaded program ID
ip link show dev eth0
bpftool prog show --type xdp

## 4. Dynamically block an attacking IPv4 address (198.51.100.44 = 0x2C6453C6)
## Format: bpftool map update id <MAP_ID> key <HEX_BYTES> value <HEX_BYTES>
MAP_ID=$(bpftool map show name blocked_ips | awk -F: '{print $1}')
bpftool map update id ${MAP_ID} key hex c6 53 64 2c value hex 01

## 5. Monitor real-time packet statistics and driver drop metrics
ethtool -S eth0 | grep -iE "rx_xdp_drop|rx_packets|rx_bytes"

To gracefully detach the XDP program and restore standard operating mode:

BASH
ip link set dev eth0 xdpoff

Engineering Trade-offs & Practical Takeaways#

Deploying packet filtering at driver line rate introduces specific operational considerations that network engineering teams must balance:

  • Header Offset Fragility: XDP runs on raw bytes. Any variable-length options (such as IPv4 IHL fields or VLAN 802.1Q tags) require explicit boundary math. The eBPF verifier will reject any program lacking mathematical proof that pointer arithmetic cannot exceed ctx->data_end.
  • Zero Host Conntrack Context: XDP operates prior to connection tracking. While ideal for stateless SYN flood drops, complex application-layer protocol inspection (e.g., HTTP header inspection, TLS SNI filtering) cannot occur cleanly at line rate in XDP and belongs in userspace reverse proxies.
  • Driver Architecture Dependencies: True multi-gigabit line-rate performance requires a NIC driver with native XDP support (xdpdrv). Running in xdpgeneric mode provides rapid development testing but sacrifices the CPU saving gained from bypassing sk_buff allocations.

Authoritative References#

Comments