Technologia eXpress Data Path (XDP) umożliwia dowolne przetwarzanie ruchu na interfejsach Linux, zanim pakiety trafią do stosu sieciowego jądra. Zastosowanie XDP - ochrona przed atakami DDoS (CloudFlare), złożone filtry, zbieranie statystyk (Netflix). Programy XDP są uruchamiane przez wirtualną maszynę eBPF, co wiąże się z ograniczeniami dotyczącymi zarówno kodu, jak i dostępnych funkcji jądra w zależności od typu filtra.
Artykuł ma na celu uzupełnienie niedoskonałości licznych materiałów dotyczących XDP. Po pierwsze, zawierają one gotowy kod, który od razu omija specyfikę XDP: jest przystosowany do weryfikacji lub zbyt prosty, aby wywołać problemy. Podczas pisania własnego kodu od zera nie ma zrozumienia, co robić z charakterystycznymi błędami. Po drugie, nie przedstawione zostały sposoby lokalnego testowania XDP bez maszyny wirtualnej i „sprzętu”, mimo że też mają swoje „pułapki”. Tekst jest skierowany do programistów zaznajomionych z sieciami i Linuxem, których interesuje XDP i eBPF.
W tej części szczegółowo omówimy, jak zbiera się filtr XDP i jak go testować, następnie napiszemy prostą wersję znanego mechanizmu SYN cookies na poziomie przetwarzania pakietów. Na razie nie będziemy tworzyć „białej listy”
zweryfikowanych klientów, prowadzić liczniki i zarządzać filtrem - wystarczą logi.
Będziemy pisać w C - nie jest to modne, ale praktyczne. Cały kod jest dostępny na GitHubie pod linkiem na końcu i podzielony na commity według etapów opisanych w artykule.
Zrzut ekranu. W trakcie artykułu zostanie opracowane mini rozwiązanie do obrony przed atakami DDoS, ponieważ jest to realistyczne zadanie dla XDP i moja dziedzina. Jednak głównym celem jest zrozumienie technologii, nie jest to przewodnik po tworzeniu gotowej ochrony. Kod edukacyjny nie jest zoptymalizowany i pomija niektóre niuanse.
Krótki przegląd XDP
Przedstawię tylko kluczowe punkty, aby nie dublować dokumentacji i istniejących artykułów.
W ten sposób do jądra ładowany jest kod filtra. Filtr otrzymuje przychodzące pakiety. W rezultacie filtr musi podjąć decyzję: przepuścić pakiet do jądra (XDP_PASS), odrzucić pakiet (XDP_DROP) lub odesłać go z powrotem (XDP_TX). Filtr może zmieniać pakiet, co jest szczególnie istotne dla XDP_TX. Można również awaryjnie przerwać program (XDP_ABORTED) i odrzucić pakiet, ale to analogiczne do assert(0) — w celach debugowania.
Wirtualna maszyna eBPF (Extended Berkley Packet Filter) została stworzona z myślą o prostocie, aby jądro mogło zweryfikować, czy kod nie wchodzi w pętlę i nie uszkadza pamięci innych procesów. Łączne ograniczenia i kontrole:
- Pętle (przejścia wstecz) są zabronione.
- Istnieje stos dla danych, ale nie ma funkcji (wszystkie funkcje C muszą być wbudowane).
- Dostęp do pamięci poza stosem i buforem pakietu jest zabroniony.
- Rozmiar kodu jest ograniczony, ale w praktyce nie jest to zbyt istotne.
- Dozwolone są wywołania tylko do specjalnych funkcji jądra (eBPF helpers).
Tworzenie i instalacja filtra wygląda następująco:
- Kod źródłowy (na przykład,
kernel.c) jest kompilowany do pliku obiektowego (kernel.o) dla architektury wirtualnej maszyny eBPF. Od października 2019 kompilacja do eBPF jest wspierana przez Clang i obiecywana w GCC 10.1. - Jeśli w tym pliku obiektowym są odwołania do struktur jądra (na przykład, do tabel i liczników), zamiast ich ID pojawiają się zera, co oznacza, że taki kod nie może być wykonany. Przed załadowaniem do jądra te zera należy zastąpić ID konkretnych obiektów stworzonych za pomocą wywołań jądra (zlifkować kod). Można to zrobić za pomocą zewnętrznych narzędzi lub napisać program, który zlifkuje i załaduje konkretny filtr.
- Jądro weryfikuje ładowany program. Sprawdzana jest obecność pętli oraz przekroczenia granic pakietu i stosu. Jeśli weryfikator nie może udowodnić, że kod jest poprawny, program zostaje odrzucony — trzeba umieć go przekonać.
- Po pomyślnej weryfikacji jądro kompiluje kod obiektowy architektury eBPF do kodu maszynowego architektury systemowej (just-in-time).
- Program jest dołączany do interfejsu i zaczyna przetwarzać pakiety.
Ponieważ XDP działa w jądrze, debugowanie odbywa się za pomocą logów śledzenia oraz pakietów, które program filtruje lub generuje. Niemniej jednak eBPF zapewnia bezpieczeństwo załadowanego kodu dla systemu, więc eksperymentowanie z XDP można przeprowadzać bezpośrednio na lokalnym systemie Linux.
Przygotowanie środowiska
Kompilacja
Clang nie może bezpośrednio wydawać kodu obiektowego dla architektury eBPF, dlatego proces składa się z dwóch kroków:
- Skompilować kod w C do kodu bajtowego LLVM (
clang -emit-llvm). - Przekształcić kod bajtowy do kodu obiektowego eBPF (
llc -march=bpf -filetype=obj).
Podczas pisania filtra przyda się kilka plików z funkcjami pomocniczymi i makrami . Ważne, aby były zgodne z wersją jądra (KVER). Pobieramy je do 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 BASEMakefile dla Arch Linux (jądro 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 ./*.oKDIR zawiera ścieżkę do nagłówków jądra, ARCH — architekturę systemu. Ścieżki i narzędzia mogą się nieco różnić pomiędzy dystrybucjami.
Przykład różnic dla Debiana 10 (jądro 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 dodają katalog z pomocniczymi nagłówkami oraz kilka katalogów z nagłówkami jądra. Symbol __KERNEL__ oznacza, że nagłówki UAPI (interfejs API dla przestrzeni użytkownika) są definiowane dla kodu jądra, ponieważ filtr działa w jądrze.
Ochronę stosu można wyłączyć (-fno-stack-protector), ponieważ weryfikator kodu eBPF i tak sprawdza przekroczenie granic stosu. Od razu warto włączyć optymalizacje, ponieważ rozmiar kodu bajtowego eBPF jest ograniczony.
Zacznijmy od filtru, który przepuszcza wszystkie pakiety i nic nie robi:
#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";Zespół make kompiluje xdp_filter.o. Gdzie go teraz przetestować?
Stojak testowy
Stojak powinien obejmować dwa interfejsy: na którym będzie filtr i z którego będą wysyłane pakiety. Muszą to być pełnoprawne urządzenia Linux z własnymi IP, aby sprawdzić, jak zwykłe aplikacje działają z naszym filtrem.
Urządzenia typu veth (wirtualny Ethernet) będą odpowiednie: to para wirtualnych interfejsów sieciowych, "połączonych" między sobą bezpośrednio. Można je stworzyć tak (w tej sekcji wszystkie polecenia ip są wykonywane z root):
ip link add xdp-remote type veth peer name xdp-localTutaj xdp-remote i xdp-local to nazwy urządzeń. Na xdp-local (192.0.2.1/24) zostanie podłączony filtr, z xdp-remote (192.0.2.2/24) będzie wysyłany ruch przychodzący. Jednak jest jeden problem: interfejsy znajdują się na jednej maszynie, a Linux nie będzie wysyłał ruchu na jeden z nich przez drugi. Można to rozwiązać sprytnymi regułami iptables, ale będą musiały modyfikować pakiety, co jest niewygodne podczas debugowania. Lepiej używać przestrzeni nazw sieciowych (network namespaces, dalej netns).
Przestrzeń nazw sieci zawiera zestaw interfejsów, tabel trasowania i zasad NetFilter, izolowanych od podobnych obiektów w innych netns. Każdy proces działa w jakiejś przestrzeni nazw i ma dostęp tylko do obiektów tego netns. Domyślnie w systemie istnieje jedyna przestrzeń nazw sieci dla wszystkich obiektów, dlatego można pracować w Linuxie i nie wiedzieć o netns.
Stwórzmy nową przestrzeń nazw xdp-test i przenieśmy się tam xdp-remote.
ip netns add xdp-test
ip link set dev xdp-remote netns xdp-testWtedy proces działający w xdp-test, nie będzie „widzieć” xdp-local (pozostanie w netns domyślnym) i przy wysyłaniu pakietu na 192.0.2.1 przekaże go przez xdp-remote, ponieważ to jedyny interfejs w 192.0.2.0/24, dostępny dla tego procesu. Działa to również w odwrotną stronę.
Przy przenoszeniu między netns interfejs jest opuszczany i traci adres. Aby skonfigurować interfejs w netns, należy uruchomić ip ... w tej przestrzeni nazw komend 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 upJak widać, to nie różni się od konfiguracji xdp-local w przestrzeni nazw domyślnej:
ip address add 192.0.2.1/24 dev xdp-local
ip link set xdp-local upJeśli uruchomisz tcpdump -tnevi xdp-local, można zobaczyć, że pakiety wysyłane z xdp-test, są dostarczane do tego interfejsu:
ip netns exec xdp-test ping 192.0.2.1Wygodnie jest uruchomić powłokę w xdp-test. W repozytorium znajduje się skrypt, który automatyzuje pracę ze stanowiskiem, na przykład można skonfigurować stanowisko poleceniem sudo ./stand up i usunąć je sudo ./stand down.
Ślad
Filtr jest przypinany do urządzenia w ten sposób:
ip -force link set dev xdp-local xdp object xdp_filter.o verboseKlucz -force jest wymagany, aby przypiąć nowy program, jeśli inny jest już przypięty. „Brak wiadomości to dobra wiadomość” nie odnosi się do tej komendy, wynik w każdym razie jest obszerny. Podanie verbose nie jest konieczne, ale z nim pojawia się raport o pracy weryfikatora kodu z listingiem asemblera:
Analiza weryfikatora:
0: (b7) r0 = 2
1: (95) exitOdłączyć program od interfejsu:
ip link set dev xdp-local xdp offW skrypcie to komendy sudo ./stand attach i sudo ./stand detach.
Po przypięciu filtra można upewnić się, że ping ciągle działa, ale czy program nadal działa? Dodajmy logi. Funkcja przypomina printf(), ale obsługuje maksymalnie trzy argumenty oprócz wzoru, i ograniczoną listę specyfikatorów. Makro bpf_printk() uproszcza wywołanie.
SEC("prog")
int xdp_main(struct xdp_md* ctx) {
+ bpf_printk("odebrano pakiet: %pn", ctx);
return XDP_PASS;
}Wynik jest kierowany do kanału śledzenia jądra, który należy włączyć:
echo -n 1 | sudo tee /sys/kernel/debug/tracing/options/trace_printkPrzeglądanie strumienia komunikatów:
cat /sys/kernel/debug/tracing/trace_pipeObie te komendy wywołują sudo ./stand log.
Ping teraz powinien generować następujące komunikaty:
-110930 [004] ..s1 78803.244967: 0: odebrano pakiet: 00000000ac510377Jeśli przyjrzeć się wynikowi weryfikatora, można zauważyć dziwne obliczenia:
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#6Chodzi o to, że programy na eBPF nie mają sekcji danych, więc jedynym sposobem kodowania ciągu formatowania są argumenty natychmiastowe komend maszyny wirtualnej:
$ python -c "import binascii; print(bytes(reversed(binascii.unhexlify('0a7025203a74656b63617020746f67'))))"
b'odebrano pakiet: %pn'Z tego powodu, komunikaty debugowania znacząco zwiększają rozmiar końcowego kodu.
Wysyłanie pakietów XDP
Zmienimy filtr: niech wszystkie przychodzące pakiety są odsyłane z powrotem. To jest niepoprawne z punktu widzenia sieci, ponieważ należałoby zmienić adresy w nagłówkach, ale obecnie ważne jest, aby to w ogóle działało.
bpf_printk("odebrano pakiet: %pn", ctx);
- return XDP_PASS;
+ return XDP_TX;
}Uruchamiamy tcpdump na xdp-remote. Powinien pokazać identyczne wychodzące i przychodzące ICMP Echo Request i przestać pokazywać ICMP Echo Reply. Ale nie pokazuje. Okazuje się, że do działania XDP_TX w programie na xdp-local , aby program również był przypisany do odpowiadającego interfejsu, przynajmniej pusty, i był podniesiony. xdp-remote Jak to odkryłem?
Śledzenie drogi pakietu w jądrze
Musisz uczynić dobro z zła, ponieważ nie ma niczego innego, co mogłoby być zrobione.
$ 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])
Co oznacza kod 6?$ errno 6 ENXIO 6 Nie ma takiego urządzenia ani adresu
Funkcjaveth_xdp_flush_bq() otrzymuje kod błędu od veth_xdp_xmit() , gdzie szukamy przezENXIO i znajdujemy komentarz. Przywróćmy podstawowy filtr (
) w plikuXDP_PASSxdp_dummy.c , dodamy go do Makefile, przypiszemy doip netns exec remote ip link set dev int xdp object dummy.o xdp-remote:
Terazpokazuje to, czego się spodziewano: tcpdump pokazuje to, czego się oczekiwało:
62:57:8e:70:44:64 > 26:0e:25:37:8f:96, typ eteru IPv4 (0x0800), długość 98: (tos 0x0, ttl 64, id 13762, offset 0, flagi [DF], proto ICMP (1), długość 84)
192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, długość 64
62:57:8e:70:44:64 > 26:0e:25:37:8f:96, typ eteru IPv4 (0x0800), długość 98: (tos 0x0, ttl 64, id 13762, offset 0, flagi [DF], proto ICMP (1), długość 84)
192.0.2.2 > 192.0.2.1: ICMP echo request, id 46966, seq 1, długość 64Jeśli zamiast tego wyświetlane są tylko ARP, należy usunąć filtry (to robi sudo ./stand detach), puścić ping, następnie ustawić filtry i spróbować ponownie. Problem polega na tym, że filtr XDP_TX działa zarówno na ARP, a jeśli stos
przestrzeni nazw xdp-test zdążył „zapomnieć” adres MAC 192.0.2.1, nie będzie mógł rozwiązać tego IP.
Sformułowanie zadania
Przejdźmy do zgłoszonego zadania: napisać mechanizm SYN cookies na XDP.
Do tej pory popularnym atakiem DDoS pozostaje SYN flood, którego istota polega na tym. Przy nawiązywaniu połączenia (TCP handshake) serwer otrzymuje SYN, przydziela zasoby na przyszłe połączenie, odpowiada pakietem SYNACK i czeka na ACK. Napastnik po prostu wysyła pakiety SYN z fałszywych adresów w liczbie tysięcy na sekundę z każdego hosta z kilku tysięcy botnetu. Serwer zmuszony jest do przydzielania zasobów natychmiast po przybyciu pakietu, a zwalnia je po długim czasie oczekiwania, w rezultacie cała pamięć lub limity się wyczerpują, nowe połączenia nie są przyjmowane, usługa staje się niedostępna.
Jeśli nie przydzielać zasobów na pakiet SYN, a tylko odpowiadać pakietem SYNACK, jak wtedy serwer ma zrozumieć, że pakiet ACK, który przybył później, odnosi się do pakietu SYN, którego nie zachowano? Przecież napastnik może generować również fałszywe ACK. Istota SYN cookie polega na tym, aby zakodować w seqnum parametry połączenia jako hash adresów, portów i zmiennej soli. Jeśli ACK zdążył przyjść przed zmianą soli, można jeszcze raz obliczyć hash i porównać z acknum. Oszustwo acknum napastnik nie może, ponieważ sól zawiera sekret, a nie zdąży przeprowadzić próby ze względu na ograniczony kanał.
SYN cookie od dawna jest wdrażany w jądrze Linux i może być nawet automatycznie włączany, jeśli SYN-y przychodzą zbyt szybko i masowo.
Likwidacja wiedzy na temat TCP handshake
TCP zapewnia przesyłanie danych jako strumienia bajtów, na przykład, nad TCP przesyłane są żądania HTTP. Strumień przesyłany jest w kawałkach w pakietach. Wszystkie pakiety TCP mają logiczne flagi i 32-bitowe numery sekwencyjne:
Kombinacja flag określa rolę konkretnego pakietu. Flaga SYN oznacza, że jest to pierwszy pakiet nadawcy w połączeniu. Flaga ACK oznacza, że nadawca otrzymał wszystkie dane od połączenia do bajtu.
acknum. Pakiet może mieć kilka flag i nazywa się według ich kombinacji, na przykład, pakiet SYNACK.Numer sekwencyjny (seqnum) określa przesunięcie w strumieniu danych dla pierwszego bajtu, który jest przesyłany w tym pakiecie. Na przykład, jeśli w pierwszym pakiecie z X bajtami danych ten numer wynosił N, w następnym pakiecie z nowymi danymi będzie N+X. Na początku połączenia każda strona wybiera ten numer w sposób losowy.
Numer potwierdzenia (acknum) - to takie samo przesunięcie jak seqnum, ale określa nie numer przesyłanego bajtu, a numer pierwszego bajtu od odbiorcy, którego nadawca nie widział.
Na początku połączenia strony muszą się zgodzić seqnum i acknum. Klient wysyła pakiet SYN z jego seqnum = X. Serwer odpowiada pakietem SYNACK, w którym zapisuje swój seqnum = Y i ustawia acknum = X + 1. Klient na SYNACK odpowiada pakietem ACK, gdzie seqnum = X + 1, acknum = Y + 1. Po tym zaczyna się właściwa transmisja danych.
Jeśli rozmówca nie potwierdzi odbioru pakietu, TCP wyśle go ponownie po czasie oczekiwania.
Dlaczego ciasteczka SYN nie są używane zawsze?
Po pierwsze, jeśli przepadnie SYNACK lub ACK, będzie trzeba czekać na ponowne wysłanie — spowalnia to nawiązywanie połączenia. Po drugie, w pakiecie SYN — i tylko w nim! — przesyłana jest szereg opcji, które wpływają na dalsze działanie połączenia. Ignorując przychodzące pakiety SYN, serwer w ten sposób pomija te opcje, a w kolejnych pakietach klient już ich nie wyśle. TCP może funkcjonować w tej sytuacji, ale przynajmniej na początkowym etapie jakość połączenia się pogorszy.
Z punktu widzenia pakietów, program XDP powinien robić następujące rzeczy:
- na SYN odpowiadać SYNACK z cookie;
- na ACK odpowiadać RST (przerwać połączenie);
- pozostałe pakiety odrzucać.
Pseudokod algorytmu wraz z analizą pakietu:
Jeśli to nie jest Ethernet,
pomiń pakiet.
Jeśli to nie jest IPv4,
pomiń pakiet.
Jeśli adres znajduje się w tabeli zatwierdzonych, (*)
zmniejsz licznik pozostałych zatwierdzeń,
pomiń pakiet.
Jeśli to nie jest TCP,
zresetuj pakiet. (**)
Jeśli to SYN,
odpowiedz SYN-ACK z cookie.
Jeśli to ACK,
jeśli w acknum nie ma cookie,
zresetuj pakiet.
Dodaj do tabeli adres z N pozostałymi zatwierdzeniami. (*)
Odpowiedz RST. (**)
W pozostałych przypadkach zresetuj pakiet.Jedna (*) oznaczone punkty, w których trzeba zarządzać stanem systemu – na pierwszym etapie można się bez nich obejść, realizując po prostu TCP handshake z generowaniem SYN cookie jako seqnum.
Na miejscu (**), dopóki nie posiadamy tabeli, będziemy pomijać pakiet.
Realizacja TCP handshake
Analiza pakietu i weryfikacja kodu
Będziemy potrzebować struktur nagłówków sieciowych: Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) i TCP (uapi/linux/tcp.h). Ostatecznie nie udało mi się podłączyć go z powodu błędów związanych z atomic64_t, musiałem skopiować potrzebne definicje do kodu.
Wszystkie funkcje, które w C są wydzielane dla ułatwienia czytania, powinny być wbudowane w miejscu wywołania, ponieważ weryfikator eBPF w jądrze zabrania powrotów, czyli w praktyce, cykli i wywołań funkcji.
#define INTERNAL static __attribute__((always_inline))Makro LOG() wyłącza drukowanie w wersji release.
Program jest konwejerem funkcji. Każda z nich przyjmuje pakiet, w którym wydzielono nagłówek odpowiedniego poziomu, na przykład, process_ether() oczekuje, że będzie wypełnione ether. Na podstawie analizy pól funkcja może przekazać pakiet na wyższy poziom. Wynikiem działania funkcji jest akcja XDP. Obecnie obsługiwacze SYN i ACK pomijają wszystkie pakiety.
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; /* źle sformatowany pakiet */
}
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);
}Zwracam uwagę na kontrole oznaczone A i B. Jeśli skomentujesz A, program się zbuduje, ale podczas ładowania wystąpi błąd weryfikacji:
Analiza weryfikatora:
11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
nieprawidłowy dostęp do pakietu, off=13 size=1, R7(id=0,off=0,r=0)
R7 offset jest poza pakietem
przetworzono 11 instrukcji (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0
Błąd przy pobieraniu programu/mapy!Kluczowy wiersz nieprawidłowy dostęp do pakietu, off=13 size=1, R7(id=0,off=0,r=0): są ścieżki wykonania, gdy trzynasty bajt od początku bufora znajduje się poza pakietem. Z listingiem trudno zrozumieć, o który wiersz chodzi, ale jest numer instrukcji (12) i dezasemblator, pokazujący wiersze kodu źródłowego:
llvm-objdump -S xdp_filter.o | lessW tym przypadku wskazuje na wiersz
LOG("Ether(proto=0x%x)", bpf_ntohs(ether->h_proto));z którego wynika, że problem tkwi w ether. Zawsze tak powinno być.
Odpowiedź na SYN
Celem na tym etapie jest sformułowanie poprawnego pakietu SYNACK z ustaloną seqnum, który w przyszłości zostanie zastąpiony SYN cookie. Wszystkie zmiany zachodzą w process_tcp_syn() i okolicach.
Sprawdzanie pakietu
Jak dziwne, oto najbardziej interesujący wiersz, a raczej komentarz do niego:
/* Required to verify checksum calculation */
const void* data_end = (const void*)ctx->data_end;Podczas pisania pierwszej wersji kodu używano jądra 5.1, dla którego istniała różnica między data_end i (const void*)ctx->data_end. Podczas pisania artykułu jądro 5.3.1 nie miało takiego problemu. Możliwe, że kompilator traktował zmienną lokalną inaczej niż pole. Moral z tego: przy dużym zagnieżdżeniu uproszczenie kodu może pomóc.
Dalej rutynowe sprawdzania długości na chwałę weryfikatora; o MAX_CSUM_BYTES poniżej.
const u32 ip_len = ip->ihl * 4;
if ((void*)ip + ip_len > data_end) {
return XDP_DROP; /* niepoprawny pakiet */
}
if (ip_len > MAX_CSUM_BYTES) {
return XDP_ABORTED; /* ograniczenie implementacji */
}
const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
return XDP_DROP; /* niepoprawny pakiet */
}
if (tcp_len > MAX_CSUM_BYTES) {
return XDP_ABORTED; /* ograniczenie implementacji */
}Przewrócenie pakietu
Wypełniamy seqnum i acknum, ustawiamy ACK (SYN już jest ustawiony):
const u32 cookie = 42;
tcp->ack_seq = bpf_htonl(bpf_ntohl(tcp->seq) + 1);
tcp->seq = bpf_htonl(cookie);
tcp->ack = 1;Zamieniamy miejscami porty TCP, adres IP i adresy MAC. Standardowa biblioteka nie jest dostępna z programu XDP, dlatego memcpy() — makro, które ukrywa intrinsics 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);Przeliczenie sum kontrolnych
Sumy kontrolne IPv4 i TCP wymagają dodawania wszystkich 16-bitowych słów w nagłówkach, a rozmiar nagłówków jest w nich zapisany, co oznacza, że w momencie kompilacji jest nieznany. To problem, ponieważ weryfikator nie przejdzie przez zwykłą pętlę do zmiennej granicy. Jednak rozmiar nagłówków jest ograniczony: do 64 bajtów każdy. Można utworzyć pętlę z ustaloną liczbą iteracji, która może zakończyć się przedwcześnie.
Zauważam, że istnieje o tym, jak obliczać sumę kontrolną częściowo, jeśli zmienione zostały tylko ustalone słowa pakietów. Jednak metoda ta nie jest uniwersalna, a jej realizacja byłaby trudniejsza do utrzymania.
Funkcja obliczania sumy kontrolnej:
#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;
}Pomimo że rozmiar sprawdzone przez wywołujący kod, drugi warunek wyjścia jest konieczny, aby weryfikator mógł udowodnić zakończenie pętli.
Dla 32-bitowych słów wdrożona jest prostsza wersja:
INTERNAL u32
sum16_32(u32 v) {
return (v >> 16) + (v & 0xffff);
}Reśmy właściwie obliczać sumy kontrolne i wysyłać pakiet z powrotem:
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;veth_xdp_flush_bq() carry() przekształca 32-bitową sumę 16-bitowych słów w sumę kontrolną, zgodnie z RFC 791.
Sprawdzanie ręcznego połączenia TCP
Filtr poprawnie nawiązuje połączenie z netcat, omijając końcowy ACK, na który Linux odpowiadał pakietem RST, ponieważ stos sieciowy nie otrzymał SYN - został on przekształcony w SYNACK i odesłany z powrotem - i z perspektywy systemu operacyjnego przybył pakiet, który nie dotyczył otwartych połączeń.
$ sudo ip netns exec xdp-test nc -nv 192.0.2.1 6666
192.0.2.1 6666: Połączenie zresetowane przez partneraWażne jest, aby sprawdzać to za pomocą czystych aplikacji i obserwować tcpdump na xdp-remote ponieważ na przykład hping3 nie reaguje na niepoprawne sumy kontrolne.
SYN cookie
Z perspektywy XDP samo sprawdzenie jest trywialne. Algorytm obliczania jest prymitywny i prawdopodobnie podatny na zaawansowane ataki. Jądro Linuxa na przykład wykorzystuje kryptograficzny SipHash, ale jego realizacja dla XDP wyraźnie nie wchodzi w zakres tego artykułu.
Pojawiły się nowe TODO związane z interakcjami zewnętrznymi:
Program XDP nie może przechowywać
cookie_seed(tajnej części soli) w zmiennej globalnej, konieczne jest przechowywanie w jądrze, którego wartość będzie okresowo aktualizowana z zaufanego generatora.W przypadku zgodności ciasteczka SYN w pakiecie ACK nie należy wyświetlać komunikatu, lecz zapisać IP zweryfikowanego klienta, aby później przepuszczać pakiety od niego.
Sprawdzenie przez legalnego klienta:
$ sudo ip netns exec xdp-test nc -nv 192.0.2.1 6666
192.0.2.1 6666: Połączenie zresetowane przez peerW logach odnotowano pomyślne przejście weryfikacji (flags=0x2 — to SYN, flags=0x10 — to 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)
ciasteczko pasuje dla klienta 20200c0Dopóki nie ma listy zweryfikowanych IP, ochrona przed własnym atakiem SYN flood nie będzie dostępna, ale oto reakcja na ACK flood, uruchamiane taką komendą:
sudo ip netns exec xdp-test hping3 --flood -A -s 1111 -p 2222 192.0.2.1Wpisy w logu:
Ether(proto=0x800)
IP(src=0x15bd11a dst=0x15bd11e proto=6)
TCP(sport=3236 dport=2222 flags=0x10)
ciasteczko nie pasujePodsumowanie
Czasami eBPF i XDP postrzegane są raczej jako narzędzie zaawansowanego administratora, a nie jako platforma do rozwijania. Rzeczywiście, XDP to narzędzie do ingerencji w przetwarzanie pakietów przez jądro, a nie alternatywa dla stosu jądrowego, jak DPDK i inne opcje kernel bypass. Z drugiej strony, XDP pozwala na wdrożenie dość skomplikowanej logiki, którą zresztą łatwo aktualizować bez przestoju w przetwarzaniu ruchu. Weryfikator nie stwarza dużych problemów, osobiście nie wzbraniałbym się przed takim dla części kodu userspace.
W drugiej części, jeśli temat interesuje, dokończymy tabelę zweryfikowanych klientów i zerwanie połączeń, wdrożymy liczniki i napiszemy narzędzie userspace do zarządzania filtrem.
Linki:
Źródło: habr.com
