Nous avons vu ce qu’était un programme XDP dans la première partie : cela peut être un moyen de limiter les attaques par déni de service distribué.
Dans cette partie, je vous propose de créer un programme XDP qui va limiter les attaques par SYN flood. On va faire cela avec le framework Rust Aya.
Suivez le guide !
Je suppose que vous êtes déjà dans un environnement pour développer avec Aya. Si ce n’est pas le cas, vous pouvez utiliser le lab Killercoda :
Créons un petit tcpdump avec XDP#
Avant de faire cela, nous allons déjà expliquer comment on analyse un segment TCP avec XDP et ainsi récupérer les paquets SYN. Au fait, vous vous rappelez comment fonctionne TCP ?
Les connexions TCP#
TCP est l’acronyme de Transmission Control Protocol. C’est probablement le protocole réseau le plus utilisé par les protocoles applicatifs : HTTP, SSH, etc. Difficile de faire l’impasse pour des articles sur du réseau. Cependant c’est probablement le plus complexe si on compare à UDP ou à ICMP.
Avant le téléchargement des données utiles, il y a une phase de connexion (3-way handshake) permettant d’établir un canal fiable.
Qu’est-ce que le SYN Flood ?#
SYN est le premier segment TCP qui sert à initier une connexion TCP. Quand un serveur reçoit un SYN, il doit renvoyer un SYN-ACK pour accuser de réception. Une fois cela, le client doit confirmer par un ACK et le téléchargement des données peut enfin opérer.
Supposons qu’un client envoie une connexion SYN mais ne répond jamais, que se passe-t-il ?
- Le serveur envoie un SYN-ACK en réponse puis réessaie 5 fois (valeur par défaut)
- Le serveur ferme enfin la connexion incomplète
Supposons qu’un client innonde de plein de connexions SYN mais ne répond jamais, que se passe-t-il ?
- Le serveur garde alors plein de connexions “en attente” (dont certain probablement légitime)
- Le serveur continue de renvoyer les SYN-ACK jusqu’à saturer ses ressources mémoire
C’est ça le principe d’une attaque SYN Flood.
Dans cet article, pour contrer cette attaque, on va compter le nombre de SYN par IP et s’il y en a trop on bloque temporairement ces segments pour cette IP.
Comment voir les détails des segments#
Si ce n’est pas déjà fait, recréons l’environnement de développement
git clone https://github.com/littlejo/eunomia.dev
cd eunomia.dev/docs/tutorials/42-xdp-loadbalancer/
./setup.sh
Démarrons un petit serveur web au niveau du namespace lb :
ip netns exec lb python3 -m http.server 8080
- Sur l’interface
veth6, nous verrons les paquets TCP qui sont envoyés par le client (comme SYN) ; - Sur l’interface
veth7, nous verrons les paquets TCP qui sont envoyés par le serveur (comme SYN-ACK).
Nous devons donc installer le programme eBPF sur veth6.
Générons le programme Aya :
cargo generate --name antiddos-xdp \
-d program_type=xdp \
-d default_iface=veth6 \
https://github.com/aya-rs/aya-template
cd antiddos-xdp
En prévision, nous allons utiliser des crates supplémentaires :
- aya-ebpf-bindings pour les bindings supplémentaires eBPF
- network-types pour les structures Rust des en-têtes de niveau 1, 2 et 3
- blog-xdp, la crate de ce blog, pour ne pas copier/coller des fonctions helpers précédemment écrites
On va donc modifier le fichier antiddos-xdp-ebpf/Cargo.toml et rajouter dans la section dependencies :
aya-ebpf-bindings = "0.1.1"
network-types = "0.1.0"
blog-xdp = { git = "https://github.com/littlejo/blog-xdp" }
Vérifions que le programme généré fonctionne bien :
On va déjà construire le binaire :
cargo build
Puis on va installer dans le namespace cible où se trouve l’interface veth6 :
ip netns exec lb cargo run
On va maintenant récupérer uniquement les paquets TCP dans le programme eBPF. On va modifier le fichier antiddos-xdp-ebpf/src/main.rs.
On se retrouve avec un code similaire au code de la partie précédente :
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)
}
On peut tester que ça fonctionne :
ip netns exec lb cargo run
Verifions avec cette commande pour tester le programme XDP :
curl 10.0.0.10:8080
Au niveau de cargo, on voit alors plusieurs paquets passés :
[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
Ce que j’expliquais au début, quand on lance une connexion TCP, il n’y a pas qu’un paquet mais plusieurs paquets.
On voit seulement les paquets qui sont envoyés par le client (ceux en ingress).
Si on veut voir les paquets qui sont envoyés par le serveur, il faut installer le programme sur l’interface veth7. C’est tout simple, en surchargeant la variable iface :
cargo run -- --iface veth7
Pour afficher un peu plus de détails sur la nature des paquets, regardons maintenant l’en-tête du segment TCP :
Pour les puristes, le schéma est un peu simplifié.
Voyons brièvement à quoi servent les principales données :
- SRC/DST PORT : les ports de TCP
- SEQUENCE/ACK NUMBER : pour le suivi des connexions
- BITFIELD : c’est là où on définit la nature du segment (SYN, ACK, etc)
- WINDOW : pour le contrôle de flux (utile pour les gros fichiers)
- CHECKSUM : pour l’intégrité du segment
Comme pour UDP, on peut donc facilement récupérer les éléments en regardant la documentation de la crate network-types :
Pour récupérer la nature du segment, il y a même des fonctions pour nous aider :
Pour reproduire un petit tcpdump, ajoutons cela par exemple :
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);
Installons ce programme sur les deux interfaces réseaux :
ip netns exec lb cargo run
cargo run -- --iface veth7
Lançons une connexion :
curl 10.0.0.10:8080
Au niveau de veth6, on voit alors les détails des différents 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
Au niveau de veth7 :
[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,
Maintenant qu’on a vu tous les détails d’un segment TCP, on va maintenant s’occuper uniquement des segments de type SYN :
match unsafe { (*tcphdr).syn() } {
1 => {}
_ => return Ok(xdp_action::XDP_PASS),
}
Si on a affaire à un SYN, on continue le traitement sinon on laisse passer le paquet.
Comme nous n’allons plus toucher à cette partie du code, nous allons créer une fonction :
#[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)
}
Nous récupérons uniquement l’IP source de cette fonction pour potentiellement la bloquer.
Cela va nous permettre de nous concentrer sur le code pour empêcher l’attaque.
Essayons d’atténuer les attaques SYN flood#
Token Bucket#
Il y a différentes options pour atténuer les DDoS. Je vais parler de l’algorithme du seau à jetons (token bucket). C’est pas mal utilisé comme algorithme (par exemple nginx avec limit_req).
Pour comprendre cet algorithme, nous allons faire une petite analogie :
Un village impose des restrictions alimentaires :
- Chaque foyer doit faire la queue pour obtenir des paniers
- Le village ne peut pas stocker plus de 5 paniers à la fois par foyer
- Si le foyer a récupéré tous ses paniers, il doit attendre le lendemain pour en avoir de nouveaux
- Si le foyer n’a pas pris de panier depuis quelques jours, il peut en récupérer plusieurs jusqu’à la limite de 5 paniers.
Cette méthode va ainsi limiter les abus tout en étant flexible en permettant de ne pas faire la queue tous les jours.
En appliquant cette idée à la protection contre les attaques SYN Flood, on obtient :
- Chaque IP doit faire la queue pour envoyer un paquet SYN à un serveur
- Le serveur ne peut pas recevoir plus de 5 paquets SYN à la fois par IP
- Si l’IP a déjà envoyé tous ses paquets SYN, elle sera bloquée temporairement et devra attendre un peu pour pouvoir envoyer de nouveaux
- Si l’IP n’a pas envoyé de paquets SYN depuis quelques temps, il peut en envoyer plusieurs jusqu’à la limite de 5.
Bon c’est bien beau mais comment on fait concrètement en eBPF ?
Comment l’implémenter dans eBPF ?#
Nous allons créer une map eBPF: BUCKET {ip => TOKEN}. Si l’IP n’est pas dans BUCKET, il va être initialisé au nombre maximal (qui est à 5 dans l’analogie). À chaque fois qu’on reçoit un SYN, on diminue de 1. Si le bucket est vide, on arrête le paquet.
Pour ceux qui ne savent pas ce qu’est une map eBPF : en simplifiant, on peut la voir comme une variable globale persistante à un programme eBPF. À chaque fois que le programme XDP est lancé, il peut accéder et modifier cette variable.
Pour en savoir plus, je te conseille de lire la troisième partie de ma série S’initier à eBPF avec Aya.
L’algorithme n’est pas complet car toutes les IPs finiraient par être bloquée, il faut rajouter des jetons. La solution intuitive serait de créer un programme utilisateur qui remplit la map eBPF de 1 régulièrement. Mais c’est un peu risqué si par exemple le programme plante.
Une astuce serait de créer une autre map eBPF : LAST_TS : {ip => ts}. Il contient le moment où on a remplit le BUCKET pour la dernière fois. Ainsi dans le programme eBPF, on peut calculer la durée qui s’est écoulée et rattraper éventuellement le retard en rajoutant les jetons. Supposons qu’on est censé donner un jeton par seconde, si le dernier remplissage date d’il y a 5 secondes, on rajoute 5 jetons dans la limite de la taille du seau.
{ip => {num => token, last_fill => ts }. Mais je trouve cela plus simple en séparant en deux maps.Repassons au code#
Décrémentation de la map Bucket#
On va déjà créer la map qui contient les jetons, on utilise une map de type BPF_MAP_TYPE_LRU_HASH :
Elle permet de nettoyer automatiquement les entrées en cas de débordement, on limite le nombre d’IPs à 16 :
use aya_ebpf::macros::map;
use aya_ebpf::maps::LruHashMap;
#[map]
pub static BUCKET: LruHashMap<[u8; 4], u64> = LruHashMap::with_max_entries(16, 0);
Pour trouver le nombre de jetons du bucket, nous allons regarder si la map contient déjà cet IP :
let token = match unsafe { BUCKET.get(&src_addr)} {
Some(x) => *x,
None => 5, //Token max
};
Le seau ne peut pas contenir plus de 5 jetons. Nous créerons une constante à la fin.
On va soustraire de 1 le nombre de jeton et on va remplir la map :
let token_final = token.saturating_sub(1);
let _ = BUCKET.insert(&source_addr, &token_final, 0);
saturating_sub est une méthode core de Rust. Elle empêche que le nombre de jetons soit négatif. Pour un entier naturel ça serait bizarre.Si le nombre de jeton est nulle, on bloque le paquet :
if token_final == 0 {
info!(&ctx, "drop");
return Ok(xdp_action::XDP_DROP);
}
Sinon on affiche le nombre de token :
info!(&ctx, "received a packet, token {}", token_final);
Pour ne rien oublier, voici le code principal :
use aya_ebpf::{macros::map, maps::LruHashMap};
#[map]
pub static BUCKET: LruHashMap<[u8; 4], u64> = LruHashMap::with_max_entries(16, 0);
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),
};
let token = match unsafe { BUCKET.get(&src_addr)} {
Some(x) => *x,
None => 5, //Token max
};
let token_final = token.saturating_sub(1);
let _ = BUCKET.insert(&src_addr, &token_final, 0);
if token_final == 0 {
info!(&ctx, "drop");
return Ok(xdp_action::XDP_DROP);
}
info!(&ctx, "received a packet, token {}", token_final);
Ok(xdp_action::XDP_PASS)
}
Pour plus de renseignements sur la map, n’hésitez pas à lire la doc :
Vérifions :
ip netns exec lb cargo run
Lançons 6 fois :
curl 10.0.0.10:8080
Sur le terminal de cargo run, on a le résultat escompté :
[INFO antiddos_xdp] received a packet, token 4
[INFO antiddos_xdp] received a packet, token 3
[INFO antiddos_xdp] received a packet, token 2
[INFO antiddos_xdp] received a packet, token 1
[INFO antiddos_xdp] drop
[INFO antiddos_xdp] drop
[INFO antiddos_xdp] drop
À la fin, comme curl n’arrive pas à se connecter au serveur, il doit retransmettre des SYN.
Calcul du rajout des jetons#
Le programme actuel ne peut que vider le seau et donc bloquer au final tous les flux. Passons maintenant au remplissage du seau. Pour cela, nous avons besoin d’une autre map LAST_TS qui va contenir, pour chaque IP, le timestamp du dernier remplissage :
Créons la map :
#[map]
static LAST_TS: LruHashMap<[u8; 4], u64> = LruHashMap::with_max_entries(16, 0);
De manière similaire que pour la map BUCKET, nous allons récupérer le timestamp de la map LAST_TS :
let ts = match unsafe { LAST_TS.get(&source_addr)} {
Some(x) => *x,
None => 0,
};
Pour forcer la création de l’entrée dans la map, j’ai mis un timestamp de zéro si l’entrée n’existe pas.
Calculons le nombre de jeton qu’il faut rajouter :
let t = unsafe {bpf_ktime_get_ns()};
let duration = t - ts;
let token_add = duration / 1_000_000_000 ;
La fonction bpf_ktime_get_ns() permet de calculer le temps depuis le démarrage du système :
Les timestamps sont exprimés en nano seconde. On rajoute ici 1 jeton par seconde écoulée.
S’il faut en rajouter, on met à jour la map :
if token_add > 0 {
let _ = LAST_TS.insert(&source_addr, &t, 0);
}
On calcule le nombre total de jeton dans le seau :
let token_final = min(token.saturating_sub(1) + token_add, 5);
Avec la fonction min, on évite que le bucket peut contenir plus que 5 jetons.
Pour debugger :
info!(&ctx, "token {}, duration {}, token_add {}", token_final, duration, token_add);
En améliorant le code avec des fonctions, cela donne :
use aya_ebpf_bindings::helpers::bpf_ktime_get_ns;
use core::cmp::min;
const TOKEN_BUCKET_SIZE: u64 = 5;
const REFILL_INTERVAL_NS: u64 = 1_000_000_000;
#[inline(always)]
fn refill_tokens(src_addr: &[u8; 4]) -> u64 {
let ts = match unsafe { LAST_TS.get(src_addr)} {
Some(x) => *x,
None => 0,
};
let t = unsafe {bpf_ktime_get_ns()};
let duration = t - ts;
let token_add = duration / REFILL_INTERVAL_NS ;
if token_add > 0 {
let _ = LAST_TS.insert(src_addr, &t, 0);
}
token_add
}
#[inline(always)]
fn consume_token(ctx: &XdpContext, src_addr: &[u8; 4]) -> u64 {
let token = match unsafe { BUCKET.get(src_addr)} {
Some(x) => *x,
None => TOKEN_BUCKET_SIZE,
};
let token_add = refill_tokens(src_addr);
let token_final = min(token.saturating_sub(1) + token_add, TOKEN_BUCKET_SIZE);
let _ = BUCKET.insert(src_addr, &token_final, 0);
info!(ctx, "token {}, token_add {}", token_final, token_add);
token_final
}
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),
};
let token_final = consume_token(&ctx, &src_addr);
if token_final == 0 {
info!(&ctx, "drop");
return Ok(xdp_action::XDP_DROP);
}
Ok(xdp_action::XDP_PASS)
}
Testons le code maintenant :
ip netns exec lb cargo run
En lançant successivement cette commande curl :
curl 10.0.0.10:8080
On obtient ce genre d’affichage :
[INFO antiddos_xdp] token 5, token_add 456446
[INFO antiddos_xdp] token 5, token_add 5
[INFO antiddos_xdp] token 5, token_add 5
[INFO antiddos_xdp] token 4, token_add 0
[INFO antiddos_xdp] token 4, token_add 1
[INFO antiddos_xdp] token 3, token_add 0
[INFO antiddos_xdp] token 3, token_add 1
[INFO antiddos_xdp] token 2, token_add 0
[INFO antiddos_xdp] token 2, token_add 1
[INFO antiddos_xdp] token 1, token_add 0
[INFO antiddos_xdp] token 1, token_add 1
[INFO antiddos_xdp] drop
[INFO antiddos_xdp] token 1, token_add 1
[INFO antiddos_xdp] token 5, token_add 11
La première fois, on a initialisé à 0 donc la durée est le timestamp. On doit ajouter 456446 jetons mais comme le seau ne peut contenir que 5 jetons on a 5 jetons.
Limite du programme#
Si tu testes avec des outils comme hping qui peuvent générer aléatoirement des IPs, le programme ne pourra pas faire grand chose contre cela. Ces outils génèrent des paquets qui n’ont pas de cohérence, il vaut mieux le détecter et arrêter le paquet. Ainsi pour des DDoS massifs, un filtrage avec des règles dynamiques, comme Cloudflare le fait serait beaucoup plus efficace.
Cet épisode est maintenant terminé ! Nous avons vu les bases des connexions TCP et des pistes pour atténuer des DDoS.
Jusqu’à maintenant, nous avons soit observé soit bloqué le paquet mais nous n’avons pas encore vu comment modifier le paquet.
Dans le prochain épisode, nous allons voir cela en créant un petit load balancer XDP.







