Nous avons vu ce qu’était un programme XDP dans la première partie : cela peut être un moyen de filtrer un flux réseau.
Dans cette partie, je vous propose une petite balade dans les différentes en-têtes et de montrer comment on peut opérer le filtrage. Nous allons ainsi voir comment redévelopper xdp-filter 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 le hello world en XDP#
Recréons l’environnement de développement#
Comme nous l’avons vu dans la précédente partie, je vous propose d’installer des namespaces :
git clone https://github.com/littlejo/eunomia.dev
cd eunomia.dev/docs/tutorials/42-xdp-loadbalancer/
./setup.sh
Rappelons ce qu’on a créé :
Créons le programme “hello world”#
Comme nous l’avons fait avec xdp-filter, nous allons installer le programme XDP sur veth0.
Générons déjà le programme Aya :
cargo generate --name browser-xdp \
-d program_type=xdp \
-d default_iface=veth0 \
https://github.com/aya-rs/aya-template
Buildons et installons le programme “hello world” :
cd browser-xdp/
cargo run
Sur un autre terminal, testons le programme :
ip netns exec lb ping -c 2 10.0.0.1
Du côté du terminal cargo run, on voit bien deux paquets :
[INFO browser_xdp] received a packet
[INFO browser_xdp] received a packet
Ainsi à chaque fois que l’interface reçoit un paquet, le programme XDP est lancé.
Maintenant nous allons voir comment arrêter un paquet et quel type de paquet on a affaire.
Les bases pour créer un programme XDP#
Les actions XDP#
Regardons d’abord le code généré par cargo.
Le fichier le plus important est celui-là : browser-xdp-ebpf/src/main.rs c’est à dire le code côté noyau.
Globalement le code ressemble aux codes générés pour d’autres types de programmes eBPF comme les Tracepoints :
#[xdp]
pub fn browser_xdp(ctx: XdpContext) -> u32 {
match try_browser_xdp(ctx) {
Ok(ret) => ret,
Err(_) => xdp_action::XDP_ABORTED,
}
}
fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
Cependant on peut remarquer une petite spécificité : xdp_action::*.
Cela permet de déterminer ce que fait le paquet une fois qu’il a fini de parcourir le programme eBPF.

On a 5 actions différentes :
XDP_ABORTED: Arrête le paquet avec erreurXDP_DROP: Arrête le paquet silencieusementXDP_PASS: Laisse passer le paquetXDP_REDIRECT: Redirige le paquet vers une autre interface ou une socketAF_XDPattachée à une interfaceXDP_TX: Redirige le paquet vers la même interface
Dans cet article, nous utiliserons uniquement XDP_PASS et XDP_DROP.
Le contexte XDP et ses méthodes#
Comme pour tous les autres programmes eBPF, pour aller au-delà du hello world, il faut comprendre comment jouer avec le contexte.
Regardons la documentation :
Contrairement aux précédents types de programmes où on utilisait qu’une seule méthode, il y en a 4 différentes pour XDP :
data()retourne l’adresse mémoire du début du paquet réseau.data_end()retourne l’adresse mémoire de fin du paquet réseau.metadata()retourne l’adresse mémoire du début de la métadonnées de XDP lié au kernel linux ou au driver.metadata_end()retourne l’adresse mémoire de fin de la métadonnées. De la même manière quedata_end(),
Dans cet article, nous utiliserons uniquement les méthodes data() et data_end() pour récupérer les différentes en-têtes.
Comment récupérer les en-têtes ?#
Rappelons le schéma suivant :
data()se trouve au début de l’en-tête ethHdrdata_end()se trouve à la fin du payload
Comment récupérer l’en-tête ethernet (ethHdr) ? Elle se situe au tout début du paquet.
let ethhdr = ctx.data();
Pour récupérer l’en-tête suivante, il suffit de rajouter la longueur de l’en-tête ethernet, en pseudo-code Rust ça donnerait :
let ipv4hdr = ctx.data() + sizeof(ethhdr);
Etc.
Concentrons-nous sur l’en-tête ethernet. Comment récupérer ses éléments ? ctx.data() est de type usize. Un entier naturel qui représente une adresse mémoire. Il faut donc récupérer son contenu. Comment faire cela ?
De la même manière que *const u8 représente un pointeur vers une chaine de caractères, ici on doit faire la même chose pour l’en-tête ethernet.
Ainsi il faut convertir dans une structure qui représente l’en-tête de la trame Ethernet. On a plusieurs possibilités pour le faire comme aller voir le code C et le convertir en Rust ce qui est un bon exercice mais un peu fastidieux. Heureusement, comme Rust est désormais bien présent dans l’écosystème d’eBPF, il y a déjà une crate Rust qui nous a fait le travail : network-types.
Pour récupérer cette crate, on va modifier le fichier browser-xdp-ebpf/Cargo.toml et rajouter dans la section dependencies :
network-types = "0.1.0"
On va ainsi pouvoir l’inclure dans le code principal (browser-xdp-ebpf/src/main.rs) :
use network_types::eth::EthHdr;
[...]
let ethhdr = ctx.data() as *const EthHdr;
Maintenant qu’on a récupéré la structure de l’en-tête ethernet, nous pouvons récupérer les différents éléments qui composent l’en-tête :
- DST MAC : l’adresse MAC destination
- SRC MAC : l’adresse MAC source
- ETHERTYPE : détermine le protocole du niveau supérieur (Comme IPv4, IPv6, Arp pour citer les plus connus).
Comment le faire avec Aya ? Regardons la documentation :
Ainsi pour récupérer l’adresse de destination, il suffit de coder :
let dst_addr = unsafe { (*ethhdr).dst_addr};
info!(&ctx, "dst_addr {:mac} ", dst_addr);
Nous devons utiliser unsafe car nous déréférençons un pointeur brut.
info! nous permet de convertir des tableaux de 6 octets en une adresse MAC. Si vous préférez la notation en majuscule, il suffit d’écrire :MAC.Essayons maintenant. Voici le code principal modifié :
use network_types::eth::EthHdr;
fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
let ethhdr = ctx.data() as *const EthHdr;
let dst_addr = unsafe { (*ethhdr).dst_addr};
info!(&ctx, "dst_addr {:mac} ", dst_addr);
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
Buildons le programme :
cargo build
La compilation fonctionne.
Installons le dans le noyau :
cargo run
On a alors l’erreur suivante :
Error: the BPF_PROG_LOAD syscall failed. Verifier output: 0: R1=ctx() R10=fp0
; unsafe { (*self.ctx).data as usize } @ xdp.rs:16
0: (61) r1 = *(u32 *)(r1 +0) ; R1_w=pkt(r=0)
; let dst_addr = unsafe { (*ethhdr).dst_addr}; @ main.rs:20
1: (71) r0 = *(u8 *)(r1 +1)
invalid access to packet, off=1 size=1, R1(id=0,off=1,r=0)
R1 offset is outside of the packet
verification time 65 usec
stack depth 0
processed 2 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0
Caused by:
Permission denied (os error 13)
Le verifier n’aime pas vraiment cette partie du code :
let ethhdr = ctx.data() as *const EthHdr;
L’erreur peut se traduire en français par :
Accès invalide au paquet.
L'offset du registre 1 est en dehors du paquet
Pas super clair. Essayons d’expliquer mieux. Quand on tente let ethhdr = ctx.data() as *const EthHdr;, Il y a un accès à la mémoire. Le verifier exige qu’on prouve que cet accès soit toujours dans le paquet (entre data() et data_end()).
Il faut donc faire une vérification pour le rassurer avant cet accès :
if ctx.data() + EthHdr::LEN > ctx.data_end() {
return Ok(xdp_action::XDP_PASS);
}
On peut traduire par : si l’accès ne se trouve pas au niveau du paquet, le programme XDP ne traite pas le paquet : il le laisse continuer son chemin dans la pile réseau de noyau Linux.
Sinon on peut alors convertir en EthHdr :
let ethhdr = ctx.data() as *const EthHdr;
Voici le code complet de la fonction principale :
fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
let start = ctx.data() ;
let end = ctx.data_end() ;
if start + EthHdr::LEN > end { //Check for the verifier
return Ok(xdp_action::XDP_PASS);
}
let ethhdr = start as *const EthHdr;
let dst_addr = unsafe { (*ethhdr).dst_addr};
info!(&ctx, "dst_addr {:mac} ", dst_addr);
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
Vérifions que ça marche :
cargo run
Lançons des ping. On récupère bien l’adresse mac :
[INFO browser_xdp] dst_addr de:ad:be:ef:00:01
[INFO browser_xdp] received a packet
[INFO browser_xdp] dst_addr de:ad:be:ef:00:01
[INFO browser_xdp] received a packet
Maintenant qu’on a réussi à récupérer l’en-tête ethHdr, comment récupérer les autres en-têtes ? Il suffit de rajouter la longueur des en-têtes qui les précèdent.
De manière assez évidente, nous allons utiliser des méthodes très proches pour toutes les en-têtes. Il serait judicieux de créer une fonction. La voilà :
| |
Quelques remarques sur la création de cette fonction :
- (L3) On inline la fonction pour des questions de performance
- (L4)
<T>: permet de créer une fonction générique non dépendante de la structure - l’offset permet de naviguer d’en-tête en en-tête (offset = 0 pour ethHdr)
- (L7) On a dû changer la méthode pour calculer la longueur de la structure avec un
size_ofpour que ça soit possible pour n’importe quelle structure
Le code principal devient alors :
fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
let dst_addr = unsafe { (*ethhdr).dst_addr};
info!(&ctx, "dst_addr {:mac} ", dst_addr);
info!(&ctx, "received a packet");
Ok(xdp_action::XDP_PASS)
}
Maintenant qu’on a créé la fonction permettant de se balader dans les différentes en-têtes, regardons comment on fait pour bloquer ou laisser passer les différents paquets en fonction des en-têtes réseaux.
Visite guidée des différentes en-têtes#
Niveau 1 : Accès réseau#
Ce qu’on peut filtrer#
On l’a déjà vu pour le premier niveau : on peut notamment récupérer l’adresse MAC source, l’adresse MAC destination et l’EtherType. On peut ainsi filtrer en fonction de l’adresse MAC ou si on veut uniquement de l’IPv4 par exemple.
On va donc utiliser la fonction créée précédemment ptr_at() :
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
Pour récupérer les adresses MAC et pour les afficher :
let dst_addr = unsafe { (*ethhdr).dst_addr };
let src_addr = unsafe { (*ethhdr).src_addr };
info!(&ctx, "src: {:mac} => dst: {:mac}", src_addr, dst_addr);
Testons :
cargo run
Et pingons. regardons le résultat :
[INFO browser_xdp] src: de:ad:be:ef:00:10 => dst: de:ad:be:ef:00:01
[INFO browser_xdp] src: de:ad:be:ef:00:10 => dst: de:ad:be:ef:00:01
Pour coder un peu plus propre, on peut créer une fonction :
#[inline(always)]
fn display_ethhdr(ctx: &XdpContext, ethhdr: *const EthHdr) {
let dst_addr = unsafe { (*ethhdr).dst_addr };
let src_addr = unsafe { (*ethhdr).src_addr };
let ethertype = unsafe { (*ethhdr).ether_type };
info!(ctx, "EthHdr: src: {:mac} => dst: {:mac} ({})", src_addr, dst_addr, ethertype);
}
Filtrons#
Ainsi pour filtrer une adresse MAC, on peut faire :
let mac: [u8; 6] = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x10]; //de:ad:be:ef:00:10
if mac == src_addr {
info!(&ctx, "drop");
return Ok(xdp_action::XDP_DROP);
}
Testons :
cargo run
Pingons depuis lb : ça ne ping plus. Regardons le résultat au niveau d’Aya :
[INFO browser_xdp] drop
[INFO browser_xdp] drop
Le programme repère bien le paquet et l’arrête.
Pingons depuis le namespace h2 :
ip netns exec h2 ping -c 2 10.0.0.1
Le ping laisse bien passer :
[INFO browser_xdp] received ethhdr packet src: de:ad:be:ef:00:02 => dst: de:ad:be:ef:00:01
[INFO browser_xdp] received ethhdr packet src: de:ad:be:ef:00:02 => dst: de:ad:be:ef:00:01
Pour ressembler plus à xdp-filter, on peut créer cette fonction qui permet de distinguer si on veut empêcher la source ou la destination:
enum Mode {
Src,
Dst,
}
#[inline(always)]
fn drop_mac(ethhdr: *const EthHdr, mac: [u8; 6], mode: Mode) -> bool {
let addr = match mode {
Mode::Src => unsafe {(*ethhdr).src_addr},
Mode::Dst => unsafe {(*ethhdr).dst_addr},
};
mac == addr
}
Au niveau du code principal cela donne alors :
fn try_browser_xdp(ctx: XdpContext) -> Result<u32, u32> {
let ethhdr: *const EthHdr = ptr_at(&ctx, 0)?;
let mac: [u8; 6] = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x10]; //de:ad:be:ef:00:10
display_ethhdr(&ctx, ethhdr);
if drop_mac(ethhdr, mac, Mode::Src) {
return Ok(xdp_action::XDP_DROP);
}
Ok(xdp_action::XDP_DROP);
}
Level up#
L’Ethertype determine le protocole du paquet dans le niveau supérieur.
Ainsi pour le récupérer, on peut l’avoir de la manière suivante :
let ethertype = unsafe {(*ethhdr).ether_type} ;
Mais il y a plus propre avec la méthode ether_type() qui vérifie si le numéro du protocole existe vraiment :

Au lieu de connaître par cœur les numéros de protocole, la crate network-types nous simplifie avec le type EtherType :
Ainsi on filtre de cette manière :
match unsafe { (*ethhdr).ether_type() } {
Ok(EtherType::Ipv4) => {}
_ => return Ok(xdp_action::XDP_PASS),
}
Les flux autres que l’IPv4 (par exemple : IPv6, Arp) passent directement et ne sont plus analysés.
Niveau 2 : Internet#
Ce qu’on peut filtrer#
On va donc supposer qu’on veut analyser uniquement les flux IPv4.
De la même manière qu’Ethernet, regardons ce qu’on peut récupérer comme données avec l’en-tête IPv4 :
Pour la clareté, l’en-tête est représentée sur plusieurs lignes. Il y a nettement plus de choses à récupérer. ceux qu’on va utiliser pour cet article :
PROTOCOL: le protocole du niveau supérieur (TCP ou UDP par exemple)SOURCE ADDRESS: l’adresse IP sourceDESTINATION ADDRESS: l’adresse IP destination
Au niveau Rust, on peut récupérer ces en-têtes :

Pour récupérer les en-têtes de l’IPv4, il suffit de décaler le pointeur à la longueur de la structure de l’en-tête Ethernet avec la fonction qu’on a créé :
let ipv4hdr: *const Ipv4Hdr = ptr_at(&ctx, EthHdr::LEN)? ;
De la même manière que pour la couche inférieure on peut créer une fonction :
#[inline(always)]
fn display_iphdr(ctx: &XdpContext, iphdr: *const Ipv4Hdr) {
let dst_addr = unsafe { (*iphdr).dst_addr };
let src_addr = unsafe { (*iphdr).src_addr };
let proto = unsafe { (*iphdr).proto };
info!(ctx, "Ipv4Hdr: src: {:i} => dst: {:i} ({})", src_addr, dst_addr, proto as u8);
}
info! nous simplifie la vie en convertissant un tableau de 4 octets en la notation d’IPv4 avec :i.Testons le code suivant :
use network_types::{eth::{EthHdr,EtherType},
ip::Ipv4Hdr,
};
fn try_browser_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)? ;
let mac: [u8; 6] = [0xde, 0xad, 0xbe, 0xef, 0x00, 0x10]; //de:ad:be:ef:00:10
//display_ethhdr(&ctx, ethhdr);
display_iphdr(&ctx, ipv4hdr);
Ok(xdp_action::XDP_PASS)
}
Si on ping :
ip netns exec lb ping -c 2 10.0.0.1
Du côté de cargo run, cela donne :
[INFO browser_xdp] Ipv4Hdr: src: 10.0.0.10 => dst: 10.0.0.1 (1)
[INFO browser_xdp] Ipv4Hdr: src: 10.0.0.10 => dst: 10.0.0.1 (1)
De la même manière que l’adresse Mac, pour empêcher l’ip du lb, on peut utiliser cette fonction:
#[inline(always)]
fn drop_ip(iphdr: *const Ipv4Hdr, ip: [u8; 4], mode: Mode) -> bool {
let addr = match mode {
Mode::Src => unsafe {(*iphdr).src_addr},
Mode::Dst => unsafe {(*iphdr).dst_addr},
};
ip == addr
}
Le code principal donne alors :
fn try_browser_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)? ;
display_iphdr(&ctx, ipv4hdr);
if drop_ip(ipv4hdr, [10, 0, 0, 10], Mode::Src) {
return Ok(xdp_action::XDP_DROP);
}
Ok(xdp_action::XDP_PASS)
}
Le ping ne fonctionne plus :
ip netns exec lb ping -c 2 10.0.0.1
[INFO browser_xdp] Ipv4Hdr: src: 10.0.0.10 => dst: 10.0.0.1 (1)
[INFO browser_xdp] Ipv4Hdr: src: 10.0.0.10 => dst: 10.0.0.1 (1)
Le programme repère bien le paquet et l’arrête.
Level up#
Pour aller au niveau supérieur, il faut savoir quel protocole on veut regarder. Pour cela on utilise l’option proto. Il est de type IPProto qui est une énumération :
Nous allons regarder UDP pour la fin de l’article. Le principe est le même que pour l’Ethertype :
match unsafe { (*ipv4hdr).proto } {
IpProto::Udp => {}
_ => return Ok(xdp_action::XDP_PASS),
}
On peut facilement filtrer si on veut bloquer certains protocoles.
Niveau 3 : Transport#
Ce qu’on peut filtrer#
Pour UDP, on peut ainsi récupérer l’en-tête de cette manière :
let udphdr: *const UdpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? ;
Regardons maintenant l’en-tête du segment UDP :
La partie la plus intéressante à filtrer sont les ports.
Au niveau Rust :
Les ports ne sont pas des entiers mais des tableaux de 2 octets. Cela n’est pas pratique. Heureusement il y a les méthodes suivantes :


On peut filtrer au niveau du port UDP :
#[inline(always)]
fn drop_udp_port(udphdr: *const UdpHdr, port: u16, mode: Mode) -> bool {
let packet_port = match mode {
Mode::Src => unsafe {(*udphdr).src_port()},
Mode::Dst => unsafe {(*udphdr).dst_port()},
};
port == packet_port
}
Testons le code suivant :
use network_types::{eth::{EthHdr,EtherType},
ip::{Ipv4Hdr,IpProto},
udp::UdpHdr,
};
fn try_browser_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::Udp => {}
_ => return Ok(xdp_action::XDP_PASS),
}
let udphdr: *const UdpHdr = ptr_at(&ctx, EthHdr::LEN + Ipv4Hdr::LEN)? ;
display_udphdr(&ctx, udphdr);
if drop_udp_port(udphdr, 8888, Mode::Dst){
return Ok(xdp_action::XDP_DROP);
}
Ok(xdp_action::XDP_PASS)
}
Installons le programme XDP :
cargo run
Créons un serveur UDP avec netcat :
nc -u 0.0.0.0 -l 8000
Créons un client udp maintenant :
ip netns exec lb netcat -u 10.0.0.1 8000
coucou
Cette commande ouvre un client UDP et envoie le message coucou.
Du côté sur le serveur UDP, on voit bien :
coucou
Mais surtout, on voit bien du côté cargo run :
[INFO browser_xdp] UdpHdr: src: 44024 => dst: 8000
Testons maintenant avec un serveur UDP avec le port 8888 :
nc -u 0.0.0.0 -l 8888
Créons un client udp maintenant :
ip netns exec lb netcat -u 10.0.0.1 8888
coucou
Il ne s’affiche rien au niveau du serveur c’est normal le programme XDP l’a bloqué :
[INFO browser_xdp] UdpHdr: src: 39540 => dst: 8888
drop_mac(), drop_ip() et drop_udp_port() sont très proches il est tout à fait possible en Rust de les rendre générique pour qu’il y en ait qu’une. De même pour display_ethdr(), display_iphdr() et display_udphdr(). Vous pouvez voir le code plus générique au niveau de la crate dédiée au blog ici et là.Niveau 4 : Application#
La crate network-types n’aide pas pour récupérer les en-têtes des protocoles applicatifs. En connaissant le port du niveau précédent, on peut facilement filtrer l’application. Si tu filtres le port 53 tu vas filtrer le DNS par exemple. Mais si tu veux filtrer une requête spécifique, c’est une autre affaire.

C’est possible d’aller plus haut. Mais les en-têtes des applications peuvent être :
- trop importantes pour la stack (limité à 512 octets), il faudrait obligatoirement passé par des maps eBPF ;
- chiffrées comme pour HTTPS ou SSH ce qui rend l’analyse impossible avec XDP.
Cet épisode est maintenant terminé ! Nous avons vu les bases pour créer un firewall comme xdp-filter.
Cependant, nous n’avons pas vu comment faire pour qu’il soit un peu plus intelligent en évitant par exemple les DDoS.
Ça tombe bien ! Dans le prochain épisode, nous allons créer un programme XDP qui va atténuer les dénis de service.








