Nous écrivons une protection contre les attaques DDoS sur XDP. Partie noyau

La technologie eXpress Data Path (XDP) permet de traiter le trafic sur les interfaces Linux avant que les paquets n'atteignent la pile réseau du noyau. L'utilisation de XDP inclut la protection contre les attaques DDoS (CloudFlare), des filtres complexes et la collecte de statistiques (Netflix). Les programmes XDP s'exécutent sur une machine virtuelle eBPF, ce qui implique des limitations tant sur leur code que sur les fonctions du noyau disponibles, selon le type de filtre.

Cet article vise à combler les lacunes de nombreux matériaux concernant XDP. Tout d'abord, ils fournissent du code prêt à l'emploi qui contourne immédiatement les particularités de XDP : il est préparé pour la vérification ou trop simple pour poser des problèmes. En essayant d'écrire son propre code à partir de zéro, il n'y a pas de compréhension des erreurs caractéristiques. Deuxièmement, les méthodes pour tester XDP localement sans VM et "matériel" ne sont pas abordées, bien qu'elles aient leurs propres "pièges". Le texte s'adresse aux programmeurs familiers avec les réseaux et Linux, intéressés par XDP et eBPF.

Dans cette section, nous allons examiner en détail comment construire un filtre XDP et comment le tester, puis nous allons écrire une simple version du mécanisme bien connu des SYN cookies au niveau du traitement des paquets. Pour l'instant, nous ne allons pas former de "liste blanche".
de clients vérifiés, tenir des compteurs et gérer le filtre - les journaux suffisent.

Nous écrirons en C - ce n'est pas à la mode, mais c'est pratique. Tout le code est disponible sur GitHub via le lien à la fin et est divisé en commits en fonction des étapes décrites dans l'article.

Avertissement. Au cours de l'article, une mini-solution sera développée pour contrer les attaques DDoS, car c'est une tâche réaliste pour XDP et dans mon domaine. Cependant, l'objectif principal est de comprendre la technologie, ce n'est pas un guide pour créer une protection prête à l'emploi. Le code d'apprentissage n'est pas optimisé et omet certains nuances.

Aperçu rapide de XDP

Je vais exposer seulement les points clés pour éviter de dupliquer la documentation et les articles existants.

Ainsi, le code du filtre est chargé dans le noyau. Les paquets entrants sont transmis au filtre. En fin de compte, le filtre doit prendre une décision : passer le paquet au noyau (XDP_PASS), le rejeter (XDP_DROP) ou le renvoyer (XDP_TX). Le filtre peut modifier le paquet, ce qui est particulièrement pertinent pour XDP_TX. On peut aussi interrompre brutalement le programme (XDP_ABORTED) et rejeter le paquet, mais c'est l'équivalent de assert(0) — pour le débogage.

La machine virtuelle eBPF (extended Berkley Packet Filter) est conçue pour être simple, permettant au noyau de vérifier que le code ne s'en boucle pas et n'endommage pas la mémoire d'autrui. Les limitations et vérifications globales sont :

  • Les boucles (retours en arrière) sont interdites.
  • Il y a une pile pour les données, mais pas de fonctions (toutes les fonctions C doivent être intégrées).
  • Les accès à la mémoire en dehors de la pile et du tampon de paquets sont interdits.
  • La taille du code est limitée, mais en pratique cela n'est pas très significatif.
  • Seuls les appels aux fonctions spéciales du noyau (eBPF helpers) sont autorisés.

Le développement et l'installation du filtre se déroulent comme suit :

  1. Le code source (par exemple, kernel.c) est compilé en code objet (kernel.o) pour l'architecture de la machine virtuelle eBPF. En octobre 2019, la compilation en eBPF est supportée par Clang et promise dans GCC 10.1.
  2. Si ce code objet contient des références aux structures du noyau (par exemple, aux tables et compteurs), des zéros sont spécifiés à la place de leurs ID, ce qui signifie qu'il est impossible d'exécuter un tel code. Avant de le charger dans le noyau, il est nécessaire de remplacer ces zéros par les ID d'objets spécifiques, créés par les appels au noyau (lier le code). Cela peut être fait avec des utilitaires externes ou en écrivant un programme qui liera et chargera un filtre spécifique.
  3. Le noyau vérifie le programme à charger. Il s'assure de l'absence de boucles et de débordements dans le paquet et la pile. Si le vérificateur ne peut pas démontrer que le code est correct, le programme est rejeté – il faut savoir le convaincre.
  4. Après une vérification réussie, le noyau compile le code objet de l'architecture eBPF en code machine de l'architecture système (just-in-time).
  5. Le programme est attaché à l'interface et commence à traiter les paquets.

Comme XDP fonctionne dans le noyau, le débogage est effectué à partir des journaux de traçage et, en réalité, à partir des paquets que le programme filtre ou génère. Néanmoins, eBPF garantit la sécurité du code chargé pour le système, donc il est possible d'expérimenter avec XDP directement sur Linux local.

Préparation de l'environnement

Assemblage

Clang ne peut pas directement émettre du code objet pour l'architecture eBPF, donc le processus se compose de deux étapes :

  1. Compiler le code en C en bytecode LLVM (clang -emit-llvm).
  2. Transformer le bytecode en code objet eBPF (llc -march=bpf -filetype=obj).

Lors de l'écriture d'un filtre, quelques fichiers avec des fonctions d'aide et des macros issues des tests du noyau. Il est important qu'ils soient compatibles avec la version du noyau (KVER). On les télécharge dans helpers/:

export KVER=v5.3.7
export BASE=https://git.kernel.org/pub/scm/linux/kernel/git/stable/linux.git/plain/tools/testing/selftests/bpf
wget -P helpers --content-disposition "${BASE}/bpf_helpers.h?h=${KVER}" "${BASE}/bpf_endian.h?h=${KVER}"
unset KVER BASE

Makefile pour Arch Linux (noyau 5.3.7) :

CLANG ?= clang
LLC ?= llc

KDIR ?= /lib/modules/$(shell uname -r)/build
ARCH ?= $(subst x86_64,x86,$(shell uname -m))

CFLAGS = 
    -Ihelpers 
    
    -I$(KDIR)/include 
    -I$(KDIR)/include/uapi 
    -I$(KDIR)/include/generated/uapi 
    -I$(KDIR)/arch/$(ARCH)/include 
    -I$(KDIR)/arch/$(ARCH)/include/generated 
    -I$(KDIR)/arch/$(ARCH)/include/uapi 
    -I$(KDIR)/arch/$(ARCH)/include/generated/uapi 
    -D__KERNEL__ 
    
    -fno-stack-protector -O2 -g

xdp_%.o: xdp_%.c Makefile
    $(CLANG) -c -emit-llvm $(CFLAGS) $< -o - | 
    $(LLC) -march=bpf -filetype=obj -o $@

.PHONY: all clean

all: xdp_filter.o

clean:
    rm -f ./*.o

KDIR contient le chemin vers les en-têtes du noyau, ARCH — l'architecture du système. Les chemins et les outils peuvent légèrement varier entre les distributions.

Exemple de différences pour Debian 10 (noyau 4.19.67)

# другая команда
CLANG ?= clang
LLC ?= llc-7

# другой каталог
KDIR ?= /usr/src/linux-headers-$(shell uname -r)
ARCH ?= $(subst x86_64,x86,$(shell uname -m))

# два дополнительных каталога -I
CFLAGS = 
    -Ihelpers 
    
    -I/usr/src/linux-headers-4.19.0-6-common/include 
    -I/usr/src/linux-headers-4.19.0-6-common/arch/$(ARCH)/include 
    # далее без изменений

CFLAGS incluent le répertoire contenant les en-têtes auxiliaires et plusieurs répertoires avec les en-têtes du noyau. Le symbole __KERNEL__ indique que les en-têtes UAPI (userspace API) sont définis pour le code du noyau, car le filtre s'exécute dans le noyau.

La protection de la pile peut être désactivée (-fno-stack-protector), car le vérificateur de code eBPF vérifie de toute façon les débordements de pile. Il est préférable d'activer immédiatement les optimisations, car la taille du bytecode eBPF est limitée.

Commençons par un filtre qui laisse passer tous les paquets et ne fait rien :

#include <uapi/linux/bpf.h>

#include <bpf_helpers.h>

SEC("prog")
int xdp_main(struct xdp_md* ctx) {
    return XDP_PASS;
}

char _license[] SEC("license") = "GPL";

Commande make compile xdp_filter.o. Où le tester maintenant ?

Stand de test

Le banc d'essai doit inclure deux interfaces : celle qui aura le filtre et celle depuis laquelle les paquets seront envoyés. Ce doivent être des dispositifs Linux à part entière avec leurs propres IP, pour tester comment les applications normales interagissent avec notre filtre.

Les dispositifs de type veth (Ethernet virtuel) conviennent : ce sont une paire d'interfaces réseau virtuelles, "connectées" directement l'une à l'autre. On peut les créer comme ça (dans cette section, toutes les commandes ip sont exécutées depuis root):

ip link add xdp-remote type veth peer name xdp-local

Ici xdp-remote et xdp-local — ce sont les noms des dispositifs. Sur xdp-local (192.0.2.1/24) sera attaché le filtre, et xdp-remote (192.0.2.2/24) recevra le trafic entrant. Cependant, il y a un problème : les interfaces sont sur la même machine, et Linux n'enverra pas le trafic d'une vers l'autre. Cela peut être résolu avec des règles astucieuses iptables, mais cela nécessitera de modifier les paquets, ce qui est peu pratique lors du débogage. Il est préférable d'utiliser des espaces de noms réseau (network namespaces, ci-après netns).

L'espace de noms réseau contient un ensemble d'interfaces, de tables de routage et de règles NetFilter, isolés des objets similaires dans d'autres netns. Chaque processus fonctionne dans un espace de noms particulier et n'a accès qu'aux objets de ce netns. Par défaut, le système dispose d'un unique espace de noms réseau pour tous les objets, ce qui permet de travailler sous Linux sans connaître les netns.

Créons un nouvel espace de noms xdp-test et déplaçons-le là xdp-remote.

ip netns add xdp-test
ip link set dev xdp-remote netns xdp-test

Alors le processus s'exécutant dans xdp-test, ne pourra pas « voir » xdp-local (il restera dans le netns par défaut) et lors de l'envoi d'un paquet à 192.0.2.1, il le transmettra à travers xdp-remote, car c'est la seule interface dans 192.0.2.0/24 disponible à ce processus. Cela fonctionne aussi dans le sens inverse.

Lors du déplacement entre les netns, l'interface est supprimée et perd son adresse. Pour configurer l'interface dans le netns, il faut exécuter ip ... dans cet espace de noms via la commande ip netns exec:

ip netns exec xdp-test 
    ip address add 192.0.2.2/24 dev xdp-remote
ip netns exec xdp-test 
    ip link set xdp-remote up

Comme on peut le voir, cela ne diffère pas de la configuration xdp-local dans l'espace de noms par défaut :

    ip address add 192.0.2.1/24 dev xdp-local
    ip link set xdp-local up

Si l'on exécute tcpdump -tnevi xdp-local, on peut voir que les paquets envoyés depuis xdp-test, arrivent sur cette interface :

ip netns exec xdp-test   ping 192.0.2.1

Pratique de lancer un shell dans xdp-test. Dans le dépôt, il y a un script qui automatise le travail avec le stand, par exemple, on peut configurer le stand avec la commande sudo ./stand up et le supprimer sudo ./stand down.

Traceroute

Le filtre est lié à l'appareil comme suit :

ip -force link set dev xdp-local xdp object xdp_filter.o verbose

Clé -force est nécessaire pour lier un nouveau programme si un autre est déjà lié. « No news is good news » ne s'applique pas à cette commande, la sortie est toujours volumineuse. Indiquer verbose n'est pas obligatoire, mais permet d'obtenir un rapport sur le travail du vérificateur de code avec une liste en langage assembleur :

Analyse du vérificateur :

0 : (b7) r0 = 2
1 : (95) exit

Délier le programme de l'interface :

ip link set dev xdp-local xdp off

Dans le script, ce sont les commandes sudo ./stand attach et sudo ./stand detach.

Après avoir lié le filtre, on peut s'assurer que ping continue de fonctionner, mais le programme fonctionne-t-il ? Ajoutons des logs. La fonction bpf_trace_printk() est similaire à printf(), mais supporte seulement jusqu'à trois arguments, en plus du modèle, et une liste limitée de spécificateurs. Le macro bpf_printk() simplifie l'appel.

   SEC("prog")
   int xdp_main(struct xdp_md* ctx) {
+      bpf_printk("got packet: %pn", ctx);
       return XDP_PASS;
   }

La sortie est envoyée au canal de traçage du noyau, qui doit être activé :

echo -n 1 | sudo tee /sys/kernel/debug/tracing/options/trace_printk

Affichage du flux de messages :

cat /sys/kernel/debug/tracing/trace_pipe

Ces deux commandes effectuent un appel sudo ./stand log.

Le ping devrait maintenant générer de tels messages :

-110930 [004] ..s1 78803.244967: 0: got packet: 00000000ac510377

Si l'on examine la sortie du vérificateur, on peut noter des calculs étranges :

0: (bf) r3 = r1
1: (18) r1 = 0xa7025203a7465
3: (7b) *(u64 *)(r10 -8) = r1
4: (18) r1 = 0x6b63617020746f67
6: (7b) *(u64 *)(r10 -16) = r1
7: (bf) r1 = r10
8: (07) r1 += -16
9: (b7) r2 = 16
10: (85) call bpf_trace_printk#6

Le fait est que les programmes eBPF n'ont pas de section de données, donc la seule façon de coder une chaîne formatée est par les arguments immédiats des commandes de la VM :

$ python -c "import binascii; print(bytes(reversed(binascii.unhexlify('0a7025203a74656b63617020746f67'))))"
b'got packet: %pn'

Pour cette raison, la sortie de débogage gonfle considérablement le code final.

Envoi de paquets XDP

Modifions le filtre : faisons en sorte qu'il renvoie tous les paquets entrants. Cela n'est pas correct du point de vue réseau, car il faudrait changer les adresses dans les en-têtes, mais pour l'instant, le fonctionnement est l'essentiel.

       bpf_printk("got packet: %pn", ctx);
-      return XDP_PASS;
+      return XDP_TX;
   }

Lançons tcpdump sur xdp-remote. Il devrait montrer des requêtes ICMP Echo sortantes et entrantes identiques et cesser d'afficher les réponses ICMP Echo. Mais cela ne fonctionne pas. En fait, pour que XDP_TX dans le programme sur xdp-local il est nécessaire, pour que l'interface jumelée xdp-remote ait également un programme assigné, même vide, et qu'elle soit activée.

Comment l'ai-je su ?

Suivre le chemin d'un paquet dans le noyau permet au mécanisme des événements perf, qui utilise d'ailleurs la même machine virtuelle, c'est-à-dire que l'eBPF est utilisé pour le débogage.

Vous devez faire le bien à partir du mal, car il n'y a plus rien d'autre à faire.

$ sudo perf trace --call-graph dwarf -e 'xdp:*'
   0.000 ping/123455 xdp:xdp_bulk_tx:ifindex=19 action=TX sent=0 drops=1 err=-6
                                     veth_xdp_flush_bq ([veth])
                                     veth_xdp_flush_bq ([veth])
                                     veth_poll ([veth])

Quel est le code 6 ?

$ errno 6
ENXIO 6 Pas de tel appareil ou d'adresse

Fonction veth_xdp_flush_bq() reçoit un code d'erreur de veth_xdp_xmit(), où en cherchant ENXIO et on trouve un commentaire.

Restaurons le filtre minimal (XDP_PASS) dans le fichier xdp_dummy.c, ajoutons-le au Makefile, attachons-le à xdp-remote:

ip netns exec remote 
    ip link set dev int xdp object dummy.o

Maintenant tcpdump montre ce qui est attendu :

62:57:8e:70:44:64 > 26:0e:25:37:8f:96, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 13762, offset 0, flags [DF], proto ICMP (1), length 84)
    192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, length 64
62:57:8e:70:44:64 > 26:0e:25:37:8f:96, ethertype IPv4 (0x0800), length 98: (tos 0x0, ttl 64, id 13762, offset 0, flags [DF], proto ICMP (1), length 84)
    192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, length 64

Si au lieu de cela, seuls les ARP sont affichés, il faut retirer les filtres (cela fait sudo ./stand detach), lancer ping, puis installer les filtres et réessayer. Le problème est que le filtre XDP_TX s'applique aussi aux ARP, et si la pile
d'espaces de noms xdp-test a eu le temps « d'oublier » l'adresse MAC 192.0.2.1, elle ne pourra pas résoudre cet IP.

Définition du problème

Passons à la tâche annoncée : écrire un mécanisme SYN cookies sur XDP.

Jusqu'à présent, l'attaque DDoS populaire reste le SYN flood, dont le principe est le suivant. Lors de l'établissement de la connexion (handshake TCP), le serveur reçoit un SYN, alloue des ressources pour la future connexion, répond avec un paquet SYNACK et attend un ACK. L'attaquant envoie simplement des paquets SYN depuis des adresses falsifiées par milliers par seconde depuis chaque hôte d'un botnet de plusieurs milliers. Le serveur est obligé d'allouer des ressources dès l'arrivée du paquet, mais les libère après un long délai, ce qui conduit à une saturation de la mémoire ou des limites, et aucune nouvelle connexion n'est acceptée, rendant le service indisponible.

Si une ressource n'est pas allouée pour chaque paquet SYN, mais que l'on se contente de répondre avec un paquet SYNACK, comment le serveur peut-il comprendre que le paquet ACK, arrivé plus tard, se rapporte au paquet SYN qui n'a pas été enregistré ? Car l'attaquant peut générer de faux ACK. Le principe des SYN cookies est d'encoder dans seqnum les paramètres de la connexion sous forme de hachage d'adresses, de ports et d'un sel changeant. Si l'ACK a trouvé le temps d'arriver avant le changement de sel, il est possible de recalculer le hachage et de le comparer à acknum. Falsifier acknum n'est pas possible pour l'attaquant, car le sel inclut un secret, et il ne peut pas le brute-forcer à cause de la bande passante limitée.

Les SYN cookies sont déjà implémentés dans le noyau Linux et peuvent même s'activer automatiquement si les SYN arrivent trop rapidement et en masse.

Initiation sur le handshake TCP

TCP assure le transfert de données sous forme de flux de bytes, par exemple des requêtes HTTP sont transmises au-dessus de TCP. Le flux est transmis par morceaux dans des paquets. Tous les paquets TCP ont des drapeaux logiques et des numéros de séquence de 32 bits :

  • La combinaison des drapeaux détermine le rôle d'un paquet spécifique. Le drapeau SYN indique qu'il s'agit du premier paquet envoyé par l'expéditeur dans la connexion. Le drapeau ACK signifie que l'expéditeur a reçu toutes les données de connexion jusqu'au byte. acknum. Un paquet peut avoir plusieurs drapeaux et est nommé selon leur combinaison, par exemple, un paquet SYNACK.

  • Le numéro de séquence (seqnum) détermine le décalage dans le flux de données pour le premier byte qui est transmis dans ce paquet. Par exemple, si dans le premier paquet avec X bytes de données ce numéro était N, dans le paquet suivant avec de nouvelles données, il sera N+X. Au début de la connexion, chaque côté choisit ce numéro de manière aléatoire.

  • Le numéro d'accusé de réception (acknum) est un décalage similaire au seqnum, mais il ne détermine pas le numéro du byte transmis, mais le numéro du premier byte de l'expéditeur auquel le destinataire n'a pas encore eu accès.

Au début de la connexion, les parties doivent converger seqnum et acknum. Le client envoie un paquet SYN avec son seqnum = X. Le serveur répond avec un paquet SYNACK, dans lequel il inscrit son seqnum = Y et fixe acknum = X + 1. Le client répond au SYNACK avec un paquet ACK, où seqnum = X + 1, acknum = Y + 1. Après cela, la transmission des données proprement dite commence.

Si l'interlocuteur ne confirme pas la réception du paquet, TCP le renvoie après un délai.

Pourquoi les SYN cookies ne sont-ils pas utilisés en permanence ?

Tout d'abord, si le SYNACK ou l'ACK se perd, il faudra attendre une nouvelle transmission, ce qui ralentit l'établissement de la connexion. Deuxièmement, dans le paquet SYN – et seulement dans celui-ci ! – un certain nombre d'options sont transmises, affectant le fonctionnement ultérieur de la connexion. En n'enregistrant pas les paquets SYN entrants, le serveur ignore ainsi ces options, et dans les paquets suivants, le client ne les enverra déjà plus. TCP peut encore fonctionner, mais au moins au début, la qualité de la connexion sera réduite.

Du point de vue des paquets, le programme XDP doit faire ce qui suit :

  • répondre au SYN avec un SYNACK contenant un cookie ;
  • répondre à l'ACK avec un RST (rompre la connexion) ;
  • rejeter les autres paquets.

Pseudocode de l'algorithme avec analyse du paquet :

Si ce n'est pas de l'Ethernet,
    passer le paquet.
Si ce n'est pas de l'IPv4,
    passer le paquet.
Si l'adresse est dans la table vérifiée,               (*)
        diminuer le compteur des vérifications restantes,
        passer le paquet.
Si ce n'est pas du TCP,
    réinitialiser le paquet.     (**)
Si c'est un SYN,
    répondre SYN-ACK avec un cookie.
Si c'est un ACK,
    si acknum ne contient pas le cookie,
        réinitialiser le paquet.
    Ajouter à la table l'adresse avec N vérifications restantes.    (*)
    Répondre RST.   (**)
Dans les autres cas, réinitialiser le paquet.

Un (*) points indiqués, où il est nécessaire de gérer l'état du système — à la première étape, on peut s'en passer, en réalisant simplement le handshake TCP en générant un cookie SYN comme seqnum.

Sur place (**), tant que nous n'avons pas de table, nous allons passer le paquet.

Implémentation du handshake TCP

Analyse du paquet et vérification du code

Nous aurons besoin des structures d'en-têtes réseau : Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) et TCP (uapi/linux/tcp.h). Je n'ai pas réussi à le connecter à cause des erreurs liées à atomic64_t, j'ai dû copier les définitions nécessaires dans le code.

Toutes les fonctions, qui en C sont définies pour la lisibilité, doivent être intégrées au point d'appel, car le vérificateur eBPF dans le noyau interdit les retours en arrière, donc pratiquement, les boucles et les appels de fonctions.

#define INTERNAL static __attribute__((always_inline))

Le macro LOG() désactive l'impression dans une version de production.

Le programme est un pipeline de fonctions. Chacune prend un paquet, dans lequel l'en-tête du niveau correspondant est alloué, par exemple, process_ether() s'attend à ce que ether. Selon les résultats de l'analyse des champs, la fonction peut transmettre le paquet au niveau supérieur. Le résultat du travail de la fonction — action XDP. Pour l'instant, les gestionnaires SYN et ACK passent tous les paquets.

struct Packet {
    struct xdp_md* ctx;

    struct ethhdr* ether;
    struct iphdr* ip;
    struct tcphdr* tcp;
};

INTERNAL int process_tcp_syn(struct Packet* packet) { return XDP_PASS; }
INTERNAL int process_tcp_ack(struct Packet* packet) { return XDP_PASS; }
INTERNAL int process_tcp(struct Packet* packet) { ... }
INTERNAL int process_ip(struct Packet* packet) { ... }

INTERNAL int
process_ether(struct Packet* packet) {
    struct ethhdr* ether = packet->ether;

    LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));

    if (ether->h_proto != bpf_ntohs(ETH_P_IP)) {
        return XDP_PASS;
    }

    // B
    struct iphdr* ip = (struct iphdr*)(ether + 1);
    if ((void*)(ip + 1) > (void*)packet->ctx->data_end) {
        return XDP_DROP; /* paquet mal formé */
    }

    packet->ip = ip;
    return process_ip(packet);
}

SEC("prog")
int xdp_main(struct xdp_md* ctx) {
    struct Packet packet;
    packet.ctx = ctx;

    // A
    struct ethhdr* ether = (struct ethhdr*)(void*)ctx->data;
    if ((void*)(ether + 1) > (void*)ctx->data_end) {
        return XDP_PASS;
    }

    packet.ether = ether;
    return process_ether(&packet);
}

Je tiens à attirer votre attention sur les vérifications marquées par A et B. Si vous commentez A, le programme se compile, mais il y aura une erreur de vérification au chargement.

Analyseur de vérification :


11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
accès invalide au paquet, off=13 taille=1, R7(id=0,off=0,r=0)
Le décalage R7 est en dehors du paquet
traité 11 instructions (limite 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0

Erreur de récupération du programme/carte !

Chaîne clé accès invalide au paquet, off=13 taille=1, R7(id=0,off=0,r=0): il existe des chemins d'exécution où le treizième octet du début du tampon est en dehors du paquet. Il est un peu difficile de comprendre de quelle ligne il s'agit à partir du listing, mais il y a le numéro d'instruction (12) et le désassembleur montrant les lignes du code source :

llvm-objdump -S xdp_filter.o | less

Dans ce cas, il pointe vers la ligne

LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));

où il devient clair que le problème réside dans ether. Cela devrait toujours être le cas.

Réponse au SYN

L'objectif à ce stade est de former un paquet SYNACK correct avec un seqnum, qui sera remplacé dans le futur par un SYN cookie. Tous les changements se produisent dans process_tcp_syn() et ses environs.

Vérification du paquet

Étonnamment, voici la ligne la plus remarquable, ou plutôt, le commentaire à son sujet :

/* Required to verify checksum calculation */
const void* data_end = (const void*)ctx->data_end;

Lors de l'écriture de la première version du code, le noyau 5.1 a été utilisé, pour lequel il y avait une différence pour data_end et (const void*)ctx->data_end. À la rédaction de cet article, le noyau 5.3.1 n'avait pas ce problème. Il se peut que le compilateur ait traité la variable locale différemment du champ. La morale est qu'avec une grande profondeur d'imbrication, simplifier le code peut aider.

Ensuite, vérifications de routine sur les longueurs en faveur du vérificateur ; concernant MAX_CSUM_BYTES ci-dessous.

const u32 ip_len = ip->ihl * 4;
if ((void*)ip + ip_len > data_end) {
    return XDP_DROP; /* paquet malformé */
}
if (ip_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* limitation d'implémentation */
}

const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
    return XDP_DROP; /* paquet malformé */
}
if (tcp_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* limitation d'implémentation */
}

Inversion du paquet

Nous remplissons seqnum et acknum, nous définissons ACK (SYN est déjà défini) :

const u32 cookie = 42;
tcp->ack_seq = bpf_htonl(bpf_ntohl(tcp->seq) + 1);
tcp->seq = bpf_htonl(cookie);
tcp->ack = 1;

Nous échangeons les ports TCP, l'adresse IP et les adresses MAC. La bibliothèque standard n'est pas disponible depuis le programme XDP, donc memcpy() est un macro qui cache l'intrinsèque de Clang.

const u16 temp_port = tcp->source;
tcp->source = tcp->dest;
tcp->dest = temp_port;

const u32 temp_ip = ip->saddr;
ip->saddr = ip->daddr;
ip->daddr = temp_ip;

struct ethhdr temp_ether = *ether;
memcpy(ether->h_dest, temp_ether.h_source, ETH_ALEN);
memcpy(ether->h_source, temp_ether.h_dest, ETH_ALEN);

Recalcul des sommes de contrôle

Les sommes de contrôle IPv4 et TCP nécessitent l'addition de tous les mots de 16 bits dans les en-têtes, dont la taille est inscrite en eux, c'est-à-dire qu'elle est inconnue au moment de la compilation. C'est un problème, car le vérificateur ne passera pas par une boucle classique jusqu'à la frontière variable. Cependant, la taille des en-têtes est limitée : jusqu'à 64 octets chacun. On peut réaliser une boucle avec un nombre fixe d'itérations qui peut se terminer prématurément.

Je remarquerai qu'il existe RFC 1624 sur la façon de recalculer la somme de contrôle partiellement si seuls des mots fixes des paquets ont été modifiés. Cependant, cette méthode n'est pas universelle, et sa mise en œuvre serait plus difficile à maintenir.

Fonction de calcul de la somme de contrôle :

#define MAX_CSUM_WORDS 32
#define MAX_CSUM_BYTES (MAX_CSUM_WORDS * 2)

INTERNAL u32
sum16(const void* data, u32 size, const void* data_end) {
    u32 s = 0;
#pragma unroll
    for (u32 i = 0; i < MAX_CSUM_WORDS; i++) {
        if (2*i >= size) {
            return s; /* normal exit */
        }
        if (data + 2*i + 1 + 1 > data_end) {
            return 0; /* should be unreachable */
        }
        s += ((const u16*)data)[i];
    }
    return s;
}

Bien que taille vérifié par le code appelant, la seconde condition de sortie est nécessaire pour que le vérificateur puisse prouver la fin de la boucle.

Pour les mots de 32 bits, une version plus simple est implémentée :

INTERNAL u32
sum16_32(u32 v) {
    return (v >> 16) + (v & 0xffff);
}

À proprement parler, le recalcul des sommes de contrôle et l'envoi du paquet en retour :

ip->check = 0;
ip->check = carry(sum16(ip, ip_len, data_end));

u32 tcp_csum = 0;
tcp_csum += sum16_32(ip->saddr);
tcp_csum += sum16_32(ip->daddr);
tcp_csum += 0x0600;
tcp_csum += tcp_len <check = 0;
tcp_csum += sum16(tcp, tcp_len, data_end);
tcp->check = carry(tcp_csum);

return XDP_TX;

Fonction carry() transforme une somme de 32 bits en une somme de contrôle de mots de 16 bits, selon la RFC 791.

Vérification de l’établissement de la connexion TCP

Le filtre établit correctement la connexion avec netcat, en ignorant l'ACK final, auquel Linux a répondu par un paquet RST, puisque la pile réseau n'a pas reçu de SYN - il a été reconstruit en SYNACK et renvoyé - et du point de vue de l'OS, le paquet est arrivé sans rapport avec les connexions ouvertes.

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666 : Connexion réinitialisée par le pair

Il est important de vérifier justement avec des applications complètes et d'observer tcpdump sur xdp-remote parce que, par exemple, hping3 ne réagit pas aux sommes de contrôle incorrectes.

Du point de vue de XDP, la vérification elle-même est triviale. L'algorithme de calcul est primitif et, probablement, vulnérable à un attaquant sophistiqué. Le noyau Linux, par exemple, utilise le SipHash cryptographique, mais son implémentation pour XDP dépasse clairement le cadre de cet article.

Des TODO sont apparus pour de nouveaux éléments liés aux interactions externes :

  • Le programme XDP ne peut pas stocker cookie_seed (la partie secrète du sel) dans une variable globale, un stockage dans le noyau est nécessaire, dont la valeur sera périodiquement mise à jour depuis un générateur fiable.

  • Lorsqu'il y a une correspondance SYN cookie dans le paquet ACK, il ne faut pas imprimer de message, mais mémoriser l'IP du client vérifié afin de permettre plus tard le passage des paquets provenant de celui-ci.

Vérification par un client légitime :

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666 : connexion réinitialisée par le pair

Les journaux ont enregistré le passage de la vérification (flags=0x2 — c'est SYN, flags=0x10 — c'est ACK) :

Ether(proto=0x800)
  IP(src=0x20e6e11a dst=0x20e6e11e proto=6)
    TCP(sport=50836 dport=6666 flags=0x2)
Ether(proto=0x800)
  IP(src=0xfe2cb11a dst=0xfe2cb11e proto=6)
    TCP(sport=50836 dport=6666 flags=0x10)
      cookie correspond à client 20200c0

Tant qu'il n'y a pas de liste d'IPs vérifiées, il n'y aura pas de protection contre les SYN floods, mais voici la réaction à un ACK flood lancé par une telle commande :

sudo ip netns exec xdp-test   hping3 --flood -A -s 1111 -p 2222 192.0.2.1

Enregistrements dans le journal :

Ether(proto=0x800)
  IP(src=0x15bd11a dst=0x15bd11e proto=6)
    TCP(sport=3236 dport=2222 flags=0x10)
      cookie ne correspond pas

Conclusion

Parfois, eBPF en général et XDP en particulier apparaissent davantage comme un outil avancé pour les administrateurs, plutôt que comme une plateforme de développement. En effet, XDP est un outil d'intervention dans le traitement des paquets par le noyau, et non une alternative à la pile du noyau, comme DPDK et d'autres options de contournement du noyau. D'autre part, XDP permet de mettre en œuvre une logique assez complexe, qui, de plus, peut être facilement mise à jour sans pause dans le traitement du trafic. Le vérificateur ne pose pas de gros problèmes, personnellement, je ne renoncerais pas à un tel outil pour certaines parties du code userspace.

Dans la deuxième partie, si le sujet vous intéresse, nous compléterons le tableau des clients vérifiés et des interruptions de connexion, nous intégrerons des compteurs et créerons un utilitaire userspace pour gérer le filtre.

Liens :

Source : habr.com

Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS 🔥 Acheter un hébergement fiable pour les sites avec protection DDoS, serveurs VPS VDS | ProHoster