Skip to main content
Let's Mitigate DDoS Attacks with XDP
  1. Ebpf-Another-Types/

Let's Mitigate DDoS Attacks with XDP

·1610 words·8 mins·
Joseph Ligier
Author
Joseph Ligier
CNCF ambassador | Kubestronaut 🐝
Table of Contents
Getting Started with XDP in Aya - This article is part of a series.
Part 3: This Article

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!

lab

I assume you are already in an environment for developing with Aya. If not, you can use the Killercoda lab:

Killer coda screenshot


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

namepaces

Let’s start a small web server at the lb namespace level:

ip netns exec lb python3 -m http.server 8080
  • On the veth6 interface, we will see the TCP packets sent by the client (such as SYN);
  • On the veth7 interface, we will see the TCP packets sent by the server (such as SYN-ACK).

We therefore need to install the eBPF program on veth6.

namepaces

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
Since the namespace does not have Internet access, we must first download the crates to compile them on the server, then install the eBPF program in the namespace.

Now we will retrieve only TCP packets in the eBPF program. We will modify the antiddos-xdp-ebpf/src/main.rs file.

headers

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).

namepaces

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

namepaces

To display a little more detail about the nature of the packets, let’s now look at the TCP segment header:

TCP Hdr

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:

TCPHdr documentation

To retrieve the nature of the segment, there are even functions to help us:

TCPHdr documentation
To reproduce a small tcpdump, let’s add this, for example:

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

👉 Become a premium member now

Getting Started with XDP in Aya - This article is part of a series.
Part 3: This Article