We saw what an XDP program is in part one: it can be used to mitigate distributed denial-of-service (DDoS) attacks.
In this part, i will show you how to create an XDP program that mitigates SYN flood attacks. We’ll do that with the Rust Aya framework.
Follow the guide!
I assume you are already in an environment for developing with Aya. If not, you can use the Killercoda lab:
Let’s create a little tcpdump with XDP#
Before doing that, we will first explain how to analyze a TCP segment with XDP and thus retrieve SYN packets. By the way, do you remember how TCP works?
TCP connections#
TCP stands for Transmission Control Protocol. It is probably the most widely used network protocol by application protocols such as HTTP, SSH, etc. It is difficult to ignore in articles about networks. However, it is probably the most complex when compared to UDP or ICMP.
Before downloading useful data, there is a connection phase (3-way handshake) to establish a reliable channel.
What is SYN Flood?#
SYN is the first TCP segment used to initiate a TCP connection. When a server receives a SYN, it must send back a SYN-ACK to acknowledge receipt. Once this is done, the client must confirm with an ACK and the data download can finally take place.
Suppose a client sends a SYN connection but never responds. What happens?
- The server sends a SYN-ACK in response and then retries 5 times (default value).
- The server finally closes the incomplete connection
Suppose a client floods the server with lots of SYN connections but never responds. What happens?
- The server then keeps a lot of connections “pending” (some of which are probably legitimate)
- The server continues to send SYN-ACK until its memory resources are saturated
That’s the principle behind a SYN Flood attack.
In this article, to counter this attack, we will count the number of SYN per IP and if there are too many, we will temporarily block these segments for that IP.
How to view segment details#
If you haven’t already done so, let’s recreate the development environment:
git clone https://github.com/littlejo/eunomia.dev
cd eunomia.dev/docs/tutorials/42-xdp-loadbalancer/
./setup.sh
Let’s start a small web server at the lb namespace level:
ip netns exec lb python3 -m http.server 8080
- On the
veth6interface, we will see the TCP packets sent by the client (such as SYN); - On the
veth7interface, we will see the TCP packets sent by the server (such as SYN-ACK).
We therefore need to install the eBPF program on veth6.
Let’s generate the Aya program:
cargo generate --name antiddos-xdp \
-d program_type=xdp \
-d default_iface=veth6 \
https://github.com/aya-rs/aya-template
cd antiddos-xdp
In anticipation, we will use additional crates:
- aya-ebpf-bindings for additional eBPF bindings
- network-types for Rust structures of level 1, 2, and 3 headers
- blog-xdp, the crate from this blog, so we don’t have to copy/paste previously written helper functions
So we’re going to modify the antiddos-xdp-ebpf/Cargo.toml file and add the following to the dependencies section:
aya-ebpf-bindings = "0.1.1"
network-types = "0.1.0"
blog-xdp = { git = "https://github.com/littlejo/blog-xdp" }
Let’s check that the generated program works properly:
First, we’ll build the binary:
cargo build
Then we’ll install it in the target namespace where the veth6 interface is located:
ip netns exec lb cargo run
Now we will retrieve only TCP packets in the eBPF program. We will modify the antiddos-xdp-ebpf/src/main.rs file.
We end up with code similar to the code in the previous section:
use network_types::{
eth::{EthHdr, EtherType},
ip::{Ipv4Hdr, IpProto},
tcp::TcpHdr,
};
use blog_xdp::helper::ptr_at;
fn try_antiddos_xdp(ctx: XdpContext) -> Result<u32, u32> {
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
match unsafe { (*ethhdr).ether_type() } {
Ok(EtherType::Ipv4) => {}
_ => return Ok(xdp_action::XDP_PASS),
}
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)? ;
match unsafe { (*ipv4hdr).proto } {
IpProto::Tcp => {}
_ => return Ok(xdp_action::XDP_PASS),
}
let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? ;
let src_addr = unsafe { (*ipv4hdr).src_addr };
let dst_addr = unsafe { (*ipv4hdr).dst_addr };
info!(&ctx, "src:{:i} => dst:{:i}", src_addr, dst_addr);
Ok(xdp_action::XDP_PASS)
}
We can test that it works:
ip netns exec lb cargo run
Then, use the following command to test the XDP program:
curl 10.0.0.10:8080
In the Cargo output, we can see several packets passing through:
[INFO antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
[INFO antiddos_xdp] src:10.0.0.1 => dst:10.0.0.10
As I explained at the beginning, when we launch a TCP connection, there is not just one packet but several packets.
We only see the packets sent by the client (those marked ingress).
If we want to see the packets sent by the server, we need to install the program on the veth7 interface. It’s very simple, just override the iface variable:
cargo run -- --iface veth7
To display a little more detail about the nature of the packets, let’s now look at the TCP segment header:
For purists, the diagram is somewhat simplified.
Let’s take a quick look at what the main data is used for:
- SRC/DST PORT: TCP ports
- SEQUENCE/ACK NUMBER: for connection tracking
- BITFIELD: this is where we define the nature of the segment (SYN, ACK, etc.)
- WINDOW: for flow control (useful for big files)
- CHECKSUM: for segment integrity
As with UDP, we can easily retrieve the elements by looking at the crate documentation network-types:
To retrieve the nature of the segment, there are even functions to help us:

let src_port = u16::from_be_bytes(unsafe { (*tcphdr).source });
let dst_port = u16::from_be_bytes(unsafe { (*tcphdr).dest });
let seq = u32::from_be_bytes(unsafe {(*tcphdr).seq});
let ack_seq = u32::from_be_bytes(unsafe {(*tcphdr).ack_seq});
let syn = unsafe {(*tcphdr).syn()};
let ack = unsafe {(*tcphdr).ack()};
let psh = unsafe {(*tcphdr).psh()};
let fin = unsafe {(*tcphdr).fin()};
info!(&ctx, "seq: {}, ack_seq: {}, syn: {}, ack: {}, data: {}, fin: {}, src: {:i}:{}, dst: {:i}:{}",
seq, ack_seq, syn, ack, psh, fin, src_addr, src_port, dst_addr, dst_port);
Let’s install this program on both network interfaces:
ip netns exec lb cargo run
cargo run -- --iface veth7
Let’s launch a connection:
curl 10.0.0.10:8080
At the veth6 level, we can then see the details of the different segments:
[INFO antiddos_xdp] seq: 2481734691, ack_seq: 0, syn: 1, ack: 0, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO antiddos_xdp] seq: 2481734692, ack_seq: 161636304, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO antiddos_xdp] seq: 2481734692, ack_seq: 161636304, syn: 0, ack: 1, data: 1, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO antiddos_xdp] seq: 2481734769, ack_seq: 161636460, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO antiddos_xdp] seq: 2481734769, ack_seq: 161637894, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO antiddos_xdp] seq: 2481734769, ack_seq: 161637894, syn: 0, ack: 1, data: 0, fin: 1, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
[INFO antiddos_xdp] seq: 2481734770, ack_seq: 161637895, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.1:47632, dst: 10.0.0.10:8080
At the veth7 level:
[INFO antiddos_xdp] seq: 161636303, ack_seq: 2481734692, syn: 1, ack: 1, data: 0, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO antiddos_xdp] seq: 161636304, ack_seq: 2481734769, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO antiddos_xdp] seq: 161636304, ack_seq: 2481734769, syn: 0, ack: 1, data: 1, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO antiddos_xdp] seq: 161636460, ack_seq: 2481734769, syn: 0, ack: 1, data: 1, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO antiddos_xdp] seq: 161637894, ack_seq: 2481734769, syn: 0, ack: 1, data: 0, fin: 1, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
[INFO antiddos_xdp] seq: 161637895, ack_seq: 2481734770, syn: 0, ack: 1, data: 0, fin: 0, src: 10.0.0.10:8080, dst: 10.0.0.1:47632,
Now that we have seen all the details of a TCP segment, we will now focus solely on SYN type segments:
match unsafe { (*tcphdr).syn() } {
1 => {}
_ => return Ok(xdp_action::XDP_PASS),
}
If we are dealing with a SYN, we continue processing; otherwise, we let the packet pass.
Since we will no longer be touching this part of the code, we will create a function:
#[inline(always)]
fn filter_tcp_syn_src(ctx: &XdpContext) -> Option<[u8; 4]> {
let ethhdr: *const EthHdr = ptr_at(&ctx, 0).ok()?;
match unsafe { (*ethhdr).ether_type() } {
Ok(EtherType::Ipv4) => {}
_ => return None,
}
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN).ok()? ;
match unsafe { (*ipv4hdr).proto } {
IpProto::Tcp => {}
_ => return None,
}
let tcphdr: *const TcpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN).ok()? ;
match unsafe { (*tcphdr).syn() } {
1 => {}
_ => return None,
}
let src_addr = unsafe { (*ipv4hdr).src_addr };
Some(src_addr)
}
fn try_antiddos_xdp(ctx: XdpContext) -> Result<u32, u32> {
let src_addr = match filter_tcp_syn_src(&ctx) {
Some(x) => x,
None => return Ok(xdp_action::XDP_PASS),
};
info!(ctx, "{}", src_addr);
Ok(xdp_action::XDP_PASS)
}
We only retrieve the source IP address from this function in order to potentially block it.
This will allow us to focus on the code to prevent the attack.
Let’s try mitigating SYN flood attacks#
Continued reading reserved for premium members ✨
The full article is only available to premium members.
Becoming a premium member is easy: just make a small donation 💖
In exchange, you will receive for 1 year (early bird offer):
- Access to all full articles as soon as they are published
- Early reading before public release
- Participation in supporting this independent blog
- Exclusive access to my vacation photos in Dubai
Your donation will help:
- Make me less dependent on large platforms
- Encourage me to create more technical content
- Lift the paywall more quickly for everyone



