Scriviamo una protezione contro gli attacchi DDoS su XDP. Parte centrale

La tecnologia eXpress Data Path (XDP) consente di eseguire una elaborazione arbitraria del traffico sulle interfacce Linux prima che i pacchetti entrino nello stack di rete del kernel. L'applicazione di XDP è la protezione contro gli attacchi DDoS (CloudFlare), filtri complessi, raccolta di statistiche (Netflix). I programmi XDP vengono eseguiti da una macchina virtuale eBPF, quindi hanno limitazioni sia sul codice che sulle funzioni kernel disponibili a seconda del tipo di filtro.

L'articolo è destinato a colmare le lacune di numerosi materiali su XDP. In primo luogo, forniscono codice pronto che elude immediatamente le caratteristiche di XDP: è preparato per la verifica o troppo semplice per causare problemi. Nel tentativo di scrivere il proprio codice da zero, non c'è comprensione su come affrontare gli errori caratteristici. In secondo luogo, non vengono trattati i metodi per testare localmente XDP senza VM e 'hardware', nonostante abbiano le loro 'insidie'. Il testo è rivolto a programmatori che conoscono le reti e Linux, interessati a XDP e eBPF.

In questa sezione esamineremo in dettaglio come viene assemblato un filtro XDP e come testarlo, quindi scriveremo una versione semplice del noto meccanismo SYN cookies a livello di elaborazione dei pacchetti. Per ora non creeremo un 'elenco bianco'
di clienti verificati, gestire contatori e controllare il filtro – bastano i log.

Scriveremo in C – non è di moda, ma è pratico. Tutto il codice è disponibile su GitHub tramite il link in fondo ed è suddiviso in commit per fasi descritte nell'articolo.

Disclaimer. Nel corso dell'articolo verrà sviluppata una mini-soluzione per riflettere gli attacchi DDoS, poiché è un compito realistico per XDP e il mio campo. Tuttavia, l'obiettivo principale è comprendere la tecnologia, non è una guida alla creazione di una protezione pronta. Il codice didattico non è ottimizzato e omette alcune sfumature.

Panoramica rapida di XDP

Esporrò solo i punti chiave, per non duplicare la documentazione e gli articoli esistenti.

Quindi, viene caricato nel kernel il codice del filtro. I pacchetti in ingresso vengono passati al filtro. Alla fine, il filtro deve prendere una decisione: far passare il pacchetto nel kernel (XDP_PASS), scartare il pacchetto (XDP_DROP) o rispedirlo indietro (XDP_TX). Il filtro può modificare il pacchetto, questo è particolarmente pertinente per XDP_TX. È anche possibile interrompere forzatamente il programma (XDP_ABORTED) e scartare il pacchetto, ma questo è l'equivalente di assert(0) – per il debug.

La macchina virtuale eBPF (extended Berkley Packet Filter) è progettata per essere semplice, in modo che il kernel possa verificare che il codice non entri in ciclo e non danneggi la memoria altrui. Limitazioni e controlli complessivi:

  • I cicli (retrocessi) sono vietati.
  • C'è uno stack per i dati, ma non ci sono funzioni (tutte le funzioni C devono essere incorporate).
  • È vietato accedere alla memoria al di fuori dello stack e del buffer del pacchetto.
  • La dimensione del codice è limitata, ma in pratica ciò non è molto significativo.
  • È consentito chiamare solo funzioni speciali del kernel (eBPF helpers).

Lo sviluppo e l'installazione del filtro appaiono così:

  1. Il codice sorgente (ad esempio, kernel.c) viene compilato in un oggetto (kernel.o) per l'architettura della macchina virtuale eBPF. A ottobre 2019, la compilazione in eBPF è supportata da Clang e promessa in GCC 10.1.
  2. Se in questo codice oggetto ci sono riferimenti alle strutture del kernel (ad esempio, a tabelle e contatori), al posto dei loro ID ci sono zeri, quindi non è possibile eseguire tale codice. Prima del caricamento nel kernel, è necessario sostituire questi zeri con gli ID di oggetti specifici, creati tramite chiamate al kernel (collegare il codice). Si può fare con utilità esterne o scrivendo un programma che linkerà e caricherà un filtro specifico.
  3. Il kernel verifica il programma da caricare. Controlla l'assenza di cicli e il non superamento dei confini del pacchetto e dello stack. Se il verificatore non riesce a dimostrare che il codice è corretto, il programma viene rifiutato, quindi bisogna saperlo blandire.
  4. Dopo una verifica riuscita, il kernel compila il codice oggetto dell'architettura eBPF in codice macchina dell'architettura del sistema (just-in-time).
  5. Il programma viene ancorato all'interfaccia e inizia a trattare i pacchetti.

Poiché XDP funziona nel kernel, il debug è svolto tramite registri di tracciamento e, di fatto, dai pacchetti che il programma filtra o genera. Tuttavia, eBPF garantisce la sicurezza del codice caricato per il sistema, quindi è possibile sperimentare con XDP direttamente su un Linux locale.

Preparazione dell'ambiente

Compilazione

Clang non può generare direttamente il codice oggetto per l'architettura eBPF, quindi il processo consiste di due passaggi:

  1. Compilare il codice C in bytecode LLVM (clang -emit-llvm).
  2. Convertire il bytecode in codice oggetto eBPF (llc -march=bpf -filetype=obj).

Quando si scrive un filtro, è utile avere un paio di file con funzioni e macro di supporto dai test del kernel. È importante che siano compatibili con la versione del kernel (KVER). Scarichiamoli 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 per 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 contiene il percorso agli header del kernel, ARCH — l'architettura del sistema. I percorsi e gli strumenti possono variare leggermente tra le distribuzioni.

Esempio di differenze per 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 includono la directory con gli header ausiliari e diverse directory con gli header del kernel. Il simbolo __KERNEL__ indica che gli header UAPI (userspace API) sono definiti per il codice del kernel, poiché il filtro viene eseguito nel kernel.

La protezione dello stack può essere disabilitata (-fno-stack-protector), poiché il verificatore del codice eBPF controlla comunque i limiti dello stack. È importante abilitare immediatamente le ottimizzazioni, poiché la dimensione del bytecode eBPF è limitata.

Iniziamo con un filtro che consente a tutti i pacchetti di passare e non fa nulla:

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

Team make compila xdp_filter.o. Dove possiamo testarlo ora?

Banchina di test

Il banco di prova deve includere due interfacce: quella su cui sarà presente il filtro e quella dalla quale verranno inviati i pacchetti. Devono essere dispositivi Linux completi con i propri IP, per verificare come le normali applicazioni operano con il nostro filtro.

Dispositivi di tipo veth (Ethernet virtuale) vanno bene: sono una coppia di interfacce di rete virtuali "collegate" direttamente tra loro. Puoi crearle così (in questa sezione tutti i comandi ip vengono eseguiti da root):

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

Qui xdp-remotexdp-local — i nomi dei dispositivi. Su xdp-local (192.0.2.1/24) sarà collegato il filtro, mentre xdp-remote (192.0.2.2/24) inoltrerà il traffico in ingresso. Tuttavia, c'è un problema: le interfacce si trovano sulla stessa macchina, e Linux non invierà il traffico su una di esse attraverso l'altra. Questo può essere risolto con regole ingegnose, iptablesma dovranno modificare i pacchetti, il che è scomodo durante il debug. È meglio utilizzare gli spazi dei nomi di rete (network namespaces, in seguito netns).

Lo spazio dei nomi di rete contiene un insieme di interfacce, tabelle di routing e regole NetFilter, isolate da oggetti simili in altri netns. Ogni processo opera in uno spazio dei nomi specifico e ha accesso solo agli oggetti di quel netns. Per impostazione predefinita, nel sistema esiste un unico spazio dei nomi di rete per tutti gli oggetti, quindi è possibile lavorare in Linux senza sapere nulla di netns.

Creiamo un nuovo spazio dei nomi xdp-test e spostiamolo lì xdp-remote.

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

Quindi, il processo in esecuzione in xdp-test, non vedrà xdp-local (rimarrà nel netns predefinito) e quando invia un pacchetto a 192.0.2.1 lo inoltrerà tramite xdp-remote, perché è l'unica interfaccia in 192.0.2.0/24 disponibile per questo processo. Lo stesso vale anche al contrario.

Quando si sposta tra netns, l'interfaccia viene rimossa e perde l'indirizzo. Per configurare l'interfaccia in netns, è necessario eseguire ip ... in questo spazio dei nomi della riga di comando 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

Come si può vedere, non è diverso dalla configurazione xdp-local nello spazio dei nomi predefinito:

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

Se si esegue tcpdump -tnevi xdp-local, si può vedere che i pacchetti inviati da xdp-test, vengono recapitati a questa interfaccia:

ip netns exec xdp-test   ping 192.0.2.1

È comodo avviare una shell in xdp-test. Nel repository c'è uno script che automatizza il lavoro con il banco di prova, ad esempio, è possibile impostare il banco di prova con il comando sudo ./stand up e rimuoverlo sudo ./stand down.

Tracciamento

Il filtro viene collegato al dispositivo in questo modo:

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

Chiave -force è necessario per collegare un nuovo programma se uno è già collegato. «No news is good news» non si applica a questo comando, l'output è voluminoso in ogni caso. Specificare verbose non è obbligatorio, ma fornisce un rapporto sul funzionamento del verificatore di codice con l'elenco in assembly:

Analisi del Verificatore:

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

Scollegare il programma dall'interfaccia:

ip link set dev xdp-local xdp off

In questo script ci sono comandi sudo ./stand attachsudo ./stand detach.

Collegando il filtro, si può verificare che ping continua a funzionare, ma la programma funziona? Aggiungiamo dei log. La funzione bpf_trace_printk() è simile a printf(), ma supporta solo fino a tre argomenti, oltre al modello, e un elenco limitato di specificatori. Il macro bpf_printk() semplifica la chiamata.

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

L'output va nel canale di tracciamento del kernel, che deve essere attivato:

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

Visualizzazione del flusso di messaggi:

cat /sys/kernel/debug/tracing/trace_pipe

Entrambi questi comandi fanno una chiamata sudo ./stand log.

Ping ora dovrebbe generare messaggi come questi:

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

Se si osserva l'output del verificatore, si possono notare calcoli strani:

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

Il punto è che i programmi su eBPF non hanno una sezione dati, pertanto l'unico modo per codificare una stringa formattata sono gli argomenti immediati delle istruzioni della VM:

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

Per questa ragione, l'output di debug gonfia notevolmente il codice finale.

Invio di pacchetti XDP

Modifichiamo il filtro: facciamo in modo che restituisca tutti i pacchetti in ingresso. Questo non è corretto dal punto di vista della rete, poiché sarebbero stati necessari cambiamenti negli indirizzi nei pacchetti, ma al momento è importante che funzioni in linea di principio.

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

Avviamo tcpdump in xdp-remote. Dovrebbe mostrare richieste ICMP Echo in uscita e in ingresso identiche e smettere di mostrare le risposte ICMP Echo. Ma non lo fa. Risulta che per farlo XDP_TX in un programma su xdp-local è necessario, affinché l'interfaccia corrispondente xdp-remote abbia anch'essa assegnato un programma, anche se vuoto, e fosse attivata.

Come l'ho scoperto?

Monitorare il percorso del pacchetto nel kernel è possibile grazie al meccanismo degli eventi perf, che, tra l'altro, utilizza la stessa macchina virtuale, quindi per le indagini su eBPF si utilizza eBPF.

Devi fare il bene dal male, perché non c'è altro modo per farlo.

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

Che cos'è il codice 6?

$ errno 6
ENXIO 6 Nessun dispositivo o indirizzo esistente

La funzione veth_xdp_flush_bq() riceve un codice di errore da veth_xdp_xmit(), dove cerchiamo ENXIO e troviamo il commento.

Ripristiniamo il filtro minimale (XDP_PASS) nel file xdp_dummy.c, aggiungiamolo a Makefile, colleghiamolo a xdp-remote:

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

Ora tcpdump mostra ciò che ci si aspetta:

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

Se al suo posto vengono mostrati solo ARP, è necessario rimuovere i filtri (questo fa sudo ./stand detach), passare ping, quindi impostare i filtri e provare di nuovo. Il problema è che il filtro XDP_TX si applica anche a ARP, e se lo stack
delle namespace xdp-test ha "dimenticato" l'indirizzo MAC 192.0.2.1, non sarà in grado di risolvere questo IP.

Definizione del compito

Passiamo all'argomento dichiarato: scrivere un meccanismo SYN cookies su XDP.

Fino ad ora, un attacco DDoS popolare rimane il SYN flood, la cui essenza è la seguente. Durante l'instaurazione di una connessione (TCP handshake), il server riceve un SYN, assegna risorse per la futura connessione, risponde con un pacchetto SYNACK e attende un ACK. L'attaccante invia semplicemente pacchetti SYN da indirizzi falsi in migliaia al secondo da ogni host di un vasto botnet. Il server è costretto ad assegnare risorse immediatamente all'arrivo del pacchetto e le libera solo dopo un lungo timeout, il risultato è che la memoria o i limiti si esauriscono, nuove connessioni non vengono accettate, il servizio non è disponibile.

Se non si assegnano risorse per ogni pacchetto SYN, ma si risponde solo con un pacchetto SYNACK, come può il server capire che il pacchetto ACK, arrivato dopo, si riferisce al pacchetto SYN che non è stato salvato? Infatti l'attaccante può generare anche ACK falsi. L'essenza del SYN cookie è quella di codificare in seqnum i parametri di connessione come un hash degli indirizzi, delle porte e di un sale variabile. Se l'ACK è riuscito ad arrivare prima del cambio del sale, è possibile calcolare nuovamente l'hash e confrontarlo con acknum. L'attaccante non può falsificarlo, poiché il sale include un segreto, e non riuscirà a tentare un attacco a causa della banda limitata. acknum Il SYN cookie è stato a lungo implementato nel kernel Linux e può addirittura attivarsi automaticamente se i SYN arrivano troppo rapidamente e in massa.

Breve guida sul TCP handshake

TCP garantisce la trasmissione dei dati come flusso di byte, ad esempio, le richieste HTTP vengono trasferite sopra TCP. Il flusso viene trasmesso in pezzi all'interno di pacchetti. Tutti i pacchetti TCP hanno segnali logici e numeri di sequenza a 32 bit:

TCP garantisce la trasmissione dei dati come un flusso di byte; ad esempio, le richieste HTTP vengono inviate tramite TCP. Il flusso viene trasmesso a pezzi in pacchetti. Tutti i pacchetti TCP hanno flag logici e numeri di sequenza a 32 bit:

  • La combinazione dei flag determina il ruolo di un pacchetto specifico. Il flag SYN indica che questo è il primo pacchetto del mittente nella connessione. Il flag ACK indica che il mittente ha ricevuto tutti i dati della connessione fino al byte acknum. Un pacchetto può avere più flag ed è denominato in base alla loro combinazione, ad esempio, pacchetto SYNACK.

  • Il numero di sequenza (seqnum) determina lo spostamento nel flusso di dati per il primo byte trasmesso in questo pacchetto. Ad esempio, se nel primo pacchetto con X byte di dati questo numero era N, nel successivo pacchetto con nuovi dati sarà N+X. All'inizio della connessione, ogni parte sceglie questo numero in modo casuale.

  • Il numero di riconoscimento (acknum) è lo stesso spostamento del seqnum, ma determina non il numero del byte trasmesso, ma il numero del primo byte da parte del ricevente che il mittente non ha visto.

All'inizio della connessione, le parti devono accordarsi seqnumacknum. Il client invia un pacchetto SYN con il suo seqnum = X. Il server risponde con un pacchetto SYNACK, nel quale scrive il suo seqnum = Y e imposta acknum = X + 1. Il client risponde al SYNACK con un pacchetto ACK, dove seqnum = X + 1, acknum = Y + 1. Dopo di che inizia effettivamente il trasferimento dei dati.

Se il destinatario non conferma la ricezione del pacchetto, TCP lo invia nuovamente entro il timeout.

Perché le SYN cookies non vengono utilizzate sempre?

In primo luogo, se il SYNACK o l'ACK vengono persi, bisogna attendere il ritrasmissione, ciò rallenta l'instaurazione della connessione. In secondo luogo, nel pacchetto SYN – e solo in esso! – vengono trasmessi una serie di opzioni che influenzano il funzionamento successivo della connessione. Non memorizzando i pacchetti SYN in entrata, il server ignora così queste opzioni, nei pacchetti successivi il client non le invierà più. TCP può comunque funzionare, ma almeno in una fase iniziale la qualità della connessione diminuirà.

Dal punto di vista dei pacchetti, il programma XDP deve fare quanto segue:

  • rispondere con SYNACK e cookie a SYN;
  • rispondere con RST (interrompere la connessione) a ACK;
  • scartare i pacchetti rimanenti.

Pseudocodice dell'algoritmo insieme all'analisi del pacchetto:

Se non è Ethernet,
    salta il pacchetto.
Se non è IPv4,
    salta il pacchetto.
Se l'indirizzo è nella tabella dei verificati,               (*)
        riduci il contatore delle verifiche rimanenti,
        salta il pacchetto.
Se non è TCP,
    resetta il pacchetto.     (**)
Se è SYN,
    rispondi con SYN-ACK e cookie.
Se è ACK,
    se in acknum non c'è il cookie,
        resetta il pacchetto.
    Aggiungi alla tabella l'indirizzo con N verifiche rimanenti.    (*)
    Rispondi RST.   (**)
Negli altri casi resetta il pacchetto.

Uno (*) sono contrassegnati i punti in cui è necessario gestire lo stato del sistema—nella prima fase si può fare a meno di essi, implementando semplicemente il handshake TCP con la generazione del SYN cookie come seqnum.

Al momento (**), finché non abbiamo una tabella, salteremo il pacchetto.

Implementazione del handshake TCP

Analisi del pacchetto e verifica del codice

Avremo bisogno delle strutture degli header di rete: Ethernet (uapi/linux/if_ether.h), IPv4 (uapi/linux/ip.h) e TCP (uapi/linux/tcp.h). L'ultimo non sono riuscito a collegarlo a causa di errori legati a atomic64_t, ho dovuto copiare le definizioni necessarie nel codice.

Tutte le funzioni, scritte in C per comodità di lettura, devono essere incorporate nel punto di chiamata, poiché il verificatore eBPF nel kernel vieta i salti all'indietro, cioè, di fatto, cicli e chiamate di funzione.

#define INTERNAL static __attribute__((always_inline))

Macro LOG() disattiva la stampa nella build di rilascio.

Il programma è una pipeline di funzioni. Ogni funzione riceve un pacchetto, in cui è presente l'intestazione del livello corrispondente, per esempio, process_ether() si aspetta che sia impostato ether. In base all'analisi dei campi, la funzione può inviare il pacchetto a un livello superiore. Il risultato dell'operazione della funzione è l'azione XDP. Finora i gestori SYN e ACK stanno saltando tutti i pacchetti.

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; /* pacchetto malformato */
    }

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

Faccio notare i controlli contrassegnati con A e B. Se commento A, il programma si compilerà, ma al caricamento ci sarà un errore di verifica:

Analisi del verficatore:


11: (7b) *(u64 *)(r10 -48) = r1
12: (71) r3 = *(u8 *)(r7 +13)
accesso non valido al pacchetto, off=13 size=1, R7(id=0,off=0,r=0)
L'offset R7 è fuori dal pacchetto
el processing ha eseguito 11 istruzioni (limite 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0

Errore nel recupero del programma/mappa!

Stringa chiave accesso non valido al pacchetto, off=13 size=1, R7(id=0,off=0,r=0): esistono percorsi di esecuzione in cui il tredicesimo byte dall'inizio del buffer si trova al di fuori del pacchetto. Dallo listing è abbastanza difficile capire a quale riga si fa riferimento, ma c'è il numero dell'istruzione (12) e il disassemblatore che mostra le righe del codice sorgente:

llvm-objdump -S xdp_filter.o | less

In questo caso indica la riga

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

da cui è chiaro che il problema è in ether. Sarebbe sempre così.

Risposta a SYN

L'obiettivo in questa fase è formare un pacchetto SYNACK corretto con un seqnum, che in futuro sarà sostituito da un cookie SYN. Tutte le modifiche avvengono in process_tcp_syn() e nei dintorni.

Controllo del pacchetto

Stranamente, questa è la riga più notevole, anzi, il commento ad essa:

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

Nella scrittura della prima versione del codice è stato utilizzato il kernel 5.1, per il quale c'era una differenza tra data_end(const void*)ctx->data_end. Nella scrittura dell'articolo, il kernel 5.3.1 non aveva questo problema. È possibile che il compilatore trattasse una variabile locale in modo diverso rispetto a un campo. La morale è che in presenza di un'elevata nidificazione, semplificare il codice può aiutare.

Successivamente, i controlli di routine sulle lunghezze in onore del verificatore; riguardo a MAX_CSUM_BYTES sotto.

const u32 ip_len = ip->ihl * 4;
if ((void*)ip + ip_len > data_end) {
    return XDP_DROP; /* pacchetto malformato */
}
if (ip_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* limitazione dell'implementazione */
}

const u32 tcp_len = tcp->doff * 4;
if ((void*)tcp + tcp_len > (void*)ctx->data_end) {
    return XDP_DROP; /* pacchetto malformato */
}
if (tcp_len > MAX_CSUM_BYTES) {
    return XDP_ABORTED; /* limitazione dell'implementazione */
}

Rivoltare il pacchetto

Compiliamo seqnum e acknum, impostiamo l'ACK (il SYN è già impostato):

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

Invertiamo le porte TCP, gli indirizzi IP e i MAC. La libreria standard non è accessibile dal programma XDP, quindi memcpy() è un macro che nasconde l'intrinseco di 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);

Ricalcolo dei checksum

Le somme di controllo IPv4 e TCP richiedono di sommare tutte le parole a 16 bit negli header, e la dimensione degli header è registrata in essi, ossia al momento della compilazione è sconosciuta. Questo è un problema, perché il verificatore non passerà un ciclo normale fino al confine della variabile. Tuttavia, la dimensione degli header è limitata: fino a 64 byte ciascuno. Si può fare un ciclo con un numero fisso di iterazioni, che può terminare in anticipo.

Nota che ci sono RFC 1624 su come ricalcolare la somma di controllo parzialmente, se solo le parole fisse dei pacchetti sono state modificate. Tuttavia, il metodo non è universale, e sarebbe più difficile supportare l'implementazione.

Funzione per il calcolo della somma di controllo:

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

Nonostante ciò che size è verificato dal codice chiamante, la seconda condizione di uscita è necessaria affinché il verificatore possa dimostrare il completamento del ciclo.

Per le parole a 32 bit è stata implementata una versione più semplice:

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

Ecco il ricalcolo delle somme di controllo e l'invio del pacchetto indietro:

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;

La funzione carry() trasforma una somma a 32 bit di parole a 16 bit in una somma di controllo, secondo la RFC 791.

Verifica dell'handshake TCP

Il filtro stabilisce correttamente una connessione con netcat, saltando l'ACK finale, a cui Linux ha risposto con un pacchetto RST, poiché lo stack di rete non riceveva SYN — è stato trasformato in SYNACK e reinviato — e dal punto di vista del SO, è arrivato un pacchetto non relativo a connessioni aperte.

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Connessione reimpostata dal peer

È importante testare precisamente con applicazioni complete e osservare tcpdump in xdp-remote perché, ad esempio, hping3 non reagisce a somma di controllo non valide.

Dal punto di vista di XDP, la verifica stessa è banale. L'algoritmo di calcolo è primitivo e probabilmente vulnerabile a un aggressore sofisticato. Il kernel Linux, ad esempio, utilizza il SipHash crittografico, ma la sua implementazione per XDP esula chiaramente dal tema dell'articolo.

Sono emerse nuove TODO relative all'interazione esterna:

  • Il programma XDP non può memorizzare cookie_seed (parte segreta del sale) in una variabile globale, è necessaria una memorizzazione nel kernel, il cui valore verrà aggiornato periodicamente da un generatore affidabile.

  • In caso di corrispondenza del cookie SYN nel pacchetto ACK, non è necessario stampare un messaggio, ma bisogna ricordare l'IP del cliente verificato, affinché i pacchetti provenienti da esso possano essere successivamente autorizzati.

Verifica da parte di un cliente legittimo:

$ sudo ip netns exec xdp-test   nc -nv 192.0.2.1 6666
192.0.2.1 6666: Connessione ripristinata dal peer

Nei log è stata registrata la verifica passata (flags=0x2 — questo è SYN, flags=0x10 — questo è 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 corrisponde per il cliente 20200c0

Finché non ci sarà un elenco di IP verificati, non ci sarà protezione contro le inondazioni SYN, ma ecco la reazione a un'inondazione ACK, lanciata con questo comando:

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

Registrazioni nel log:

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

Conclusione

A volte eBPF in generale e XDP in particolare vengono visti più come strumenti per amministratori avanzati piuttosto che come piattaforme per lo sviluppo. Infatti, XDP è uno strumento di intervento nella manipolazione dei pacchetti da parte del kernel, e non un'alternativa allo stack kernel, come DPDK e altre varianti di bypass del kernel. D'altra parte, XDP consente di implementare logiche piuttosto complesse, che tra l'altro sono facilmente aggiornabili senza pause nel trattamento del traffico. Il verificatore non rappresenta un grande problema, personalmente non rinuncerei a uno strumento del genere per parti del codice userspace.

Nella seconda parte, se il tema interessa, completeremo la tabella dei clienti verificati e delle disconnessioni, implementeremo contatori e scriveremo un'utilità userspace per gestire il filtro.

Link:

Fonte: habr.com

Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server 🔥 Acquista hosting affidabile per siti web con protezione DDoS, VPS VDS server | ProHoster