Niedawno
zostało odkryte kilka problemów w Linuksowej obsłudze protokołu TCP.
Najpoważniejszy problem o sygnaturze CVE-2019-11477 umożliwia zdalne
zawieszenie systemu Linux i został ochrzczony mianem "SACK Panic". Zanim
przystąpicie do dalszej lektury, warto odrobić pracę domową i najpierw
przeczytać opis samej podatności, po Angielsku tu i tu, po Polsku tutaj, czy zobaczyć patch na jądro.
Wszystkie
większe serwisy podchwyciły ten temat informując o poważnej luce. Z jednej
strony bardzo dobrze, ale to czego w dalszym ciągu brakuje to rzeczywistej
analizy podatności, stopnia trudności i warunkach w jakich jest możliwa do
wykorzystania. Tego typu informacje umożliwią nam identyfikację najbardziej
zagrożonych maszyn w naszej infrastrukturze. Zainteresowany? Więc zapraszam do
lektury.
Na początek
warto opisać pobieżnie jak połączenia TCP są nawiązywane oraz jak wygląda ich obsługa
w Linuksie i nie tylko. Aplikacja chcąca skorzystać a jakiejś usługi, czyli klient,
inicjuje połączenie wysyłając pakiet z flagą SYN oraz ustawionymi opcjami TCP. Opcje
TCP określają parametry nawiązywanego połączenia. Najistotniejszy dla nas, z
punktu widzenia podatności która analizujemy, jest rozmiar MSS (Maximum Segment
Size). Jest to informacja o tym ile danych, liczonych w bajtach, może
maksymalnie zawierać pakiet TCP. Dwie rzeczy są tutaj szczególnie istotne:
- Wartość MSS zawiera w sobie rozmiar danych oraz rozmiar opcji TCP. Stąd, jeżeli rozmiar MSS wynosi 48, to po odjęciu od niego 12 bajtów przeznaczonych dla opcji TIMESTAMP, określającej kiedy pakiet został wysłany, pozostaje już tylko 36 bajtów na dane. Sumarycznie opcje TCP mogą zabrać nie więcej jak 40 bajtów, co zostawia jedynie 8 bajtów na dane w przypadku MSS ustawionego na 48.
- Wartość MSS podana podczas zestawiania połączenia, to tylko informacja z punktu widzenia klienta (bądź serwera), zazwyczaj zależna od parametru MTU karty sieciowej. Żadna ze stron komunikacji nic nie wie o sieciach przez które przechodzą ich pakiety. Dodatkowo obie strony będą dążyć do tego by przesłać jak najwięcej danych w jak najkrótszym czasie. Oznacza to, że ilość wysyłanych danych w pojedynczym pakiecie, będzie cały czas zwiększana aż do momentu zgubienia pakietu. Dopiero wtedy następuje powrót do mniejszej wartości (określonej przez algorytm PMTUD) lub do wartość podanej przy zestawianiu połączenia. Podobnie zresztą rzecz się ma z szybkością nadawania pakietów - będzie zwiększana dotąd aż pakiety nie zaczną być gubione. Podsumowując ten punkt, trzeba wiedzieć, że parametr MSS może się zmieniać nawet w trakcie trwania nawiązanego połączenia.
Kolejny istotny
parametr TCP, to rozmiar okna, mówiący po nadaniu ilu danych, nadający musi
zaczekać aż odbiorca potwierdzi odbiór. Okno również może być zmieniane w
czasie trwania połączenia.
W następnym
etapie, serwer odpowiada pakietem z flagami SYN i ACK, oraz podobnym zestawem
opcji TCP jak klient, ale podanymi z jego punktu widzenia. Ostatecznie klient
odpowiada pakietem ACK, czym potwierdza zestawienie połączenia.
Do zrozumienia
"SACK Panic", trzeba jeszcze wyjaśnić na czym polega mechanizm
TSO (TCP Segment Offload) i GSO (Generic Segmentation Offload), w
którym znajduje się podatność.
Podczas normalnej transmisji system może odbierać i nadawać tysiące pakietów z danymi w bardzo krótkim czasie. Dla każdego takiego pakietu trzeba policzyć sumę kontrolną, sprawdzić poprawność nagłówków itp. Jednym słowem dużo pracy. Z tego powodu od dłuższego czasu, karty sieciowe posiadają układy sprzętowe, które liczą sumy kontrolne oraz łączą małe pakiety w większe, zanim zostaną przekazane do systemu operacyjnego. Łączenie pakietów w większe, jest właśnie mechanizmem TSO. Wykorzystuje to fakt, że zazwyczaj pakiety pojawiają się w kolejności nadawania. Jeżeli karta sieciowa odbierze dane o indeksie od 1 do 10, a następnie w minimalnym odstępie czasu, dane od 11 do 20, to równie dobrze może zachować się tak jakby dostała jeden blok danych, od 1 do 20. W ten sposób zamiast kilku pakietów, system będzie przerabiać tylko jeden.
Po czasie zauważono
również, że łączenie pakietów daje spore benefity wydajnościowe głównie ze
względu na jednokrotne przejście pakietu przez kod obsługujący stos TCP/IP. W
związku z tym powstał programowy mechanizm łączenia pakietów, w jądrze Linuks
znany jako GSO.
To czy
posiadamy włączone tego typu rozwiązania poprawiające wydajność, możemy
zweryfikować wydając polecenie, "ethtool -k <network_interface>".
Przy pomocy tego samego narzędzia, możemy również wyłączyć GSO, co również
ochroni nas przed atakiem „SACK Panic”.
Wracając do
tematu, jeżeli pakiety nie przychodzą w poprawnej kolejności, najczęściej
oznacza to, że jakiś pakiet został po drodze zgubiony. W tym momencie odbiorca
powinien odpowiedzieć nadawcy pakietem potwierdzającym odbiór danych z opcją
TCP SACK. W opcji SACK jest zawarta informacja, które dane dotarły, co
umożliwia ponowną retransmisję tylko niewielkiej, zgubionej porcji danych. W
Linuksie, wszystkie tego typu informacje o danych przesyłanych w ramach
połączenia, jądro trzyma w strukturze sk_buff.
Skoro mamy już podbudowę teoretyczną, to możemy przejść do analizy samego ataku. Z opisu wynika, że możliwe jest przepełnienie 16-bitowej wartości tcp_gso_segs. Wartość ta, to nic innego jak liczba segmentów TCP które zostały połączone w jeden duży blok danych. Czyli ilość danych przechowywanych w strukturze sk_buff, będzie wynosić tcp_gso_segs*tcp_gso_size. Druga wartość (tcp_gso_size), to wartość MSS pomniejszona o rozmiar zarezerwowany dla opcji TCP. Gdy podczas inicjowania połączenia ustawimy najniższy możliwy MSS, czyli 48 bajtów, wartość tcp_gso_size wyniesie 36. Dzieje się tak dlatego, ponieważ 12 bajtów musi zostać zarezerwowane dla opcji TCP TIMESTAMP, która zazwyczaj jest dodawana do każdego transmitowanego pakietu.
Teraz zadajmy
sobie kluczowe pytanie: ile maksymalnie segmentów danych możemy połączyć w
jeden duży blok, czyli jaka jest maksymalna wartość tcp_gso_segs? Jak
możemy przeczytać choćby w tym
komentarzu, struktura sk_buff
może utrzymać 17 fragmentów po 32kB każdy. Zatem by otrzymać odpowiedź na
pytanie, dzielimy tą wartość przez tcp_gso_size. Teraz, w normalnym
przypadku gdy mamy MSS na poziomie 48 bajtów, zostaje 36 bajtów na dane, co
daje maksymalnie nieco ponad 15 tysięcy segmentów. Gdybyśmy jednak zmusili
jądro do wykorzystania pełnego zestawu opcji TCP, na dane pozostanie już tylko
8 bajtów co daje: 17*32768 / 8 = 69632. Otrzymujemy więc liczbę,
którą nijak nie możemy zapisać jako wartość 16-bitową. Nastąpi przepełnienie tcp_gso_segs
co spowoduje błąd jądra.
Czyli wiemy już, że do przeprowadzenia pomyślnego ataku trzeba spełnić takie warunki:
- Połączenie musi transferować przynajmniej 0.5MB danych. Wartość ta wynika z ilości danych które muszą się znaleźć w strukturze sk_buff (69632 * 8)
- Musimy zbudować jeden duży blok danych do retransmisji przy pomocy pakietów SACK
- Ofiara ma włączony mechanizm GSO
- Połączenie z MSS ustawionym na 48 bajtów. Możemy podać podczas zestawiania połączenia lub negocjować później poprzez PMTUD (który domyślnie jest zazwyczaj wyłączony)
- Jądro musi użyć pełnego zestawu opcji TCP
Ostatni warunek
wydaje się najbardziej kuriozalny. W jaki sposób nawiązać połączenie zmuszając
nasz cel do wykorzystania pełnego zestawu opcji TCP? Na początek warto
spojrzeć na fragment funkcji tcp_connect_init:
static void tcp_connect_init(struct sock *sk)
{
const struct dst_entry *dst = __sk_dst_get(sk);
struct tcp_sock *tp = tcp_sk(sk);
__u8 rcv_wscale;
/* We'll fix this up when we get a response from the other end.
* See tcp_input.c:tcp_rcv_state_process case TCP_SYN_SENT.
*/
tp->tcp_header_len = sizeof(struct tcphdr) +
(sysctl_tcp_timestamps ? TCPOLEN_TSTAMP_ALIGNED : 0);
#ifdef CONFIG_TCP_MD5SIG
if (tp->af_specific->md5_lookup(sk, sk))
tp->tcp_header_len += TCPOLEN_MD5SIG_ALIGNED;
#endif
Pole tp->tcp_header_len zawiera rozmiar nagłówka TCP który będzie transmitowany. Wygląda na to, że moglibyśmy spróbować zestawić połączenie z wykorzystaniem przestarzałej już opcji TCP, dodającej hash MD5 z nagłówka oraz danych (RFC2385). Tego typu połączenie jest inicjowane w specjalny sposób poprzez wykonanie kodu podobnego do prezentowanego poniżej:
// enable md5 signatures
struct tcp_md5sig md5;
const char *key = "__SECRET__";
memcpy(&md5.tcpm_addr, &dst_addr, sizeof(dst_addr));
strcpy((char*)md5.tcpm_key, key);
md5.tcpm_keylen = strlen(key);
if ( setsockopt(server_sd, IPPROTO_TCP, TCP_MD5SIG, (void*)&md5, sizeof(struct tcp_md5sig)) < 0 ) {
perror("setsockopt(TCP_MD5SIG)");
exit(1);
}
Jest jednak problem, a nawet dwa. Aby zestawić takie połączenie, serwer oraz klient muszą znać źródłowy oraz docelowy adres IP oraz swoje numery portów, już w momencie tworzenia gniazda sieciowego. Inaczej mówiąc w konfiguracji serwer ma zapisany adres IP oraz port na którym przyjmuje połączenia, oraz adres IP i port z którego klient nawiąże połączenie. Podobnie po stronie klienta. Zatem nie da się zmusić drugą stronę połączenia, do przełączenia się w tego typu tryb pracy, jeżeli usługa nie była wcześniej odpowiednio skonfigurowana.
Drugi problem
jest taki, że opcja TCP_MD5SIG ma "tylko" 20 bajtów.
Sumarycznie dałoby to nam 12+20=32, czyli w dalszym ciągu o 8 bajtów za mało.
Ewidentnie nie tędy droga, chociaż wątek
ten był podnoszony przez badaczy z Chin.
Co nam
pozostaje? Spójrzmy jak jest obliczana wartość MSS dla pakietu który ma zostać
wysłany, funkcja tcp_current_mss:
unsigned int tcp_current_mss(struct sock *sk)
{
const struct tcp_sock *tp = tcp_sk(sk);
const struct dst_entry *dst = __sk_dst_get(sk);
u32 mss_now;
unsigned int header_len;
struct tcp_out_options opts;
struct tcp_md5sig_key *md5;
mss_now = tp->mss_cache;
if (dst) {
u32 mtu = dst_mtu(dst);
if (mtu != inet_csk(sk)->icsk_pmtu_cookie)
mss_now = tcp_sync_mss(sk, mtu);
}
header_len = tcp_established_options(sk, NULL, &opts, &md5) +
sizeof(struct tcphdr);
/* The mss_cache is sized based on tp->tcp_header_len, which assumes
* some common options. If this is an odd packet (because we have SACK
* blocks etc) then our calculated header_len will be different, and
* we have to adjust mss_now correspondingly */
if (header_len != tp->tcp_header_len) {
int delta = (int) header_len - tp->tcp_header_len;
mss_now -= delta;
}
return mss_now;
}
Analizując funkcję powyżej, oraz funkcję tcp_established_options zobaczymy, że pozostaje już tylko zmuszenie ofiary do wysłania w naszą stronę pakietów z opcją SACK. Tak, prawie takich samych, których później musimy użyć do przepełnienia wartości tcp_gso_segs.
Opcja SACK (RFC2018) zawiera dwa
bajty nagłówka, oraz może zawierać opis nawet czterech "dziur" w
strumieniu danych (8 bajtów na jedną). Dodajemy do tego 2 bajty wyrównania oraz
12 bajtów opcji TIMESTAMP i finalnie uzyskamy 40 bajtów przy pomocy trzech
bloków SACK. W takim wypadku wartość MSS wyniesie 8 bajtów. Bingo!
Poniżej
przykład pakietu o jakim mowa, zrzucony przy pomocy tcpdump:
IP (tos 0x0, ttl 64, id 23002, offset 0, flags [DF], proto TCP (6), length 88)
192.168.5.221.80 > 192.168.5.219.45627: Flags [.], cksum 0xcdc7, seq 2857331114:2857331122, ack 145047428, win 231,
options [nop,nop,TS val 3764364609 ecr 3361447027,nop,nop,sack 3 {145047468:145047476}{145047452:145047460}{145047436:145047444}]
, length 8: HTTP
0x0000: 4500 0058 59da 4000 4006 53bd c0a8 05dd E..XY.@.@.S.....
0x0010: c0a8 05db 0050 b23b aa4f 69aa 08a5 3f84 .....P.;.Oi...?.
0x0020: f010 00e7 8d53 0000 0101 080a e05f a541 .....S......._.A
0x0030: c85b 9c73 0101 051a 08a5 3fac 08a5 3fb4 .[.s......?...?.
0x0040: 08a5 3f9c 08a5 3fa4 08a5 3f8c 08a5 3f94 ..?...?...?...?.
0x0050: 4854 5450 2f31 2e31 HTTP/1.1
Wartość MSS dla poszczególnych połączeń możemy podejrzeć przy pomocy polecenia:
ss -ti -o state established
Mowa jednak o kolejce do retransmisji, tak więc niestety w tego typu informacjach o połączeniu nie zobaczymy 8-śmio bajtowej wartości MSS. O skuteczności takiego podejścia można się przekonać dopiero debugując jądro.
Łącząc wszystko
co się dowiedzieliśmy w całość, wygląda na to, że metoda ataku może składać się
z następujących kroków:
- Nawiązujemy połączenie lub przyjmujemy połączenie od atakowanej maszyny
- Ustawiamy wartość MSS na 48 oraz maksymalny parametr window
- Inicjujemy wymianę danych (np. wykonując zapytanie do serwera HTTP) i rozpędzamy połączenie w celu podniesienia window
- Wysyłamy pakiety danych z odpowiednio spreparowanym numerem sekwencyjnym, by zasymulować utratę trzech pakietów podczas transmisji. Zmuszamy tym samym ofiarę do dopełnienia swoich pakietów opcją SACK.
- Połączenie musi być na tyle "rozpędzone" by ofiara wysłała do nas jeszcze ~0.5MB danych zanim zamilknie ze względu na przekroczenie rozmiaru okna.
- Potwierdzamy napływające dane od ofiary własnymi pakietami SACK
Największym
problemem wydaje się na tym etapie utrzymanie połączenia wystarczająco długo by
zbudować kolejkę retransmisji z odpowiednią ilością danych.