Piszemy ochronę przed atakami DDoS na XDP. Część nuklearna

Technologia eXpress Data Path (XDP) umożliwia losowe przetwarzanie ruchu na interfejsach systemu 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ą wykonywane przez maszynę wirtualną eBPF, więc mają ograniczenia zarówno w kodzie, jak i dostępnych funkcjach jądra w zależności od typu filtra.

Artykuł ma na celu uzupełnienie braków licznych materiałów na temat XDP. Po pierwsze, udostępniają gotowy kod, który od razu omija funkcje XDP: jest przygotowany do weryfikacji lub jest zbyt prosty, aby sprawiać problemy. Kiedy następnie próbujesz napisać kod od zera, nie masz pojęcia, co zrobić z typowymi błędami. Po drugie, nie omówiono sposobów lokalnego testowania XDP bez maszyny wirtualnej i sprzętu, mimo że wiążą się one z pewnymi pułapkami. Tekst przeznaczony jest dla programistów znających sieci i Linuksa zainteresowanych XDP i eBPF.

W tej części zrozumiemy szczegółowo jak jest zbudowany filtr XDP i jak go przetestować, następnie napiszemy prostą wersję dobrze znanego mechanizmu plików cookie SYN na poziomie przetwarzania pakietów. Nie będziemy jeszcze tworzyć „białej listy”.
zweryfikowani klienci, prowadź liczniki i zarządzaj filtrem - wystarczy logów.

Będziemy pisać w C – nie jest to modne, ale praktyczne. Cały kod jest dostępny na GitHubie poprzez link na końcu i jest podzielony na zatwierdzenia zgodnie z etapami opisanymi w artykule.

Zrzeczenie się. W trakcie tego artykułu opracuję mini-rozwiązanie zapobiegające atakom DDoS, ponieważ jest to realistyczne zadanie dla XDP i mojej dziedziny specjalizacji. Jednak głównym celem jest zrozumienie technologii, nie jest to przewodnik po tworzeniu gotowych zabezpieczeń. Kod tutoriala nie jest zoptymalizowany i pomija pewne niuanse.

Krótki przegląd XDP

Zarysuję tylko najważniejsze punkty, aby nie powielać dokumentacji i istniejących artykułów.

Zatem kod filtra jest ładowany do jądra. Przychodzące pakiety są przekazywane do filtra. W rezultacie filtr musi podjąć decyzję: przekazać pakiet do jądra (XDP_PASS), upuść pakiet (XDP_DROP) lub odeślij go (XDP_TX). Filtr może zmienić pakiet, jest to szczególnie prawdziwe w przypadku XDP_TX. Można także przerwać program (XDP_ABORTED) i zresetuj pakiet, ale jest to analogiczne assert(0) - do debugowania.

Maszyna wirtualna eBPF (rozszerzony filtr pakietów Berkley) została celowo uproszczona, aby jądro mogło sprawdzić, czy kod nie zapętla się i nie uszkadza pamięci innych osób. Łączne ograniczenia i kontrole:

  • Pętle (do tyłu) są zabronione.
  • Istnieje stos danych, ale nie ma funkcji (wszystkie funkcje C muszą być wstawione).
  • Dostęp do pamięci poza stosem i buforem pakietów jest zabroniony.
  • Rozmiar kodu jest ograniczony, ale w praktyce nie ma to większego znaczenia.
  • Dozwolone są tylko wywołania specjalnych funkcji jądra (pomocników eBPF).

Projektowanie i instalacja filtra wygląda następująco:

  1. Kod źródłowy (np kernel.c) jest kompilowany do obiektu (kernel.o) dla architektury maszyny wirtualnej eBPF. Od października 2019 r. kompilacja do eBPF jest obsługiwana przez Clang i obiecana w GCC 10.1.
  2. Jeśli ten kod obiektowy zawiera wywołania struktur jądra (na przykład tabel i liczników), ich identyfikatory są zastępowane zerami, co oznacza, że ​​taki kod nie może zostać wykonany. Przed załadowaniem do jądra należy zastąpić te zera identyfikatorami konkretnych obiektów utworzonych poprzez wywołania jądra (połącz kod). Możesz to zrobić za pomocą zewnętrznych narzędzi lub możesz napisać program, który połączy i załaduje określony filtr.
  3. Jądro sprawdza załadowany program. Sprawdzany jest brak cykli oraz nieprzekroczenie granic pakietów i stosów. Jeżeli weryfikator nie jest w stanie udowodnić, że kod jest poprawny, program zostaje odrzucony – trzeba umieć go zadowolić.
  4. Po pomyślnej weryfikacji jądro kompiluje kod obiektowy architektury eBPF do kodu maszynowego architektury systemu (just-in-time).
  5. Program podłącza się do interfejsu i rozpoczyna przetwarzanie pakietów.

Ponieważ XDP działa w jądrze, debugowanie odbywa się przy użyciu dzienników śledzenia, a w rzeczywistości pakietów, które program filtruje lub generuje. Jednak eBPF dba o to, aby pobrany kod był bezpieczny dla systemu, dzięki czemu możesz eksperymentować z XDP bezpośrednio na lokalnym systemie Linux.

Przygotowanie środowiska

montaż

Clang nie może bezpośrednio wygenerować kodu obiektowego dla architektury eBPF, dlatego proces składa się z dwóch etapów:

  1. Skompiluj kod C do kodu bajtowego LLVM (clang -emit-llvm).
  2. Konwertuj kod bajtowy na kod obiektowy eBPF (llc -march=bpf -filetype=obj).

Podczas pisania filtra przyda się kilka plików z funkcjami pomocniczymi i makrami z testów jądra. Ważne jest, aby były zgodne z wersją jądra (KVER). Pobierz 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 BASE

Plik Makefile 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 ./*.o

KDIR zawiera ścieżkę do nagłówków jądra, ARCH - architektura systemu. Ścieżki i narzędzia mogą się nieznacznie różnić w zależności od dystrybucji.

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 połącz katalog z nagłówkami pomocniczymi i kilkoma katalogami z nagłówkami jądra. Symbol __KERNEL__ oznacza, że ​​nagłówki UAPI (userspace API) są zdefiniowane dla kodu jądra, ponieważ filtr jest wykonywany w jądrze.

Ochronę stosu można wyłączyć (-fno-stack-protector), ponieważ weryfikator kodu eBPF nadal sprawdza naruszenia stosu poza granicami. Warto od razu włączyć optymalizacje, bo wielkość kodu bajtowego eBPF jest ograniczona.

Zacznijmy od filtra, 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 zbiera xdp_filter.o. Gdzie teraz spróbować?

Stanowisko badawcze

Stanowisko musi zawierać dwa interfejsy: na którym będzie stał filtr oraz z którego będą wysyłane pakiety. Muszą to być pełnoprawne urządzenia linuksowe z własnymi adresami IP, aby sprawdzić, jak zwykłe aplikacje współpracują z naszym filtrem.

Odpowiednie są dla nas urządzenia typu veth (wirtualny Ethernet): jest to para wirtualnych interfejsów sieciowych „połączonych” bezpośrednio ze sobą. Możesz je utworzyć w ten sposób (w tej sekcji wszystkie polecenia ip przeprowadzane są od root):

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

Tutaj xdp-remote и xdp-local — nazwy urządzeń. NA xdp-local (192.0.2.1/24) zostanie podłączony filtr z xdp-remote (192.0.2.2/24) ruch przychodzący będzie wysyłany. Istnieje jednak problem: interfejsy znajdują się na tej samej maszynie, a Linux nie będzie przesyłał ruchu do jednego z nich przez drugi. Można to rozwiązać za pomocą trudnych zasad iptables, ale będą musieli zmienić pakiety, co jest niewygodne przy debugowaniu. Lepiej jest używać sieciowych przestrzeni nazw (zwanych dalej netns).

Sieciowa przestrzeń nazw zawiera zestaw interfejsów, tablic routingu i reguł NetFilter, które są odizolowane od podobnych obiektów w innych sieciach. Każdy proces działa w przestrzeni nazw i ma dostęp tylko do obiektów tej sieci. Domyślnie system ma jedną sieciową przestrzeń nazw dla wszystkich obiektów, więc możesz pracować w Linuksie i nie znać się na netns.

Utwórzmy nową przestrzeń nazw xdp-test i przenieś go tam xdp-remote.

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

Następnie rozpoczyna się proces xdp-test, nie „zobaczę” xdp-local (domyślnie pozostanie w netns) i przy wysyłaniu pakietu do 192.0.2.1 przejdzie przez niego xdp-remoteponieważ jest to jedyny interfejs w sieci 192.0.2.0/24 dostępny dla tego procesu. Działa to również w odwrotnym kierunku.

Podczas poruszania się pomiędzy sieciami interfejs ulega awarii i traci swój adres. Aby skonfigurować interfejs w netns, musisz uruchomić ip ... w tej przestrzeni nazw poleceń 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

Jak widać, nie różni się to od ustawienia xdp-local w domyślnej przestrzeni nazw:

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

Jeśli biegniesz tcpdump -tnevi xdp-local, możesz zobaczyć, które pakiety zostały wysłane z xdp-test, są dostarczane do tego interfejsu:

ip netns exec xdp-test   ping 192.0.2.1

Wygodne jest uruchomienie powłoki xdp-test. W repozytorium znajduje się skrypt automatyzujący pracę ze stoiskiem, np. za pomocą komendy możesz skonfigurować stoisko sudo ./stand up i usuń go sudo ./stand down.

Rysunek kalkowy

Filtr jest powiązany z urządzeniem w następujący sposób:

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

klucz -force potrzebne do połączenia nowego programu, jeśli inny jest już połączony. „Brak wiadomości to dobra wiadomość” nie dotyczy tego polecenia, w każdym razie wniosek jest obszerny. wskazać verbose opcjonalne, ale wraz z nim pojawia się raport z pracy weryfikatora kodu z listą montażu:

Verifier analysis:

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

Odłącz program od interfejsu:

ip link set dev xdp-local xdp off

W skrypcie są to polecenia sudo ./stand attach и sudo ./stand detach.

Możesz się o tym upewnić, dołączając filtr ping nadal działa, ale czy program działa? Dodajmy logi. Funkcjonować bpf_trace_printk() podobny do printf(), ale obsługuje tylko trzy argumenty inne niż wzorzec i ograniczoną listę specyfikatorów. Makro bpf_printk() upraszcza rozmowę.

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

Dane wyjściowe trafiają do kanału śledzenia jądra, który należy włączyć:

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

Wyświetl wątek wiadomości:

cat /sys/kernel/debug/tracing/trace_pipe

Obydwa te polecenia wykonują połączenie sudo ./stand log.

Ping powinien teraz wywoływać takie komunikaty:

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

Jeśli przyjrzysz się uważnie wynikom weryfikatora, zauważysz 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#6
<...>

Faktem jest, że programy eBPF nie mają sekcji danych, więc jedynym sposobem zakodowania ciągu formatującego są bezpośrednie argumenty poleceń VM:

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

Z tego powodu wyniki debugowania znacznie zawyżają wynikowy kod.

Wysyłanie pakietów XDP

Zmieńmy filtr: pozwólmy mu odsyłać wszystkie przychodzące pakiety. Jest to nieprawidłowe z punktu widzenia sieci, ponieważ konieczna byłaby zmiana adresów w nagłówkach, ale teraz praca jest w zasadzie ważna.

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

Biegnij tcpdump na xdp-remote. Powinien pokazywać identyczne wychodzące i przychodzące żądanie echa ICMP i przestać wyświetlać odpowiedź echa ICMP. Ale tego nie widać. Okazuje się, że do pracy XDP_TX w programie pt xdp-local musido interfejsu pary xdp-remote przydzielono mu także program, nawet jeśli był pusty, i został wychowany.

Skąd to wiedziałem?

Śledź ścieżkę pakietu w jądrze Mechanizm zdarzeń perf pozwala, nawiasem mówiąc, na użycie tej samej maszyny wirtualnej, czyli eBPF służy do deasemblacji z eBPF.

Ze zła trzeba zrobić dobro, bo nie ma innego wyjścia.

$ 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 to jest kod 6?

$ errno 6
ENXIO 6 No such device or address

Funkcja veth_xdp_flush_bq() otrzymuje kod błędu od veth_xdp_xmit(), gdzie szukaj według ENXIO i znajdź komentarz.

Przywróćmy filtr minimalny (XDP_PASS) w pliku xdp_dummy.c, dodaj go do pliku Makefile i powiąż z xdp-remote:

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

Instrukcja tcpdump pokazuje, czego się oczekuje:

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

Jeśli zamiast tego pokazane są tylko ARP, musisz usunąć filtry (to robi sudo ./stand detach), odpuść ping, a następnie ustaw filtry i spróbuj ponownie. Problemem jest filtr XDP_TX ważne zarówno na ARP, jak i na stosie
przestrzenie nazw xdp-test udało się „zapomnieć” adresu MAC 192.0.2.1, nie będzie w stanie rozpoznać tego adresu IP.

Stwierdzenie problemu

Przejdźmy do postawionego zadania: napisz mechanizm cookies SYN na XDP.

SYN Flood pozostaje popularnym atakiem DDoS, którego istota jest następująca. Po nawiązaniu połączenia (uzgadnianie TCP) serwer odbiera komunikat SYN, przydziela zasoby dla przyszłego połączenia, odpowiada pakietem SYNACK i czeka na potwierdzenie. Osoba atakująca po prostu wysyła tysiące pakietów SYN na sekundę ze sfałszowanych adresów z każdego hosta w wielotysięcznym botnecie. Serwer jest zmuszony do przydzielenia zasobów natychmiast po nadejściu pakietu, ale zwalnia je po dużym czasie oczekiwania, w wyniku czego następuje wyczerpanie pamięci lub limitów, nowe połączenia nie są akceptowane, a usługa jest niedostępna.

Jeśli nie przydzielasz zasobów na podstawie pakietu SYN, a jedynie odpowiadasz pakietem SYNACK, jak serwer może zrozumieć, że pakiet ACK, który przybył później, odnosi się do pakietu SYN, który nie został zapisany? W końcu osoba atakująca może również wygenerować fałszywe potwierdzenia. Celem pliku cookie SYN jest jego zakodowanie seqnum parametry połączenia w postaci skrótu adresów, portów i zmieniającej się soli. Jeśli potwierdzenie dotarło przed zmianą soli, możesz ponownie obliczyć skrót i porównać go z nim acknum. Kuźnia acknum atakujący nie może, ponieważ sól zawiera sekret i nie będzie miał czasu na jego przejrzenie ze względu na ograniczony kanał.

Plik cookie SYN jest od dawna zaimplementowany w jądrze Linuksa i może nawet zostać automatycznie włączony, jeśli pliki SYN dotrą zbyt szybko i masowo.

Program edukacyjny na temat uzgadniania protokołu TCP

Protokół TCP zapewnia transmisję danych w postaci strumienia bajtów, na przykład żądania HTTP są przesyłane przez protokół TCP. Strumień przesyłany jest fragmentami w pakietach. Wszystkie pakiety TCP mają flagi logiczne i 32-bitowe numery sekwencyjne:

  • Kombinacja flag określa rolę konkretnego pakietu. Flaga SYN wskazuje, że jest to pierwszy pakiet nadawcy w połączeniu. Flaga ACK oznacza, że ​​nadawca otrzymał wszystkie dane połączenia aż do bajtu acknum. Pakiet może mieć kilka flag i jest wywoływany poprzez ich kombinację, na przykład pakiet SYNACK.

  • Numer sekwencyjny (seqnum) określa przesunięcie w strumieniu danych dla pierwszego bajtu przesyłanego w tym pakiecie. Przykładowo, jeśli w pierwszym pakiecie zawierającym X bajtów danych liczba ta wynosiła N, w kolejnym pakiecie z nowymi danymi będzie to N+X. Na początku połączenia każda ze stron losowo wybiera ten numer.

  • Numer potwierdzenia (acknum) - to samo przesunięcie co seqnum, ale nie określa numeru przesyłanego bajtu, ale numer pierwszego bajtu od odbiorcy, którego nadawca nie widział.

Na początku połączenia strony muszą się zgodzić seqnum и acknum. Klient wysyła ze swoim pakietem SYN seqnum = X. Serwer odpowiada pakietem SYNACK, w którym go zapisuje seqnum = Y i eksponuje acknum = X + 1. Klient odpowiada na SYNACK pakietem ACK, gdzie seqnum = X + 1, acknum = Y + 1. Następnie rozpoczyna się właściwy transfer danych.

Jeśli partner nie potwierdzi odbioru pakietu, protokół TCP wyśle ​​go ponownie po upływie limitu czasu.

Dlaczego pliki cookie SYN nie zawsze są używane?

Po pierwsze, jeśli utracono SYNACK lub ACK, trzeba będzie poczekać na ponowne wysłanie - konfiguracja połączenia ulegnie spowolnieniu. Po drugie, w pakiecie SYN – i tylko w nim! — przesyłanych jest szereg opcji, które wpływają na dalsze działanie połączenia. Nie zapamiętując przychodzących pakietów SYN, serwer ignoruje te opcje; klient nie wyśle ​​ich w kolejnych pakietach. TCP może w tym przypadku zadziałać, ale przynajmniej na początkowym etapie jakość połączenia spadnie.

Z perspektywy pakietów program XDP musi wykonywać następujące czynności:

  • odpowiedz SYN za pomocą SYNACK i pliku cookie;
  • odpowiedz na ACK za pomocą RST (rozłącz);
  • odrzuć pozostałe pakiety.

Pseudokod algorytmu wraz z parsowaniem pakietów:

Если это не Ethernet,
    пропустить пакет.
Если это не IPv4,
    пропустить пакет.
Если адрес в таблице проверенных,               (*)
        уменьшить счетчик оставшихся проверок,
        пропустить пакет.
Если это не TCP,
    сбросить пакет.     (**)
Если это SYN,
    ответить SYN-ACK с cookie.
Если это ACK,
    если в acknum лежит не cookie,
        сбросить пакет.
    Занести в таблицу адрес с N оставшихся проверок.    (*)
    Ответить RST.   (**)
В остальных случаях сбросить пакет.

Jeden (*) zaznaczone są punkty, w których należy zarządzać stanem systemu - na pierwszym etapie można się bez nich obejść, po prostu implementując uzgadnianie TCP z generowaniem ciasteczka SYN jako kolejnego numeru.

Na miejscu (**), chociaż nie mamy tabeli, pominiemy pakiet.

Implementacja uzgadniania TCP

Parsowanie pakietu i weryfikacja kodu

Będziemy potrzebować struktur nagłówków sieci: Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) i TCP (uapi/linux/tcp.h). Tego ostatniego nie udało mi się połączyć ze względu na błędy związane z atomic64_t, musiałem skopiować niezbędne definicje do kodu.

Wszystkie funkcje wyróżnione w C ze względu na czytelność muszą być wstawione w momencie wywołania, ponieważ weryfikator eBPF w jądrze zabrania wycofywania się, czyli w rzeczywistości pętli i wywołań funkcji.

#define INTERNAL static __attribute__((always_inline))

Makro LOG() wyłącza drukowanie w kompilacji wydania.

Program jest przenośnikiem funkcji. Każdy otrzymuje pakiet, w którym podświetlony jest nagłówek odpowiedniego poziomu, na przykład process_ether() oczekuje, że zostanie wypełniony ether. Na podstawie wyników analizy pola funkcja może przekazać pakiet na wyższy poziom. Wynikiem funkcji jest akcja XDP. Na razie procedury obsługi SYN i ACK przekazują 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; /* malformed packet */
    }

    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ę skompiluje, ale podczas ładowania wystąpi błąd weryfikacji:

Verifier analysis:

<...>
11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
invalid access to packet, off=13 size=1, R7(id=0,off=0,r=0)
R7 offset is outside of the packet
processed 11 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0

Error fetching program/map!

Kluczowy ciąg invalid access to packet, off=13 size=1, R7(id=0,off=0,r=0): Istnieją ścieżki wykonania, gdy trzynasty bajt od początku bufora znajduje się na zewnątrz pakietu. Z zestawienia trudno zrozumieć, o której linii mówimy, ale jest tam numer instrukcji (12) i dezasembler pokazujący linie kodu źródłowego:

llvm-objdump -S xdp_filter.o | less

W tym przypadku wskazuje na linię

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

co jasno pokazuje, że problem istnieje ether. Zawsze tak będzie.

Odpowiedź dla SYN

Celem na tym etapie jest wygenerowanie prawidłowego pakietu SYNACK ze stałym seqnum, który w przyszłości zostanie zastąpiony plikiem cookie SYN. Wszystkie zmiany zachodzą w process_tcp_syn() i okolic.

Weryfikacja pakietu

Co dziwne, oto najbardziej niezwykły 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 wykorzystano jądro 5.1, dla którego weryfikatora istniała różnica pomiędzy data_end и (const void*)ctx->data_end. W chwili pisania tego tekstu w jądrze 5.3.1 nie występował ten problem. Możliwe, że kompilator uzyskiwał dostęp do zmiennej lokalnej inaczej niż do pola. Morał z tej historii: uproszczenie kodu może pomóc, gdy występuje dużo zagnieżdżeń.

Następnie przeprowadzane są rutynowe kontrole 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; /* malformed packet */
}
if (ip_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* implementation limitation */
}

const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
    return XDP_DROP; /* malformed packet */
}
if (tcp_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* implementation limitation */
}

Rozkładanie paczki

Wypełnij seqnum и acknum, ustaw ACK (SYN jest już ustawiony):

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

Zamień porty TCP, adresy IP i adresy MAC. Biblioteka standardowa nie jest dostępna z poziomu programu XDP, więc memcpy() — makro, które ukrywa cechy Clanga.

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

Przeliczanie sum kontrolnych

Sumy kontrolne IPv4 i TCP wymagają dodania wszystkich 16-bitowych słów w nagłówkach i zapisywany jest w nich rozmiar nagłówków, to znaczy nieznany w czasie kompilacji. Stanowi to problem, ponieważ weryfikator nie pominie normalnej pętli prowadzącej do zmiennej granicznej. Ale rozmiar nagłówków jest ograniczony: do 64 bajtów każdy. Możesz utworzyć pętlę ze stałą liczbą iteracji, która może zakończyć się wcześniej.

Zaznaczam, że jest RFC 1624 o tym, jak częściowo przeliczyć sumę kontrolną, jeśli zmienione zostaną tylko stałe słowa pakietów. Metoda ta nie jest jednak uniwersalna i jej wdrożenie byłoby trudniejsze w utrzymaniu.

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

Chociaż size weryfikowane przez kod wywołujący, konieczny jest drugi warunek wyjścia, aby weryfikator mógł udowodnić zakończenie pętli.

W przypadku słów 32-bitowych zaimplementowano prostszą wersję:

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

Właściwie przeliczam sumy kontrolne i wysyłam 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 << 8;
tcp->check = 0;
tcp_csum += sum16(tcp, tcp_len, data_end);
tcp->check = carry(tcp_csum);

return XDP_TX;

Funkcja carry() tworzy sumę kontrolną z 32-bitowej sumy 16-bitowych słów, zgodnie z RFC 791.

Weryfikacja uzgadniania TCP

Filtr poprawnie nawiązuje połączenie z netcat, brakuje końcowego ACK, na który Linux odpowiedział pakietem RST, ponieważ stos sieciowy nie otrzymał SYN - został przekonwertowany na SYNACK i odesłany - a z punktu widzenia systemu operacyjnego przybył pakiet niezwiązany z otwarciem znajomości.

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Connection reset by peer

Ważne jest, aby sprawdzić pełnoprawne aplikacje i obserwować tcpdump na xdp-remote ponieważ na przykład hping3 nie reaguje na nieprawidłowe sumy kontrolne.

Z punktu widzenia XDP sama weryfikacja jest banalna. Algorytm obliczeniowy jest prymitywny i prawdopodobnie podatny na ataki wyrafinowanego atakującego. Na przykład jądro Linuksa wykorzystuje kryptograficzny SipHash, ale jego implementacja dla XDP wyraźnie wykracza poza zakres tego artykułu.

Wprowadzono dla nowych TODO związanych z komunikacją zewnętrzną:

  • Program XDP nie może zapisać cookie_seed (tajna część soli) w zmiennej globalnej, potrzebujesz pamięci w jądrze, której wartość będzie okresowo aktualizowana z niezawodnego generatora.

  • Jeżeli w pakiecie ACK pasuje plik cookie SYN, nie musisz drukować komunikatu, ale zapamiętaj adres IP zweryfikowanego klienta, aby móc dalej przekazywać od niego pakiety.

Weryfikacja legalnego klienta:

$ sudoip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Connection reset by peer

Z dzienników wynika, że ​​kontrola przebiegła pomyślnie (flags=0x2 - to jest SYN, flags=0x10 jest 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 matches for client 20200c0

Co prawda nie ma listy zweryfikowanych adresów IP, ale przed samym powodzią SYN nie będzie ochrony, ale oto reakcja na powódź ACK uruchomioną następującym poleceniem:

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

Wpisy dziennika:

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

wniosek

Czasami eBPF w ogóle, a XDP w szczególności przedstawiane są bardziej jako zaawansowane narzędzie administratora niż platforma programistyczna. Rzeczywiście, XDP jest narzędziem do zakłócania przetwarzania pakietów przez jądro, a nie alternatywą dla stosu jądra, jak DPDK i inne opcje obejścia jądra. Z drugiej strony XDP pozwala na implementację dość złożonej logiki, którą ponadto można łatwo aktualizować bez przerywania przetwarzania ruchu. Weryfikator nie stwarza dużych problemów, osobiście nie odmówiłbym tego w przypadku części kodu przestrzeni użytkownika.

W drugiej części, jeśli temat będzie ciekawy, uzupełnimy tabelę zweryfikowanych klientów i rozłączeń, zaimplementujemy liczniki oraz napiszemy narzędzie przestrzeni użytkownika do zarządzania filtrem.

Linki:

Źródło: www.habr.com

Dodaj komentarz