SYN/ACK-Retries unter Linux mit eBPF messen

Wir sind häufig DDoS-Angriffen ausgesetzt. Ein SYN-Flood ist eine der ältesten Techniken, die dabei verwendet werden: der Angreifer sendet zahlreiche SYN-Pakete mit gespooften IP-Absenderadressen.

Dabei wird ausgenutzt, dass der TCP-Handshake aus mehreren Schritten besteht:

  • Der Client sendet ein SYN-Paket
  • Der Server antwortet mit einem SYN/ACK-Paket
  • Der Client antwortet mit einem ACK-Paket

Der Empfänger muss nicht nur für jedes erhaltene Paket einen State in seinem TCP-Stack aufrecht erhalten, sondern sendet im Angriffsfall mehrmals das SYN/ACK-Antwortpaket auf das SYN. Bei einem SYN-Flood wird es aber natürlich niemals eine Antwort auf das SYN/ACK geben.

Bei Linux werden standardmäßig bis zu 6 SYN/ACK-Pakete (1 initial + 5 Retries) auf ein einzelnes SYN geantwortet. Währenddessen bleibt der TCP-State erhalten und Connection-Tracking-States in Paketfiltern werden durch jedes neue SYN/ACK aktuell gehalten, wodurch sie ebenfalls nicht ablaufen können. Zusätzlich erzeugen wir unnötigen ausgehenden Traffic, der dann bei den gespooften Source-Adressen ankommt.

Maßnahmen gegen SYN-Floods sind z.B. einfach mehr offene States zu erlauben oder gar keinen State zu tracken, indem man SYN-Cookies verwendet. Mehr States kosten natürlich mehr Ressourcen und SYN-Cookies funktionieren nicht mit allen Protokollen oder in allen Fällen.

Wir haben uns aber zusätzlich noch einen anderen Ansatz angeschaut: Die Menge der SYN/ACK-Retries zu reduzieren.

Wie viele Retries brauchen wir?

Natürlich haben die SYN/ACK-Retries im TCP-Stack eine sinnvolle Funktion: wenn ein einzelnes SYN/ACK verloren geht, hilft der Retry, dass trotzdem eine Verbindung zustande kommt. Bevor wir also die Konfiguration ändern, sollten wir herausfinden, wie viele Retries wir üblicherweise brauchen bis eine Verbindung zustande kommt (wenn der Client ein ACK schickt).

Eine Möglichkeit Netzwerkpakete zu erfassen ist, den Traffic mitzulesen, z.B. mit pcap-basierten Tools wie tcpdump. Das kann aber Performance kosten und dann müssten wir noch einen Weg finden, die mitgezeichneten Pakete statistisch auszuwerten.

bpftrace

Eine coolere Lösung ist das Tool bpftrace zu benutzen. bpftrace hat eine Awk-ähnliche DSL (Domain Specific Language) zum Programmieren von Tracing-Tools für Linux. bpftrace kompiliert das Programm in eBPF-Code, welcher dann im Kernel an zahlreichen Stellen eingebunden werden kann. bpftrace übernimmt auch komplett automatisch den Datenaustausch von Kernel zu Userspace. Zusätzlich bringt es Features mit, welche die statistische Auswertung erleichtern, z.B. Histogramme.

Der folgende bpftrace-Code zählt pro TCP-Tuple (Source-IP, Source-Port, Destination-IP, Destination-Port) die Menge der Retries bis eine Verbindung zustande kommt und trägt diese Daten in ein Histogramm ein.

#!/usr/bin/env bpftrace

// Dieses Tool zählt Retransmits von TCP SYN/ACK.
// @established_after_retransmits ist ein Histogramm, wie viele Retransmits gesendet wurden,
// bis der TCP-Handshake abgeschlossen war.

#include <linux/tcp.h>
#include <net/tcp_states.h>

tracepoint:sock:inet_sock_set_state {
	if (args->protocol != IPPROTO_TCP) {
		return;
	}

	if (args->family != AF_INET) {
		return;
	}

	if (args->oldstate == TCP_SYN_RECV) {
		// Handshake abgeschlossen oder abgebrochen
		$retransmits = @retransmits[ntop(args->saddr), args->sport, ntop(args->daddr), args->dport];
		delete(@retransmits[ntop(args->saddr), args->sport, ntop(args->daddr), args->dport]);

		if (args->newstate == TCP_ESTABLISHED) {
			@established_after_retransmits = lhist($retransmits, 0, 10, 1);
		}

		// Hier müsste man eigentlich Sockets messen können, die nach SYN_RECV in CLOSE übergehen,
		// weil die maximale Menge an Retransmits vorbei war.
		// Das hat in Tests aber nicht funktioniert und es scheint auch sonst keinen Hook zu geben,
		// über den man das Schließen des Sockets erkennen könnte.
	}
}

tracepoint:tcp:tcp_retransmit_synack {
	// Wir benutzen das Tuple (Src/Dst-IP/Port) als Key für die Verbindungen,
	// weil der Socket-Pointer zwischen diesem Tracepoint und sock:inet_sock_set_state
	// nicht zusammenpasst und daher nicht verwendet werden kann.
	@retransmits[ntop(args->saddr), args->sport, ntop(args->daddr), args->dport]++;

	if (@retransmits[ntop(args->saddr), args->sport, ntop(args->daddr), args->dport] > 5) {
		// Limit ist (standardmäßig) 5, kann also nicht sein -> Messfehler.
		// (Sockets, die Linux aufgibt, können wir nicht messen (siehe oben wegen CLOSE),
		// und daher bleibt der Retransmit-Counter erhalten, weil wir bpftrace auch nicht
		// Werte in Maps expiren können.
		// Wenn die gleiche Src/Dst-IP/Port-Kombi danach wiederverwendet wird, dann kann der
		// Counter hier über das Maximum gehen.
		@retransmits_too_high++;
		delete(@retransmits[ntop(args->saddr), args->sport, ntop(args->daddr), args->dport]);
	}
}

interval:s:10 {
	print(@established_after_retransmits);
	print(@retransmits_too_high);
}

Der Code reagiert auf verschiedene Events: tracepoint:sock:inet_sock_set_state und tracepoint:tcp:tcp_retransmit_synack sind Tracepoints, eine stabile API des Kernels für Tracing-Schnittstellen. inet_sock_set_state wird ausgelöst wenn ein Socket seinen Status ändert und tcp_retransmit_synack wenn ein SYN/ACK-Retry stattfindet.

interval:s:10 sorgt dafür, dass alle 10 Sekunden die aktuellen Daten ausgegeben werden. Standardmäßig erzeugt bpftrace keine Ausgabe, bis man das Programm beendet – erst dann werden alle gesammelten Daten angezeigt.

Ergebnisse

Nach 1h Ausführung des Programms auf einem unseren Loadbalancer hatten wir diese Ergebnisse:

@established_after_retransmits:
[0, 1)            216379 |@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@|
[1, 2)               318 |                                                    |
[2, 3)               132 |                                                    |
[3, 4)                45 |                                                    |
[4, 5)                17 |                                                    |
[5, 6)                12 |                                                    |

Die Schreibweise [0, 1) bedeutet hier „von 0 bis <1“, also nur 0. D.h.:

  • 216379 Verbindungen wurden ohne Retries aufgebaut
  • 318 brauchten 1 Retry
  • usw.

Man kann hier deutlich sehen, dass die Menge an Verbindungen, bei denen die Retries tatsächlich ausgereizt wurden, sehr gering sind. Da das bpftrace-Programm abgebrochene Verbindungen nicht zuverlässig messen kann (siehe Code-Kommentare), kann es sogar sein, dass die 12 Verbindungen hier gar nicht zu Stande kamen, sondern ein Messfehler sind.

Die Retries erfolgen mit einem exponentiellen Backoff, so dass der letzte Retry erst 32s nach dem SYN gesendet wird. Bei interaktiven Clients wie Browsern wird der Anwender vorher schon längst aufgegeben haben.

Fazit

Die Messergebnisse haben uns darin bestätigt, dass wir die SYN/ACK-Retries mit wenig Risiko von 5 auf 4 reduzieren können. Zusätzlich haben wir die TTL der Conntrack-Einträge reduziert:

net.ipv4.tcp_synack_retries=4
net.netfilter.nf_conntrack_tcp_timeout_syn_recv=20

Die Lebensdauer eines Conntrack-Eintrags für ein gespooftes SYN reduziert sich dadurch von 92s auf 36s. Das bedeutet bei einem (anhaltenden) SYN-Flood 60% weniger Conntrack-Einträge.