Skip to content

eBPF XDP: Block IPs Before the Linux Network Stack (Tested Example)

Last updated on October 27, 2026

Updated October 2026. I rebuilt every example in this post from scratch and ran it on Debian 13 (clang 19, libbpf 1.5, bpftool 7.5, iproute2 6.15). The first version had a bpftool command that does not parse, skipped the compile error almost everyone hits, and lost the block list on every reload. Those are fixed below, and the unsourced performance table is gone.

What is XDP? XDP (eXpress Data Path) is a hook in the network driver where a small eBPF program sees every incoming packet before the kernel allocates anything for it. The program answers with XDP_PASS (continue as usual), XDP_DROP (throw it away), XDP_TX (bounce it back out the same port) or XDP_REDIRECT (send it to another interface, CPU or an AF_XDP socket). Because the decision happens before conntrack, routing and netfilter, dropping a packet in XDP costs a fraction of what the same rule costs in iptables or nftables.

eBPF is the part that makes this safe: the kernel verifies the program before loading it (no unbounded loops, no reads outside the packet), JIT-compiles it, and lets user space share data with it through maps. That is all we need for a practical tool: an IP block list you can change at runtime, with a drop counter per address.

When XDP is worth it

  • You need to drop a lot of traffic from known sources and nftables already shows up in top as softirq load.
  • You want the drop to happen before conntrack, so a flood does not fill the conntrack table.
  • You do not need it for a handful of firewall rules on a quiet server. nftables is easier to read and audit, and it sees both directions. XDP only sees incoming packets.

1. Install the tools

sudo apt install clang llvm libbpf-dev bpftool iproute2

You do not need kernel headers for this. libbpf-dev brings bpf_helpers.h, and the UAPI headers in /usr/include/linux come from linux-libc-dev, which libc6-dev pulls in. On Fedora and RHEL the packages are clang llvm libbpf-devel bpftool iproute.

2. The program

Save as xdp_block.c. It looks up the source address of every IPv4 packet in a hash map, drops the packet if the address is there and bumps a counter for that address:

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

/* Source IPv4 addresses to drop. Key: address in network byte order. */
struct {
	__uint(type, BPF_MAP_TYPE_HASH);
	__uint(max_entries, 65536);
	__type(key, __u32);
	__type(value, __u64);           /* packets dropped from this address */
	__uint(pinning, LIBBPF_PIN_BY_NAME);
} blocked_ips SEC(".maps");

SEC("xdp")
int xdp_block(struct xdp_md *ctx)
{
	void *data_end = (void *)(long)ctx->data_end;
	void *data = (void *)(long)ctx->data;
	struct ethhdr *eth = data;
	struct iphdr *ip;
	__u64 *drops;

	if ((void *)(eth + 1) > data_end)
		return XDP_PASS;
	if (eth->h_proto != bpf_htons(ETH_P_IP))
		return XDP_PASS;

	ip = (void *)(eth + 1);
	if ((void *)(ip + 1) > data_end)
		return XDP_PASS;

	drops = bpf_map_lookup_elem(&blocked_ips, &ip->saddr);
	if (drops) {
		__sync_fetch_and_add(drops, 1);
		return XDP_DROP;
	}
	return XDP_PASS;
}

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

Two details that matter later. The key is the address exactly as it sits in the packet, in network byte order, so user space has to write it the same way. And LIBBPF_PIN_BY_NAME pins the map to the BPF filesystem, so it survives reloading the program.

3. Compile it

clang -O2 -g -target bpf -I/usr/include/$(uname -m)-linux-gnu -c xdp_block.c -o xdp_block.o

Without the -I part you get this, on Debian and Ubuntu at least:

In file included from xdp_block.c:2:
In file included from /usr/include/linux/bpf.h:11:
/usr/include/linux/types.h:5:10: fatal error: 'asm/types.h' file not found

With -target bpf clang does not look in the architecture-specific include directory, where asm/types.h lives. Pointing it there explicitly fixes it. The usual forum answer is to install gcc-multilib, but the -I flag does the job without pulling in a 32-bit toolchain.

4. Attach it to an interface

sudo ip link set dev eth0 xdp obj xdp_block.o sec xdp
ip -d link show eth0 | grep prog/xdp
prog/xdp id 464

xdp lets the kernel choose: native driver mode if the driver supports it, generic mode otherwise. Generic mode works on any interface but runs later, after the kernel has already allocated a buffer, so most of the speed advantage is gone. Use xdpdrv to insist on native mode: on a driver without support it fails with Error: Underlying driver does not support XDP in native mode. instead of falling back silently. The pinned map shows up here:

sudo ls /sys/fs/bpf/tc/globals/
blocked_ips

5. Block and unblock addresses

bpftool wants the key as separate bytes. For 203.0.113.7 that is 203 0 113 7, and the value is the 8-byte counter, starting at zero:

M=/sys/fs/bpf/tc/globals/blocked_ips
sudo bpftool map update pinned $M key 203 0 113 7 value 0 0 0 0 0 0 0 0
sudo bpftool map dump pinned $M
sudo bpftool map delete pinned $M key 203 0 113 7

Typing bytes gets old quickly, so I use a small wrapper:

#!/bin/sh
# /usr/local/bin/xdp-block: add IP | del IP | list
M=/sys/fs/bpf/tc/globals/blocked_ips
case "$1" in
  add)  bpftool map update pinned $M key $(echo "$2" | tr . " ") value 0 0 0 0 0 0 0 0 ;;
  del)  bpftool map delete pinned $M key $(echo "$2" | tr . " ") ;;
  list) bpftool -j map dump pinned $M | python3 -c "
import json, sys
for e in json.load(sys.stdin):
    print('.'.join(str(int(b, 16)) for b in e['key']), e['formatted']['value'], 'packets dropped')" ;;
  *)    echo "usage: $0 add IP | del IP | list" >&2; exit 1 ;;
esac

In my lab the client behind a veth pair was 10.10.0.2. After blocking it, three pings disappeared and the counter showed them:

sudo xdp-block add 10.10.0.2
ping -c 3 10.10.0.1        # from the client: 100% packet loss
sudo xdp-block list
10.10.0.2 3 packets dropped

xdp-block del 10.10.0.2 and the next ping goes through. No reload, no rule reordering, the change is live on the next packet.

6. Reload and detach

Attaching a second time fails, because there is already a program on the interface:

Error: XDP program already attached.

Replace it with -force. Thanks to the pinned map the block list is still there afterwards. Without pinning every reload starts with an empty map, which is exactly what the old version of this post did:

sudo ip -force link set dev eth0 xdp obj xdp_block.o sec xdp
sudo ip link set dev eth0 xdp off

xdp off removes the program. The pinned map stays in /sys/fs/bpf until you delete the file or reboot.

Gotchas

  • key 0x01020304 does not work. bpftool parses keys byte by byte and fails with Error: error parsing byte: value. Pass four separate bytes in network order.
  • The dump shows the key as a number. With BTF, bpftool map dump prints 10.10.0.2 as 33557002, the bytes read as a little-endian integer. The raw bytes are in bpftool -j map dump, which is what the wrapper uses.
  • tcpdump does not see dropped packets. XDP runs before the capture point, so a blocked source looks like silence, not like packets being rejected. Use the counter.
  • Do not lock yourself out. Never test on the interface you are logged in through without a console at hand. One wrong address in the map and your SSH session is gone, and no firewall rule will let you back in, because XDP sits in front of all of them.
  • IPv4 only. The program passes IPv6 untouched. Blocking IPv6 needs a second map keyed by struct in6_addr and a check for ETH_P_IPV6.
  • Ingress only. XDP never sees traffic leaving the host. For that you need tc or nftables.

Takeaways

  • XDP drops packets in the driver, before conntrack and netfilter, which makes it the cheapest place to discard traffic you never want.
  • Compile with -I/usr/include/$(uname -m)-linux-gnu, attach with ip link ... xdp, check with ip -d link.
  • Pin the map with LIBBPF_PIN_BY_NAME so reloads keep the block list.
  • bpftool keys are bytes in network order, and tcpdump will not show what XDP dropped.

Links

Published inLinuxMonitoringSecurity