Get started
Security

eBPF and XDP Deep Dive: Dropping Malicious Packets at the Network Driver Layer

Write, verify, load and safely roll out an XDP program in C that drops traffic from blocklisted IPv4 and IPv6 prefixes before the kernel builds a socket buffer.

Most Linux packet filtering happens in netfilter, after the kernel has already allocated a socket buffer (an skb) for each packet, parsed it and walked it through the routing code. For a flood of junk traffic, that work is the cost. XDP, the eXpress Data Path, runs a small eBPF program inside the network driver's receive path, before any of that happens, and lets the program decide each packet's fate with a single return value.

This guide builds a working XDP blocklist in C with libbpf: it drops packets whose source address falls in a blocked IPv4 or IPv6 prefix, counts what it does, and can run in a count-only mode first. It covers what the verifier demands, how to load it with ip or bpftool, how to update the blocklist live, and how to roll it out without locking yourself out of a server.

Key takeaways

  • XDP runs before the kernel networking stack. A dropped packet never gets an skb, never reaches netfilter and never appears in tcpdump.
  • The verifier must be able to prove every packet read is in bounds. Check each header against data_end before touching it.
  • Use an LPM trie for prefixes. One map lookup finds the longest matching blocked prefix for an address.
  • Native mode is the one that pays off. Generic mode works on any interface and is ideal for testing, but runs after the skb exists.
  • Roll out in count-only mode with a timed automatic detach, and keep a way into the machine that does not depend on the interface you are filtering.

Where XDP runs

XDP attaches to one network interface's receive path in one of three modes:

Modeip keywordWhere it runsUse it for
NativexdpdrvIn the driver, on the raw receive bufferProduction, on NICs whose driver supports XDP
GenericxdpgenericAfter the kernel builds the skbTesting, or interfaces without driver support
OffloadxdpoffloadOn the NIC itselfThe small set of SmartNICs that support it, with a restricted feature set

Most mainstream server NIC drivers in current kernels support native mode, as do virtio_net and veth. Check your driver's documentation rather than assuming: if you ask ip for plain xdp, it uses native mode where available and falls back to generic silently.

The program's return value decides what happens next:

Return codeEffect
XDP_DROPDiscard the packet immediately.
XDP_PASSHand the packet to the normal kernel stack.
XDP_TXSend the (possibly rewritten) packet back out of the interface it arrived on.
XDP_REDIRECTSend it to another interface, another CPU or an AF_XDP socket, via a map.
XDP_ABORTEDDrop it and fire the xdp:xdp_exception tracepoint. Signals a program error.

The program

// SPDX-License-Identifier: GPL-2.0
#include <linux/bpf.h>
#include <linux/if_ether.h>
#include <linux/ip.h>
#include <linux/ipv6.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_endian.h>

#ifdef DRY_RUN
#define BLOCK_ACTION XDP_PASS   /* count matches, drop nothing */
#else
#define BLOCK_ACTION XDP_DROP
#endif

struct lpm_v4 {
	__u32 prefixlen;            /* host byte order, 0-32 */
	__u32 addr;                 /* network byte order */
};

struct lpm_v6 {
	__u32 prefixlen;            /* 0-128 */
	__u8  addr[16];
};

struct {
	__uint(type, BPF_MAP_TYPE_LPM_TRIE);
	__uint(max_entries, 65536);
	__type(key, struct lpm_v4);
	__type(value, __u8);
	__uint(map_flags, BPF_F_NO_PREALLOC);   /* required for LPM tries */
} block_v4 SEC(".maps");

struct {
	__uint(type, BPF_MAP_TYPE_LPM_TRIE);
	__uint(max_entries, 65536);
	__type(key, struct lpm_v6);
	__type(value, __u8);
	__uint(map_flags, BPF_F_NO_PREALLOC);
} block_v6 SEC(".maps");

enum { STAT_PASS, STAT_BLOCK, STAT_MALFORMED, STAT_MAX };

struct {
	__uint(type, BPF_MAP_TYPE_PERCPU_ARRAY);
	__uint(max_entries, STAT_MAX);
	__type(key, __u32);
	__type(value, __u64);
} stats SEC(".maps");

static __always_inline int count(__u32 idx, int action)
{
	__u64 *v = bpf_map_lookup_elem(&stats, &idx);

	if (v)                      /* the verifier insists on this check */
		*v += 1;                /* per-CPU slot, so no atomics */
	return action;
}

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

	/* Prove every read stays inside the packet before making it. */
	if ((void *)(eth + 1) > data_end)
		return count(STAT_MALFORMED, XDP_PASS);

	if (eth->h_proto == bpf_htons(ETH_P_IP)) {
		struct iphdr *ip = (void *)(eth + 1);
		struct lpm_v4 key = { .prefixlen = 32 };

		if ((void *)(ip + 1) > data_end)
			return count(STAT_MALFORMED, XDP_PASS);
		key.addr = ip->saddr;
		if (bpf_map_lookup_elem(&block_v4, &key))
			return count(STAT_BLOCK, BLOCK_ACTION);
	} else if (eth->h_proto == bpf_htons(ETH_P_IPV6)) {
		struct ipv6hdr *ip6 = (void *)(eth + 1);
		struct lpm_v6 key = { .prefixlen = 128 };

		if ((void *)(ip6 + 1) > data_end)
			return count(STAT_MALFORMED, XDP_PASS);
		__builtin_memcpy(key.addr, &ip6->saddr, sizeof(key.addr));
		if (bpf_map_lookup_elem(&block_v6, &key))
			return count(STAT_BLOCK, BLOCK_ACTION);
	}

	return count(STAT_PASS, XDP_PASS);
}

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

What the verifier requires

The kernel's verifier checks every possible path through the program before it will load. Three rules shape the code above.

  • Every packet access must be bounds-checked. ctx->data and ctx->data_end mark the start and end of the packet. The verifier rejects any read it cannot prove lies between them, so each header pointer is compared with data_end before it is dereferenced. If you delete the IPv4 check, loading fails with a message like invalid access to packet, off=26 size=4 followed by R1 offset is outside of the packet: offset 26 is the IPv4 source address, 14 bytes of Ethernet header plus 12 into the IP header.
  • Map lookups can return NULL. bpf_map_lookup_elem returns a pointer or nothing, and dereferencing it without the if (v) check is rejected.
  • No unbounded loops or arbitrary memory. Copies use fixed sizes, such as the 16-byte __builtin_memcpy of the IPv6 source address, and helpers are the only way to reach kernel data.

The program reads only the fixed part of each IP header, so IP options do not matter to it. Frames carrying an 802.1Q VLAN tag in the packet data have a different h_proto and pass untouched; on many NICs, VLAN receive offload strips the tag before XDP sees the frame, but if yours does not, parse the VLAN header too.

Why an LPM trie

A hash map answers "is this exact key present?". A blocklist needs "is this address inside any blocked prefix?", which is what BPF_MAP_TYPE_LPM_TRIE does: the key starts with a prefix length, and a lookup with a full-length key (/32 or /128) returns the longest stored prefix that contains it. One lookup covers single hosts and whole ranges. LPM tries must be created with BPF_F_NO_PREALLOC, and the address part of the key is in network byte order.

Why per-CPU counters

The stats map is a BPF_MAP_TYPE_PERCPU_ARRAY. Each CPU gets its own copy of each counter, so CPUs processing packets in parallel never contend for one cache line and need no atomic operations. The cost is moved to read time: whoever reads the map adds up the per-CPU values.

Build it

sudo apt install clang llvm libbpf-dev linux-tools-common linux-tools-generic   # Debian/Ubuntu package names
clang -O2 -g -Wall -target bpf -I/usr/include/$(uname -m)-linux-gnu \
      -c xdp_blocklist.c -o xdp_blocklist.o
clang -O2 -g -Wall -target bpf -I/usr/include/$(uname -m)-linux-gnu \
      -DDRY_RUN -c xdp_blocklist.c -o xdp_dryrun.o

The extra include path lets clang find asm/types.h on Debian-family systems. -g embeds BTF type information, which lets bpftool print map keys and values with their field names. The second build is the count-only variant: it counts matches as blocked but returns XDP_PASS.

Load, attach and detach

With ip

ip link set dev eth0 xdpgeneric obj xdp_dryrun.o sec xdp       # attach in generic mode
ip link set dev eth0 xdpgeneric off                             # detach generic
ip link set dev eth0 xdpdrv obj xdp_blocklist.o sec xdp         # attach in native mode
ip -force link set dev eth0 xdpdrv obj xdp_dryrun.o sec xdp     # replace the native program
ip -d link show dev eth0                                        # shows prog/xdp id N
ip link set dev eth0 xdpdrv off                                 # detach native

Detach using the same mode keyword you attached with. ip refuses to replace an attached program unless you pass -force, and the kernel will not run a native and a generic program on one interface at the same time, so detach one mode before attaching the other.

With bpftool and pinned maps

Pinning the program and its maps in the BPF filesystem lets you update the blocklist and read counters by path, and keeps the maps across a program replacement:

bpftool prog load xdp_blocklist.o /sys/fs/bpf/xdp_blocklist type xdp pinmaps /sys/fs/bpf/xdpbl
bpftool net attach xdpdrv pinned /sys/fs/bpf/xdp_blocklist dev eth0
bpftool net show dev eth0
bpftool net detach xdpdrv dev eth0
rm -r /sys/fs/bpf/xdp_blocklist /sys/fs/bpf/xdpbl   # unload once detached

Use xdpgeneric in place of xdpdrv for generic mode, and add overwrite to net attach to replace a program already attached. A pinned program stays loaded after you detach it, until the pins are removed.

Manage the blocklist

For the IPv4 trie, the key is 4 bytes of prefix length in host byte order, then 4 address bytes in network order. On little-endian machines such as x86-64 and arm64, 203.0.113.0/24 becomes 24 0 0 0 203 0 113 0. Two small shell helpers save counting bytes:

block4()   { local ip=${1%/*} len=${1#*/}; bpftool map update pinned /sys/fs/bpf/xdpbl/block_v4 key $len 0 0 0 ${ip//./ } value 1; }
unblock4() { local ip=${1%/*} len=${1#*/}; bpftool map delete pinned /sys/fs/bpf/xdpbl/block_v4 key $len 0 0 0 ${ip//./ }; }

block4 203.0.113.0/24
block4 198.51.100.200/32
bpftool map dump pinned /sys/fs/bpf/xdpbl/block_v4

Always store the network address with host bits zeroed. IPv6 keys are easiest in hex: 2001:db8:5::/48 is a prefix length of 48 (30 00 00 00) followed by the 16 address bytes.

bpftool map update pinned /sys/fs/bpf/xdpbl/block_v6 \
  key hex 30 00 00 00 20 01 0d b8 00 05 00 00 00 00 00 00 00 00 00 00 value 1

Updates take effect on the next packet; no reload is needed. Read the counters, summed across CPUs, with:

bpftool map dump pinned /sys/fs/bpf/xdpbl/stats | jq -c '.[] | {key, total: ([.values[].value] | add)}'

Keys 0, 1 and 2 are passed, blocked and malformed.

XDP_TX and XDP_REDIRECT

This program only drops or passes, but the other two actions are what make XDP useful beyond filtering. XDP_TX bounces a rewritten packet straight back out of the same interface, which suits answering a probe or a challenge without involving the stack. XDP_REDIRECT is returned by bpf_redirect_map() with a BPF_MAP_TYPE_DEVMAP (another interface), BPF_MAP_TYPE_CPUMAP (another CPU, to spread work) or BPF_MAP_TYPE_XSKMAP (an AF_XDP socket, which hands raw frames to a user-space program). Both need driver support in native mode, and redirecting to another interface also needs that interface to support it.

Roll it out safely

A filter at this layer can cut off all traffic, including your own SSH session, with one wrong prefix. Take it in steps.

  1. Keep a way in that bypasses the filter: an out-of-band console, a separate management interface, or at minimum a management address you never add to the list.
  2. Test the logic offline. bpftool prog run pinned /sys/fs/bpf/xdp_blocklist data_in packet.bin repeat 1 runs the program against a captured frame and prints its return value: 1 for XDP_DROP, 2 for XDP_PASS. Test runs also increment the counters. Build a few frames from documentation addresses inside and outside your blocked prefixes and check each verdict.
  3. Arm an automatic detach before attaching anything. systemd-run --on-active=10m /usr/sbin/ip link set dev eth0 xdpdrv off schedules a detach in ten minutes; use the mode keyword you will attach with. If you lose access, it undoes the change. Cancel it with systemctl stop on the timer it names once you are confident.
  4. Start with the count-only build. Load xdp_dryrun.o with pinmaps /sys/fs/bpf/xdpbl, attach it, fill the blocklist and watch the blocked counter. If it counts your monitoring system, your load balancer or your office, fix the list before anything is dropped.
  5. Switch to enforcing on one host, reusing the same pinned maps so the blocklist and counters carry over, then watch the counters, application error rates and connection counts before going wider.

The switch in step 5 loads the enforcing build against the existing maps and swaps it in place:

bpftool prog load xdp_blocklist.o /sys/fs/bpf/xdp_blocklist type xdp \
  map name block_v4 pinned /sys/fs/bpf/xdpbl/block_v4 \
  map name block_v6 pinned /sys/fs/bpf/xdpbl/block_v6 \
  map name stats pinned /sys/fs/bpf/xdpbl/stats
bpftool net attach xdpdrv pinned /sys/fs/bpf/xdp_blocklist dev eth0 overwrite

What XDP will not do

  • It cannot help once the link is full. If an attack saturates the upstream connection, dropping packets on the server is too late. Volumetric floods need filtering upstream.
  • Source blocklists are weak against spoofing. Floods from forged or rotating addresses need rules on other packet features, such as protocol, port or flags, or SYN cookies in the stack.
  • It sees ingress only. XDP has no hook on transmit; use tc for egress.
  • It keeps no connection state unless you build it in maps yourself.

Failure modes and troubleshooting

SymptomLikely cause and fix
Load fails with invalid access to packetA header read is not bounds-checked against data_end. Add the check before the read.
Load fails with a NULL-pointer error on a map valueA lookup result is used without checking it.
Native attach refusedThe driver lacks XDP support, or the MTU or offloads conflict. Some drivers need LRO off or a smaller MTU unless the program declares multi-buffer support with SEC("xdp.frags").
Counters stay at zeroAttached to the wrong interface, such as a bond, bridge or VLAN device instead of the one receiving traffic. Check with bpftool net show.
Dropped traffic not in tcpdump or nftables countersExpected. XDP drops happen before both; read the program's own counters.
ip will not replace the programAdd -force, or use bpftool net attach ... overwrite.
Blocklist update failsThe trie is full at max_entries, or the key length is wrong. Check with bpftool map show.

Checklist

  1. Compile with -O2 -g -target bpf and load once in generic mode on a test box.
  2. Bounds-check every header against data_end and NULL-check every map lookup.
  3. Use an LPM trie for prefixes and store network addresses with host bits zeroed.
  4. Count with a per-CPU array and sum across CPUs when reading.
  5. Pin maps so blocklists and counters survive program replacement.
  6. Verify verdicts offline with bpftool prog run and documentation-range frames.
  7. Arm a timed detach and keep out-of-band access before attaching.
  8. Run count-only first, then enforce on one host, then widen.
  9. Detach with the same mode keyword you attached with, and remove pins to unload.

Closing

XDP moves the cheapest possible decision, drop or pass, to the earliest possible point. That makes it a strong tool against floods from known sources, as long as you respect its limits: it cannot unclog a full link, and an address list is only as good as the addresses are real. Keep the program small, test it offline, and roll it out the way you would any change that can lock you out.

For more notes on running fast, reliable sites, see The Daily Tide.