Implementierung eines DDoS-Schutzes auf XDP. Kernkomponente

Die eXpress Data Path (XDP)-Technologie ermöglicht die flexible Verarbeitung von Datenverkehr auf Linux-Schnittstellen, bevor die Pakete in den Netzwerk-Stack des Kernels gelangen. Der Einsatz von XDP dient dem Schutz vor DDoS-Angriffen (CloudFlare), komplexen Filtern und der Statistiksammlung (Netflix). XDP-Programme werden von der virtuellen Maschine eBPF ausgeführt, wodurch es Einschränkungen sowohl für den Code als auch für die verfügbaren Kernel-Funktionen in Abhängigkeit vom Filtertyp gibt.

Dieser Artikel zielt darauf ab, die Mängel zahlreicher Materialien zu XDP zu beheben. Erstens beinhalten diese oft vorgefertigten Code, der sofort die Besonderheiten von XDP umgeht: er ist entweder für die Verifizierung vorbereitet oder zu einfach, um Probleme zu verursachen. Wenn man später versucht, eigenen Code von Grund auf neu zu schreiben, fehlt das Verständnis dafür, wie man mit typischen Fehlern umgeht. Zweitens werden Möglichkeiten zur lokalen Testung von XDP ohne VM und «Hardware» nicht behandelt, obwohl auch diese ihre eigenen «Schwächen» haben. Der Text richtet sich an Programmierer, die mit Netzwerken und Linux vertraut sind und sich für XDP und eBPF interessieren.

In diesem Abschnitt werden wir detailliert untersuchen, wie der XDP-Filter aufgebaut wird und wie man ihn testet. Anschließend werden wir eine einfache Version des bekannten SYN-Cookie-Mechanismus auf der Paketverarbeitungsebene schreiben. Zunächst werden wir keinen "Whitelist"-Mechanismus implementieren.
Wir werden festgelegte Kunden überprüfen, Zähler führen und den Filter verwalten – das reicht aus den Logs.

Wir werden in C programmieren – das ist zwar nicht trendy, aber praktisch. Der gesamte Code ist auf GitHub verfügbar, über den Link am Ende, und in Commits nach den im Artikel beschriebenen Phasen unterteilt.

Haftungsausschluss. Im Laufe des Artikels wird eine Mini-Lösung zur Abwehr von DDoS-Angriffe entwickelt, da dies eine realistische Aufgabe für XDP und mein Fachgebiet ist. Das Hauptziel ist jedoch, die Technologie zu verstehen; dies ist kein Leitfaden zur Erstellung eines fertigen Schutzes. Der Lerncode ist nicht optimiert und überspringt einige Nuancen.

Kurze Übersicht über XDP

Ich werde nur die wichtigsten Punkte ansprechen, um die Dokumentation und bestehende Artikel nicht zu wiederholen.

Der Filtercode wird in den Kernel geladen. Ankommende Pakete werden an den Filter übergeben. Schließlich muss der Filter eine Entscheidung treffen: das Paket in den Kernel durchlassen (XDP_PASS), das Paket zurücksetzen (XDP_DROP) oder es zurücksenden (XDP_TX). Der Filter kann das Paket ändern, was besonders relevant ist für XDP_TX. Es ist auch möglich, das Programm (XDP_ABORTED) abzubrechen und das Paket zurückzusetzen, jedoch ist das vergleichbar mit assert(0) — zur Fehlersuche.

Die eBPF (extended Berkley Packet Filter) virtuelle Maschine ist so einfach gestaltet, dass der Kernel überprüfen kann, ob der Code nicht in eine Schleife gerät und keinen anderen Speicher beschädigt. Die kumulativen Einschränkungen und Prüfungen sind:

  • Schleifen (Rücksprünge) sind verboten.
  • Es gibt einen Datenstack, aber keine Funktionen (alle C-Funktionen müssen eingebettet werden).
  • Zugriffe auf den Speicher außerhalb des Stacks und des Paketpuffers sind verboten.
  • Die Größe des Codes ist begrenzt, aber in der Praxis ist das nicht sehr bedeutend.
  • Nur spezielle Kernel-Funktionen (eBPF-Hilfsfunktionen) sind zulässig.

Die Entwicklung und Installation des Filters erfolgt wie folgt:

  1. Der Quellcode (zum Beispiel, kernel.c) wird in ein Objekt (kernel.o) für die Architektur der eBPF virtuellen Maschine kompiliert. Ab Oktober 2019 wird die Kompilierung in eBPF von Clang unterstützt und ist für GCC 10.1 in Aussicht gestellt.
  2. Falls dieser Objektcode auf Kernelstrukturen zugreift (z. B. auf Tabellen und Zähler) und anstelle ihrer IDs Nullen verwendet werden, kann dieser Code nicht ausgeführt werden. Vor dem Laden in den Kernel müssen diese Nullen durch die IDs spezifischer Objekte ersetzt werden, die durch Kernelaufrufe erstellt wurden (den Code verlinken). Dies kann mit externen Tools erfolgen oder durch das Schreiben eines Programms, das den spezifischen Filter verlinkt und lädt.
  3. Der Kernel validiert das zu ladende Programm. Dabei wird geprüft, ob keine Zyklen vorhanden sind und ob die Grenzen des Pakets und des Stacks nicht überschritten werden. Wenn der Validierer nicht nachweisen kann, dass der Code korrekt ist, wird das Programm abgelehnt – man muss ihn überzeugen können.
  4. Nach erfolgreicher Validierung kompiliert der Kernel den Objektcode der eBPF-Architektur in Maschinencode der Systemarchitektur (just-in-time).
  5. Das Programm wird an die Schnittstelle angehängt und beginnt, Pakete zu verarbeiten.

Da XDP im Kernel arbeitet, erfolgt die Fehlersuche über Trace-Logs und durch die Pakete, die das Programm filtert oder generiert. Dennoch sorgt eBPF für die Sicherheit des geladenen Codes im System, sodass man mit XDP direkt auf einem lokalen Linux experimentieren kann.

Umgebungsvorbereitung

Zusammenbau

Clang kann den Objektcode für die eBPF-Architektur nicht direkt ausgeben, daher besteht der Prozess aus zwei Schritten:

  1. Den C-Code in LLVM-Bitcode kompilieren (clang -emit-llvm).
  2. Den Bitcode in eBPF-Objektcode umwandeln (llc -march=bpf -filetype=obj).

Beim Schreiben des Filters sind einige Dateien mit Hilfsfunktionen und Makros hilfreich aus den Kernel-Tests. Es ist wichtig, dass sie zur Kernel-Version passen (KVER). Wir laden sie in 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 für Arch Linux (Kernel 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 enthält den Pfad zu den Kernel-Headern, ARCH — die Systemarchitektur. Pfade und Werkzeuge können zwischen den Distributionen leicht variieren.

Beispiele für Unterschiede bei Debian 10 (Kernel 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 bindet das Verzeichnis mit unterstützenden Header-Dateien und mehrere Verzeichnisse mit Kernel-Headern. Das Zeichen __KERNEL__ bedeutet, dass UAPI-Header (User-Space-API) für den Kernel-Code definiert sind, da der Filter im Kernel ausgeführt wird.

Der Stackschutz kann deaktiviert werden (-fno-stack-protector), da der eBPF-Codeverifier dennoch auf Stacküberläufe prüft. Man sollte sofort Optimierungen aktivieren, da die Größe des eBPF-BYTE-Codes begrenzt ist.

Beginnen wir mit einem Filter, der alle Pakete durchlässt und nichts tut:

#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";

Der Befehl make sammelt xdp_filter.o. Wo soll es jetzt getestet werden?

Teststand

Der Stand sollte zwei Schnittstellen umfassen: eine, auf der der Filter läuft, und eine, von der die Pakete gesendet werden. Es sollten vollständige Linux-Geräte mit ihren IPs sein, um zu überprüfen, wie gewöhnliche Anwendungen mit unserem Filter interagieren.

Geräte des Typs veth (Virtual Ethernet) sind dafür geeignet: Es handelt sich um ein Paar virtueller Netzwerkinterfaces, die direkt „miteinander verbunden“ sind. Sie können so erstellt werden (in diesem Abschnitt werden alle Befehle ip ausgeführt von root):

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

Hier xdp-remote und xdp-local — die Gerätenamen. An xdp-local (192.0.2.1/24) wird der Filter angeschlossen, mit xdp-remote (192.0.2.2/24) ist die Zieladresse für den eingehenden Traffic. Es gibt jedoch ein Problem: die Schnittstellen befinden sich auf einem einzigen Gerät, und Linux wird den Traffic nicht über eine dieser Schnittstellen zur anderen senden. Dies kann durch komplexe Regeln gelöst werden. iptables, allerdings müssen die Pakete geändert werden, was beim Debuggen unangenehm ist. Besser ist es, Netzwerknamensräume (network namespaces, im Folgenden netns) zu verwenden.

Ein Netzwerknamensraum hat eine Sammlung von Schnittstellen, Routing-Tabellen und NetFilter-Regeln, die von ähnlichen Objekten in anderen netns isoliert sind. Jeder Prozess läuft in einem bestimmten Namensraum und hat Zugang nur zu den Objekten dieses netns. Standardmäßig gibt es im System nur einen Netzwerknamensraum für alle Objekte, was bedeutet, dass man in Linux arbeiten kann, ohne von netns zu wissen.

Erstellen wir einen neuen Namensraum xdp-test und verschieben wir dort hinein xdp-remote.

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

Ein Prozess, der in xdp-test, wird nicht „sehen“ können xdp-local (er bleibt im Standard-netns) und überschickt ein Paket an 192.0.2.1 über xdp-remote, da dies die einzige Schnittstelle im 192.0.2.0/24 ist, die diesem Prozess zur Verfügung steht. Dies gilt auch umgekehrt.

Beim Wechsel zwischen netns wird das Interface deaktiviert und verliert die Adresse. Um das Interface in netns zu konfigurieren, muss man ausführen ip ... in diesem Namensraum der Befehle 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

Wie man sieht, unterscheidet sich das nicht von der Konfiguration xdp-local im Standardnamensraum:

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

Wenn man ausführt tcpdump -tnevi xdp-local, kann man sehen, dass die Pakete, die von xdp-test, auf dieses Interface zugestellt werden:

ip netns exec xdp-test   ping 192.0.2.1

Es ist praktisch, eine Shell in xdp-test. Im Repository gibt es ein Skript, das die Arbeit mit dem Stand automatisch macht, zum Beispiel kann man den Stand mit dem Befehl einrichten sudo ./stand up und ihn entfernen sudo ./stand down.

Traceroute

Der Filter wird wie folgt an das Gerät gebunden:

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

Der Schlüssel -force ist nötig, um ein neues Programm zu binden, wenn bereits eines gebunden ist. „Keine Nachrichten sind gute Nachrichten“ gilt nicht für diesen Befehl, die Ausgabe ist in jedem Fall umfangreich. Anzugeben verbose ist optional, aber damit erhält man einen Bericht über die Arbeit des Verifizierers mit der Liste des Assemblers:

Verifizierungsanalyse:

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

Das Programm vom Interface trennen:

ip link set dev xdp-local xdp off

Im Skript sind das die Befehle sudo ./stand attach und sudo ./stand detach.

Durch das Anbringen des Filters können Sie sicherstellen, dass ping es weiterhin funktioniert, aber funktioniert das Programm auch? Lassen Sie uns Protokolle hinzufügen. Die Funktion bpf_trace_printk() ähnelt printf(), unterstützt jedoch nur bis zu drei Argumente neben dem Format-String und eine begrenzte Liste von Spezifizierern. Das Makro bpf_printk() vereinfacht den Aufruf.

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

Die Ausgabe erfolgt an den Kernel-Trace-Kanal, der aktiviert werden muss:

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

Nachrichtenstrom anzeigen:

cat /sys/kernel/debug/tracing/trace_pipe

Beide dieser Befehle führen den Aufruf aus sudo ./stand log.

Ping sollte jetzt solche Nachrichten auslösen:

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

Wenn man sich die Ausgabe des Verifiers anschaut, kann man seltsame Berechnungen bemerken:

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

Das Problem ist, dass Programme auf eBPF keinen Datenbereich haben, sodass der einzige Weg, die Formatzeichenfolge zu kodieren, die Immediate-Argumente der VM-Befehle sind:

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

Aus diesem Grund bläht die Debug-Ausgabe den finalen Code erheblich auf.

Versand von XDP-Paketen

Ändern wir den Filter: Er soll alle eingehenden Pakete zurücksenden. Das ist aus netzwerktechnischer Sicht inkorrekt, da die Adressen in den Headern geändert werden müssten, aber derzeit ist die grundsätzliche Funktionalität wichtig.

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

Starten wir tcpdump findet man xdp-remote. Er sollte identische ausgehende und eingehende ICMP Echo Requests anzeigen und aufhören, ICMP Echo Replies anzuzeigen. Macht er aber nicht. Es stellt sich heraus, dass für die Funktion XDP_TX im Programm auf xdp-local benötigt wird, dass dem benachbarten Interface xdp-remote auch ein Programm zugewiesen ist, zumindest ein leeres, und es hochgefahren wurde.

Wie habe ich das herausgefunden?

Den Paketpfad im Kernel nachverfolgen ermöglicht der perf events-Mechanismus, der übrigens dieselbe virtuelle Maschine verwendet, das heißt, für Debugging-Angelegenheiten mit eBPF wird eBPF eingesetzt.

Du musst Gutes aus Bösem tun, denn es gibt nichts anderes, aus dem man es machen könnte.

$ 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])

Was bedeutet der Code 6?

$ errno 6
ENXIO 6 Kein solches Gerät oder Adresse

Die Funktion veth_xdp_flush_bq() erhält einen Fehlercode von veth_xdp_xmit(), wo wir suchen nach ENXIO und finden den Kommentar.

Wir stellen den minimalen Filter wieder her (XDP_PASS) in der Datei xdp_dummy.c, wir fügen ihn ins Makefile ein, binden ihn an xdp-remote:

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

Jetzt tcpdump zeigt das Erwartete:

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

Wenn stattdessen nur ARP angezeigt wird, müssen die Filter entfernt werden (das macht sudo ./stand detach), dann die ping, anschließend die Filter setzen und es erneut versuchen. Das Problem ist, dass der Filter XDP_TX auch auf ARP wirkt, und wenn der
Namensraum xdp-test den MAC-Adresse 192.0.2.1 «vergessen» hat, kann er diese IP nicht auflösen.

Problemstellung

Kommen wir zur gestellten Aufgabe: einen SYN-Cookies-Mechanismus auf XDP zu schreiben.

Die nach wie vor beliebteste DDoS-Attacke ist der SYN Flood, dessen Kernprinzip folgendermaßen aussieht: Bei der Herstellung einer Verbindung (TCP-Handshake) empfängt der Server ein SYN, weist Ressourcen für die zukünftige Verbindung zu, antwortet mit einem SYNACK-Paket und wartet auf das ACK. Der Angreifer sendet einfach SYN-Pakete von gefälschten Adressen in Tausenden pro Sekunde von jedem Host eines Botnets mit mehreren Tausend. Der Server muss sofort Ressourcen bereitstellen, sobald das Paket eintrifft, und gibt diese nach einer langen Timeout-Periode frei. Infolgedessen wird der Speicher oder das Limit erschöpft, neue Verbindungen werden nicht akzeptiert, und der Dienst ist nicht mehr erreichbar.

Wenn keine Ressourcen pro SYN-Paket zugewiesen werden und lediglich mit dem SYNACK-Paket geantwortet wird, wie kann der Server dann wissen, dass das ACK-Paket, das später kam, zu dem SYN-Paket gehört, das nicht gespeichert wurde? Schließlich kann der Angreifer auch gefälschte ACKs generieren. Die Idee von SYN-Cookies besteht darin, die Parametersätze der Verbindungen als Hash von Adressen, Ports und einer wechselnden Salz zu kodieren. seqnum Wenn das ACK rechtzeitig ankommt, bevor das Salz wechselt, kann der Hash erneut berechnet und mit acknumverglichen werden. Der Angreifer kann dies nicht fälschen, da das Salz ein Geheimnis enthält, und aufgrund der begrenzten Bandbreite wird er nicht rechtzeitig sein. acknum Der Angreifer kann nicht, da das Salz ein Geheimnis enthält und aufgrund der begrenzten Bandbreite nicht die Zeit hat, es zu entschlüsseln.

Das SYN-Cookie ist seit langem im Linux-Kernel implementiert und kann sogar automatisch aktiviert werden, wenn SYN-Pakete zu schnell und in großer Anzahl eintreffen.

Einführung in den TCP-Handshake

TCP ermöglicht die Übertragung von Daten als Byte-Stream, beispielsweise werden HTTP-Anfragen über TCP übertragen. Der Stream wird in Paketen übertragen. Alle TCP-Pakete verfügen über logische Flags und 32-Bit-Sequenznummern:

  • Die Kombination der Flags bestimmt die Rolle des jeweiligen Pakets. Das SYN-Flag bedeutet, dass dies das erste Paket des Senders in der Verbindung ist. Das ACK-Flag zeigt an, dass der Sender alle Verbindungsdaten bis zum Byte erhalten hat. acknumEin Paket kann mehrere Flags haben und wird nach ihrer Kombination benannt, beispielsweise als SYNACK-Paket.

  • Die Sequenznummer (seqnum) bestimmt die Verschiebung im Datenstrom für das erste Byte, das in diesem Paket übertragen wird. Wenn in dem ersten Paket mit X Byte Daten diese Nummer N war, wird sie im nächsten Paket mit neuen Daten N+X sein. Zu Beginn der Verbindung wählt jede Partei diese Nummer zufällig.

  • Bestätigungsnummer (acknum) – dies ist die gleiche Verschiebung wie seqnum, definiert jedoch nicht die Nummer des übertragenen Bytes, sondern die Nummer des ersten Bytes des Empfängers, das der Sender nicht gesehen hat.

Zu Beginn der Verbindung müssen die Parteien sich einigen seqnum und acknum. Der Client sendet ein SYN-Paket mit seinem seqnum = X. Der Server antwortet mit einem SYNACK-Paket, in dem er sein seqnum = Y und stellt acknum = X + 1. Der Client antwortet auf SYNACK mit einem ACK-Paket, in dem seqnum = X + 1, acknum = Y + 1. Danach beginnt die tatsächliche Datenübertragung.

Wenn der Gesprächspartner den Erhalt des Pakets nicht bestätigt, sendet TCP es nach einer Zeitüberschreitung erneut.

Warum werden SYN-Cookies nicht immer verwendet?

Erstens, wenn SYNACK oder ACK verloren gehen, muss auf die erneute Übertragung gewartet werden – dies verlangsamt die Einrichtung der Verbindung. Zweitens enthält das SYN-Paket – und nur darin! – eine Reihe von Optionen, die die weitere Funktion der Verbindung beeinflussen. Indem der Server die eingehenden SYN-Pakete nicht speichert, ignoriert er somit diese Optionen; in den folgenden Paketen wird der Client sie nicht mehr senden. TCP kann dennoch funktionieren, jedoch wird die Qualität der Verbindung mindestens in der Anfangsphase reduziert.

Aus Sicht der Pakete muss das XDP-Programm Folgendes tun:

  • Antworten Sie auf SYN mit SYNACK und einem Cookie;
  • Antworten Sie auf ACK mit RST (Verbindung trennen);
  • Alle anderen Pakete verworfen.

Pseudocode des Algorithmus einschließlich der Paketprüfung:

Wenn es kein Ethernet ist,
    Paket überspringen.
Wenn es kein IPv4 ist,
    Paket überspringen.
Wenn die Adresse in der Überprüfungstabelle steht,               (*)
        Zähler der verbleibenden Überprüfungen verringern,
        Paket überspringen.
Wenn es kein TCP ist,
    Paket zurücksetzen.     (**)
Wenn es SYN ist,
    mit SYN-ACK und Cookie antworten.
Wenn es ACK ist,
    wenn acknum nicht das Cookie enthält,
        Paket zurücksetzen.
    Adresse mit N verbleibenden Überprüfungen in die Tabelle eintragen.    (*)
    Mit RST antworten.   (**)
In allen anderen Fällen Paket zurücksetzen.

Ein (*) markierte Punkte, in denen der Systemstatus verwaltet werden muss – in der ersten Phase können wir ohne sie auskommen, indem wir einfach das TCP-Handshake mit der Generierung von SYN-Cookies als seqnum implementieren.

Vor Ort (**), während wir keine Tabelle haben, überspringen wir das Paket.

Implementierung des TCP-Handshake

Paketverarbeitung und Codeverifizierung

Wir benötigen die Strukturen der Netzwerkheader: Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) und TCP (uapi/linux/tcp.h). Ich konnte das Letzte aufgrund von Fehlern, die mit atomic64_t, verbunden waren, nicht einbinden und musste die erforderlichen Definitionen in den Code kopieren.

Alle Funktionen, die in C zur besseren Lesbarkeit hervorgehoben werden, müssen am Ort des Aufrufs eingebettet werden, da der eBPF-Überprüfer im Kernel Rücksprünge, d.h. tatsächlich Schleifen und Funktionsaufrufe, verbietet.

#define INTERNAL static __attribute__((always_inline))

Makro LOG() deaktiviert die Ausgabe in der Release-Version.

Das Programm ist eine Pipeline aus Funktionen. Jede akzeptiert ein Paket, in dem der Header der entsprechenden Ebene hervorgehoben ist, z. B. process_ether() erwartet, dass etherausgefüllt ist. Je nach Analyse der Felder kann die Funktion das Paket an die höhere Ebene weiterleiten. Das Ergebnis der Funktion ist eine XDP-Aktion. Momentan lassen die SYN- und ACK-Handler alle Pakete durch.

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; /* fehlerhaftes Paket */
    }

    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);
}

Ich möchte auf die Prüfungen hinweisen, die mit A und B gekennzeichnet sind. Wenn A kommentiert wird, kompiliert das Programm, aber beim Laden gibt es einen Verifizierungsfehler:

Verifizierer-Analyse:


11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
Ungültiger Zugriff auf das Paket, off=13 Größe=1, R7(id=0,off=0,r=0)
R7-Offset liegt außerhalb des Pakets
verarbeitet 11 Insns (Grenze 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0

Fehler beim Abrufen von Programm/Map!

Schlüsselzeile Ungültiger Zugriff auf das Paket, off=13 Größe=1, R7(id=0,off=0,r=0): Es gibt Ausführungswege, bei denen das dreizehnte Byte vom Anfang des Buffers außerhalb des Pakets liegt. Anhand der Liste ist es schwer zu erkennen, um welche Zeile es sich handelt, jedoch gibt es die Instruktionsnummer (12) und den Disassembler, der die Zeilen des Quellcodes zeigt:

llvm-objdump -S xdp_filter.o | less

In diesem Fall zeigt es auf die Zeile

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

aus der klar hervorgeht, dass das Problem bei ether. So sollte es immer sein.

Antwort auf SYN

Das Ziel in diesem Schritt ist, ein korrektes SYNACK-Paket mit fixiertem seqnum, das in Zukunft durch ein SYN-Cookie ersetzt wird. Alle Änderungen finden in process_tcp_syn() und den umliegenden Funktionen statt.

Paketprüfung

Merkwürdigerweise ist hier die bemerkenswerteste Zeile, genauer gesagt der Kommentar dazu:

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

Bei der Erstellung der ersten Version des Codes wurde Kernel 5.1 verwendet, für dessen Verifier es einen Unterschied gab zwischen data_end und (const void*)ctx->data_end. Bei der Erstellung des Artikels hatte Kernel 5.3.1 dieses Problem nicht. Möglicherweise hat der Compiler auf die lokale Variable anders zugegriffen als auf das Feld. Die Moral: Bei hoher Verschachtelung kann eine Vereinfachung des Codes helfen.

Als nächstes die routinemäßigen Längenprüfungen im Dienste des Verifiers; zu MAX_CSUM_BYTES unten.

const u32 ip_len = ip->ihl * 4;
if ((void*)ip + ip_len > data_end) {
    return XDP_DROP; /* fehlerhaftes Paket */
}
if (ip_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* Implementierungsbeschränkung */
}

const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
    return XDP_DROP; /* fehlerhaftes Paket */
}
if (tcp_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* Implementierungsbeschränkung */
}

Paket-Drehung

Wir füllen aus seqnum und acknum, setzen ACK (SYN ist bereits gesetzt):

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

Wir tauschen die TCP-Ports, IP-Adressen und MAC-Adressen. Die Standardbibliothek ist aus dem XDP-Programm nicht verfügbar, daher memcpy() — ein Makro, das den Clang Intrinsic verbirgt.

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

Neuberechnung der Prüfziffern

Die Prüfziffern für IPv4 und TCP erfordern die Summierung aller 16-Bit-Wörter in den Headern. Die Größe der Header ist dabei unbekannt, da sie zur Zeit der Kompilierung festgelegt wird. Dies ist problematisch, da der Verifikator keine gewöhnliche Schleife bis zur Variablen-Grenze durchlaufen kann. Allerdings ist die Größe der Header begrenzt: maximal 64 Byte pro Header. Man könnte eine Schleife mit einer festen Anzahl an Iterationen erstellen, die vorzeitig beendet werden kann.

Ich möchte darauf hinweisen, dass es RFC 1624 gibt, die beschreibt, wie die Prüfziffer teilweise neu berechnet werden kann, wenn nur feste Wörter der Pakete verändert wurden. Dieser Ansatz ist jedoch nicht universell, und die Implementierung wäre schwieriger zu warten.

Die Funktion zur Berechnung der Prüfziffer:

#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;
}

Obwohl Größe es vom aufrufenden Code geprüft wird, ist die zweite Bedingung für den Austrittsfall notwendig, damit der Verifikator den Abschluss der Schleife beweisen kann.

Für 32-Bit-Wörter wurde eine einfachere Version implementiert:

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

Die eigentliche Neuberechnung der Prüfziffern und das Versenden des Pakets zurück:

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;

Die Funktion carry() berechnet aus einer 32-Bit-Summe von 16-Bit-Wörtern eine Prüfziffer gemäß RFC 791.

TCP-Handschlagprüfung

Der Filter stellt erfolgreich eine Verbindung zu netcat, überspringt das endgültige ACK, auf das Linux mit einem RST-Paket reagierte, da der Netzwerk-Stack kein SYN erhielt – es wurde in ein SYNACK umgewandelt und zurückgesendet – und aus Sicht des OS kam ein Paket an, das nicht zu offenen Verbindungen gehörte.

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Verbindung vom Peer zurückgesetzt

Es ist wichtig, dies mit vollwertigen Anwendungen zu überprüfen und tcpdump findet man xdp-remote zu beobachten, weil zum Beispiel hping3 es nicht auf fehlerhafte Prüfziffern reagiert.

Aus Sicht von XDP ist die gesamte Überprüfung trivial. Der Berechnungsalgorithmus ist primitiv und wahrscheinlich anfällig für einen raffinierten Angreifer. Der Linux-Kernel verwendet beispielsweise kryptografisches SipHash, aber seine Implementierung für XDP fällt eindeutig außerhalb des Rahmens dieses Artikels.

Neu erschienen für neue TODOs im Zusammenhang mit externen Interaktionen:

  • Das XDP-Programm kann keine cookie_seed speichern. (den geheimen Teil des Schlüssels) in einer globalen Variablen speichern, es wird ein Speicher im Kern benötigt, dessen Wert regelmäßig von einem vertrauenswürdigen Generator aktualisiert wird.

  • Bei Übereinstimmung des SYN-Cookies im ACK-Paket sollte keine Nachricht ausgegeben, sondern die IP des überprüften Clients gespeichert werden, um Pakete von ihm später durchzulassen.

Überprüfung durch einen legitimen Client:

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Verbindung vom Peer zurückgesetzt

In den Logs wurde die Überprüfung aufgezeichnet (flags=0x2 — das ist SYN, flags=0x10 — das ist 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 stimmt mit Client 20200c0 überein

Solange es keine Liste überprüfter IPs gibt, wird kein Schutz gegen SYN Flood vorhanden sein, aber hier ist die Reaktion auf Flooding von ACK, ausgelöst durch diesen Befehl:

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

Einträge im Log:

Ether(proto=0x800)
  IP(src=0x15bd11a dst=0x15bd11e proto=6)
    TCP(sport=3236 dport=2222 flags=0x10)
      Cookie stimmt nicht überein

Fazit

Manchmal wird eBPF im Allgemeinen und XDP im Besonderen eher als Werkzeug für fortgeschrittene Administratoren denn als Plattform für die Entwicklung betrachtet. Tatsächlich ist XDP ein Werkzeug zur Eingriffe in die Paketerfassung des Kernels und keine Alternative zum Kernel-Stack, wie es bei DPDK und anderen Kernel-Bypass-Optionen der Fall ist. Andererseits ermöglicht XDP die Implementierung recht komplexer Logik, die zudem leicht aktualisiert werden kann, ohne dass der Datenverkehr unterbrochen wird. Der Verifikator sorgt nicht für große Probleme, ich würde persönlich nicht auf so etwas für Teile des Userspace-Codes verzichten.

Im zweiten Teil, falls das Thema interessant ist, werden wir die Tabelle der verifizierten Clients und der Verbindungsabbrüche fertigstellen, Zähler einfügen und ein Userspace-Utility zur Verwaltung des Filters schreiben.

Links:

Quelle: habr.com

Zuverlässiges Webhosting mit DDoS-Schutz, VPS- und VDS-Server kaufen 🔥 Zuverlässiges Webhosting mit DDoS-Schutz, VPS- und VDS-Server kaufen | ProHoster