
XDP SYN Flood Protection: Building eBPF Defenses
A working XDP SYN flood defense is a small eBPF program that parses the Ethernet, IP, and TCP headers with real bounds checks, verifies the SYN flag, rate-limits each source IP in a per-CPU map, and returns XDP_DROP before the packet reaches the socket layer. Attach it to your NIC with iproute2 or bpftool, key a hash map on the source address, and drop bursts over threshold. That is how you protect Linux from a SYN flood at line rate. Copy-pasted snippets that skip bounds checking either fail the verifier or die under load. Read the verifier log and trace the running program before you trust it.
Last updated: 2026-07-21
I build small, safe kernel programs that stop high-rate packet floods at the NIC driver level so the system stays responsive under stress. XDP runs before the Linux networking stack, letting an eBPF program parse headers, count packets per source IP, and drop bursts with minimal latency. The whole point is to decide a packet's fate at the earliest entry point, where a drop costs almost nothing.
What does an XDP SYN flood program actually look like?
It looks like straight-line C with no loops and a bounds check before every read. Here is the order: cast the packet start to an Ethernet header, confirm data + sizeof(ethhdr) <= data_end, check the EtherType for IPv4, then repeat that check for the IP header, then the TCP header. Skip any one of those and the verifier rejects the load with a pointer arithmetic error. That rejection is the verifier doing its job, not a bug.
Once the headers are safe, read the TCP flags and act only on a SYN with no ACK. That single filter matters. A SYN flood is cheap to send and expensive to answer, because each SYN makes the kernel allocate a half-open connection. Dropping the abusive SYNs at the driver keeps that table from filling.
The baseline I use keys a hash map on the IPv4 source address. Each entry holds a last_update timestamp and a packet_count. I call bpf_ktime_get_ns() for a monotonic clock, compare against a one-second window, and reset the count when the window rolls. If a single source crosses the default threshold of 250 packets per second, the packet returns XDP_DROP. Everything under threshold, and every non-IPv4 frame, returns XDP_PASS.
#define THRESHOLD 250
#define TIME_WINDOW_NS 1000000000ULL
Keep the constants at the top so tuning is one edit. And tag the file GPL, because most helpers refuse to load otherwise.
How XDP DDoS protection works at the packet level
XDP sits in the NIC driver, before sk_buff allocation and before the network stack builds any per-packet state. That placement is the entire advantage for DDoS protection on Linux. A dropped packet at this hook never touches the routing code, netfilter, or the socket layer, so a flood costs the host almost nothing per packet.
The trade-off is less context. At the XDP hook you have raw bytes and a length, nothing more. No connection tracking, no socket, no reassembly. So the work is stateless-first: you decide from the headers in front of you and whatever you stashed in a map. For most volumetric attacks that is enough, and it is why XDP scales where an iptables rule chain would melt.
If you want the hook explained with latency numbers and attach modes, I wrote that up separately in a guide on using XDP for low-latency processing.
The core building blocks of eBPF DDoS protection
Three pieces show up in every eBPF DDoS design: the program type, the return actions, and the maps. Get these straight and the rest is detail.
- Program type.
BPF_PROG_TYPE_XDPfor the driver hook. TC (BPF_PROG_TYPE_SCHED_CLS) when you needsk_buffcontext or egress. Socket filters when you are filtering a single socket, not the wire. - Return actions.
XDP_PASShands the packet up the stack.XDP_DROPfrees it immediately.XDP_TXbounces it back out the same NIC.XDP_REDIRECTsends it to another interface or a userspace ring. - Maps. The shared memory between kernel and user space. Hash maps for per-key state, per-CPU arrays for hot counters, ring buffers for sending samples up to a userspace collector.
These building blocks tie XDP and general Linux DDoS protection together. Whether you are dropping a SYN flood, rate-limiting a prefix, or sampling for analysis, you are choosing a program type, picking an action per packet, and reading or writing a map. Nothing more exotic than that.
Tracking traffic with PerCpuArray maps
Reach for a BPF_MAP_TYPE_PERCPU_ARRAY when a plain hash map starts contending. Here is the real problem with a central hash entry: under a flood, every CPU tries to update the same counter for the same source IP, and they serialize on it. That contention is the last thing you want when packets arrive faster than you can count them.
A per-CPU array gives each CPU its own slot, so increments need no atomics and no lock. Each core bumps its local count, and you sum the slots later from user space or during an occasional lookup. The cost is memory and a bit of aggregation logic, and the counts are approximate for a moment until you add them up.
| Design | Where counters live | Good for | Watch out for |
|---|---|---|---|
| Hash map | One entry per source IP | Simple gating logic | Contends across many CPUs |
| PerCpuArray | One slot per CPU per key | Low-contention counting | Needs aggregation, more memory |
| Windowing | last_update + packet_count | Predictable thresholds | Sliding windows add complexity |
For a SYN flood I usually start with the hash map because the logic is easy to read. When the box has many cores and the flood is heavy, I move the counters to per-CPU and aggregate. Measure first, and do not add the complexity on a hunch.
bpf_xdp_adjust_head versus bpf_skb_load_bytes
These two helpers solve different problems and live in different contexts, and mixing them up is a common way to fail the verifier.
bpf_xdp_adjust_head moves the start of the packet in an XDP program. You grow or shrink the headroom to add or remove headers, which is how you prepend a response or strip an encapsulation before an XDP_TX. After you call it, your old data and data_end pointers are stale. You must re-read them from the context and re-run every bounds check, or the verifier stops you cold. That reload trips people constantly.
bpf_skb_load_bytes is for the TC and socket-filter world, where you have an sk_buff rather than a raw XDP buffer. It safely copies bytes out of a possibly non-linear packet into your own buffer. Use it when the data you need might live in a paged fragment, so a direct pointer read would miss it.
The short version: in BPF_PROG_TYPE_XDP, you adjust headroom and reload pointers. In a TC or socket program with a possibly fragmented packet, you load bytes into a local buffer. Match the helper to your context. The first blog post you found probably reached for the wrong one.
Handling UDP floods differently from SYN floods
Treat a UDP flood as pure volume and a SYN flood as state exhaustion. A SYN flood hurts because each SYN reserves a half-open connection, so the answer is a cookie or a strict per-IP rate limit that keeps that table from filling. A UDP flood has no handshake and no state to protect, so there is nothing clever to track.
For UDP, count and drop. Match the destination port, threshold the source or prefix in a per-CPU counter, and XDP_DROP the overage. Do not build connection state for a connectionless protocol; that only burns map memory an attacker can force you to allocate. This is where per-CPU counters earn their keep, because UDP floods tend to spread across every core at once.
One gotcha: spoofed source IPs. Both flood types often forge the source address, so a per-source rate limit degrades into per-nothing. When sources are random, fall back to a per-destination-port or per-prefix budget, and accept that you are shaping aggregate rate rather than pinning a culprit.
Redirecting flood traffic to a honeypot for analysis
Sometimes dropping wastes evidence. Instead of XDP_DROP, use XDP_TX to bounce suspect packets back out, or XDP_REDIRECT to shunt them to an isolated interface or a userspace ring where a collector can inspect them. That turns your mitigation into a sensor without slowing the fast path.
I keep the redirect target on a separate NIC or namespace so a flood aimed at the honeypot cannot leak into production. Sample rather than mirror everything; a ring buffer plus a sampling rate keeps the analysis box alive under a real attack. If you want to see abusive sources before you shape them, pair this with a program that blocks IP addresses with eBPF once the analysis names names.
Redirection costs more than a drop, so reserve it for flows you actually want to study. Under a full-rate flood, redirect a sample and drop the rest.
Loading and pinning an XDP program to eth0
Compile first, then attach, then verify. This is where the docs for bpftool prog load and ip link set dev eth0 xdp pinned tend to leave gaps, so here is the workflow that holds together.
Build the object with clang, no linking:
clang -O2 -g -target bpf -c xdp_ebpf_prog.c -o xdp_ebpf_prog.o
-O2 keeps the instruction count down, which matters against the verifier's limit. -target bpf emits BPF bytecode. Skip the link step so the loader can find the xdp section.
The straight path with iproute2:
IFACE=eth0
sudo ip link set dev $IFACE xdp obj xdp_ebpf_prog.o sec xdp
The pinned path with bpftool, when you want the program to outlive the loader:
sudo bpftool prog load xdp_ebpf_prog.o /sys/fs/bpf/xdp_syn
sudo ip link set dev eth0 xdp pinned /sys/fs/bpf/xdp_syn
The gap nobody spells out: pinning needs the BPF filesystem mounted at /sys/fs/bpf. The program then stays loaded until you both detach it from the interface and rm the pin. Detach with ip link set dev eth0 xdp off. Forget the pin and you will wonder why an old program is still filtering after you thought you removed it. On a laptop without native driver support, add xdpgeneric to force SKB mode, and expect worse numbers than a real NIC.
Is Rust with aya worth it over C?
Use aya when your loader and control plane are already Rust. It will not make the kernel code any safer. The eBPF the verifier sees is the same either way, and the same bounds checks and instruction limit apply. Rust's borrow checker does not follow your bytes into the packet buffer, so you still bounds-check every read by hand.
What aya genuinely buys you is one language and one toolchain across the kernel program and the userspace loader, with no separate libbpf C dance. That is real ergonomics if your team lives in Rust. The cost is a smaller ecosystem of examples and more moving parts when the build breaks, and eBPF build failures are already annoying enough.
My call: if you are writing the userspace side in C or already lean on libbpf, stay in C for the XDP program too, because the examples and the kernel docs line up with what you are doing. If your platform is Rust top to bottom, aya is a reasonable switch. Do not rewrite a working C program in Rust for its own sake.
How do you confirm it is actually dropping packets?
A clean load proves nothing about the attack. The program compiled and the verifier accepted it; that tells you the code is safe to run. Whether it stops the flood is a separate question. Confirm the drops under load, or you are trusting a green light that means nothing.
Run this first: bpftool prog show to confirm the program is attached and get its ID, then bpftool map dump on your counter map while you generate traffic. The numbers moving is your evidence. Watch a stats map that increments on every XDP_DROP, and expect it to climb only when a source crosses threshold.
Then generate a real flood in a lab. I test with a Docker Nginx target and hping3 firing SYNs, and I read the drop counter and the target's connection table side by side. If drops rise and the half-open connections stay flat, the program works. Cross-check the kernel side too:
sudo bpftool prog trace
strace -f -e trace=bpf ./your_loader
bpftool prog trace surfaces bpf_printk output from the program so you can see which branch a packet took. strace on the loader shows the bpf() syscalls and any EACCES or E2BIG the verifier threw back. One caveat from experience: loopback tests flatter your results, because lo skips the driver fast path. Expect the real gains on a physical NIC in native mode, and validate there before you call it done. For watching drops in production, wire it into how you monitor packet drops with eBPF.
Production notes and where the numbers come from
A few operational habits keep this from biting you later. Size your hash map for the source cardinality you expect and add an eviction or aging pass, or a spoofed flood will fill it. Prefer native driver mode over generic SKB mode on any NIC that supports it. And keep the program straight-line and loop-free, because the verifier walks every path and the one-million-instruction limit is real; complex code hits it fast.
The default threshold of 250 packets per second is a starting point, not a law. It is deliberately conservative so a legitimate burst does not get punished on a small box. Raise it for busy public services and lower it for quiet backends, and always tune against a measured baseline instead of a number lifted off a blog. For the flag semantics and helper contracts, the bpf-helpers manual page is the primary reference worth keeping open.
Cloudflare's move to an always-on XDP model shows this scales: compiled rules, per-packet sampling, and drops spread across every CPU. You do not need their scale to borrow the shape. If you want an off-the-shelf starting point, the XDP firewall setup for OpenWRT covers the same attach-and-verify loop on a router.
FAQ
Why does my XDP program load fine but drop nothing?
Almost always the packets never reach your check. Confirm the attach mode with bpftool prog show and ip link show dev eth0; a generic-mode attach on a NIC you thought was native behaves differently. Then add a bpf_printk on the parse path and read it with bpftool prog trace. If the print never fires, the EtherType or protocol filter is rejecting traffic before your counter, so check the byte order on your header comparisons.
Can I run this on a virtual machine or does it need bare metal?
It runs in a VM, but the driver fast path may not. Many virtual NICs only support generic (SKB) mode, so you get correct behavior without the line-rate drop cost. Test the logic in the VM, then benchmark on a physical NIC with native XDP support, because generic-mode numbers will not tell you how the real path performs.
Does XDP protect against application-layer (L7) attacks?
No. XDP sees raw packets and has no idea there is an HTTP request or a TLS session inside them, so it works at layers 3 and 4. It stops SYN floods, UDP floods, and volumetric junk before the stack, but a slow-request or L7 flood needs a proxy or application-aware filter above it. Keep XDP as your volumetric front line and put an application-aware filter above it for everything else.
What happens to legitimate traffic from a busy source behind NAT?
A per-source-IP limit treats a NAT gateway as one client, so many real users behind one address can trip your threshold together. When you know a source is a shared egress, raise its budget or key on something finer than the source IP alone. This is the main reason to tune thresholds against a real baseline rather than trusting the default.
How do I remove the program cleanly if it misbehaves?
Detach from the interface first with ip link set dev eth0 xdp off, then remove any pin under /sys/fs/bpf if you pinned it. Skipping the pin removal leaves the program loaded and can leave stale filtering in place. Confirm it is gone with bpftool prog show before you assume the interface is clear.
