piątek, 5 lipca 2019

Bez paniki o "SACK Panic" - analiza CVE-2019-11477


  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:
  1. Nawiązujemy połączenie lub przyjmujemy połączenie od atakowanej maszyny
  2. Ustawiamy wartość MSS na 48 oraz maksymalny parametr window
  3. Inicjujemy wymianę danych (np. wykonując zapytanie do serwera HTTP) i rozpędzamy połączenie w celu podniesienia window
  4. 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.
  5. 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.
  6. 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.

Mam nadzieję, że udało mi się rzucić nieco więcej światła na mechanikę tego problemu. Zachęcam do podzielenia się własnymi spostrzeżeniami, lub wynikami eksperymentów w komentarzach.