
Cześć wszystkim! Nazywam się Dmitrij Samsonow, pracuję jako główny administrator systemów w «Odnoklassnikach». Mamy ponad 7 tys. fizycznych serwerów, 11 tys. kontenerów w naszej chmurze oraz 200 aplikacji, które w różnych konfiguracjach tworzą 700 różnych klastrów. Przeważająca większość serwerów działa pod kontrolą CentOS 7.
14 sierpnia 2018 roku opublikowano informacje o podatności FragmentSmack.
() i SegmentSmack (). Są to podatności z wektorem ataku sieciowego i dość wysoką oceną (7.5), które grożą odmową usługi (DoS) z powodu wyczerpania zasobów (CPU). Łatka dla jądra dla FragmentSmack wówczas nie została zaproponowana, co więcej, wyszła znacznie później po publikacji informacji o podatności. Aby usunąć SegmentSmack, zalecano aktualizację jądra. Sam pakiet aktualizacyjny został wydany tego samego dnia, wystarczyło go tylko zainstalować.
Nie, wcale nie mamy nic przeciwko aktualizacji jądra! Jednak są pewne niuanse...
Jak aktualizujemy jądro na produkcji
W zasadzie nic skomplikowanego:
- Pobrać pakiety;
- Zainstalować je na określonej liczbie serwerów (w tym na serwerach obsługujących naszą chmurę);
- Upewnić się, że nic się nie zepsuło;
- Upewnić się, że wszystkie standardowe ustawienia jądra zastosowały się bez błędów;
- Poczekać kilka dni;
- Sprawdzić wskaźniki serwerów;
- Przełączyć wdrożenie nowych serwerów na nowe jądro;
- Zaktualizować wszystkie serwery w centrach danych (jedno centrum danych na raz, aby zminimalizować wpływ na użytkowników w przypadku problemów);
- Zrestartować wszystkie serwery.
Powtórzyć dla wszystkich gałęzi posiadanych przez nas jąder. Na dzień dzisiejszy są to:
- Standardowe CentOS 7 3.10 — dla większości zwykłych serwerów;
- Waniliowe 4.19 — dla naszego , ponieważ potrzebujemy BFQ, BBR itp.;
- Jądro Elrepo kernel-ml 5.2 — dla , ponieważ 4.19 wcześniej zachowywało się niestabilnie, a potrzebne są te same funkcje.
Jak możesz się domyślić, najwięcej czasu zajmuje restartowanie tysięcy serwerów. Ponieważ nie wszystkie podatności są krytyczne dla wszystkich serwerów, restartujemy tylko te, które są bezpośrednio dostępne z internetu. W chmurze, aby nie ograniczać elastyczności, nie przypisujemy dostępnych zewnętrznie kontenerów do pojedynczych serwerów z nowym jądrem, a restartujemy wszystkie hosty bez wyjątku. Na szczęście procedura jest tam prostsza niż w przypadku zwykłych serwerów. Na przykład kontenery stateless mogą po prostu przenieść się na inny serwer podczas restartu.
Niemniej jednak jest dużo pracy do wykonania, a może ona zająć kilka tygodni, a w przypadku jakichkolwiek problemów z nową wersją — nawet kilka miesięcy. Hakerzy doskonale to rozumieją, dlatego potrzebny jest plan „B”.
FragmentSmack/SegmentSmack. Workaround
Na szczęście dla niektórych podatności taki plan „B” istnieje i nazywa się Workaround. Najczęściej polega to na zmianie ustawień jądra/aplikacji, które pozwalają minimalizować możliwy efekt lub całkowicie wyeliminować eksploatację podatności.
W przypadku FragmentSmack/SegmentSmack taki Workaround:
«Można zmienić domyślne wartości 4MB i 3MB w net.ipv4.ipfrag_high_thresh oraz net.ipv4.ipfrag_low_thresh (i ich odpowiedniki dla ipv6 net.ipv6.ipfrag_high_thresh oraz net.ipv6.ipfrag_low_thresh) na 256 kB i 192 kB odpowiednio lub poniżej. Testy pokazują od niewielkiego do znacznego spadku wykorzystania CPU podczas ataku, w zależności od sprzętu, ustawień i warunków. Może jednak wystąpić pewien wpływ na wydajność z powodu ipfrag_high_thresh=262144 bajtów, ponieważ tylko dwa fragmenty 64K mogą jednocześnie zmieścić się w kolejce do odbudowy. Na przykład istnieje ryzyko, że aplikacje pracujące z dużymi pakietami UDP mogą ulec awarii.».
Same parametry są opisane tak:
ipfrag_high_thresh - DŁUGA LICZBA CAŁKOWITA
Maksymalna pamięć używana do reassemblacji fragmentów IP.
ipfrag_low_thresh - DŁUGA LICZBA CAŁKOWITA
Maksymalna pamięć używana do ponownego zestawienia fragmentów IP przed tym, jak jądro
rozpocznie usuwanie niekompletnych kolejek fragmentów w celu zwolnienia zasobów.
Jądro nadal akceptuje nowe fragmenty do defragmentacji.
Nie mamy usług produkcyjnych obsługujących duże UDP. W LAN ruch fragmentowany nie występuje, w WAN występuje, ale nieznacznie. Nie zapowiada się źle — można wdrażać Workaround!
FragmentSmack/SegmentSmack. Pierwsza krew
Pierwszym problemem, na który natrafiliśmy, było to, że kontenery w chmurze czasami stosowały nowe ustawienia tylko częściowo (tylko ipfrag_low_thresh), a czasami w ogóle ich nie stosowały — po prostu zawieszały się przy starcie. Trudno było powtórzyć problem w sposób stabilny (wszystkie ustawienia stosowane ręcznie przebiegały bez jakichkolwiek trudności). Zrozumienie, dlaczego kontener się zawiesza przy starcie, także nie jest takie proste: nie wykryto żadnych błędów. Jedno było pewne: przywrócenie ustawień rozwiązuje problem z zawieszaniem się kontenerów.
Dlaczego samo zastosowanie Sysctl na hoście nie jest wystarczające? Kontener żyje w swoim przydzielonym Namespace sieciowym, więc przynajmniej w kontenerze może różnić się od hosta.
Jak dokładnie stosowane są ustawienia Sysctl w kontenerze? Ponieważ kontenery są w naszym przypadku nieuprzywilejowane, nie można zmienić żadnego ustawienia Sysctl, wchodząc do samego kontenera — po prostu brakuje uprawnień. Do uruchamiania kontenerów nasza chmura w tym czasie używała Dockera (obecnie już ). Parametry nowego kontenera, w tym potrzebne ustawienia Sysctl, były przekazywane do Dockera przez API.
Podczas testowania wersji okazało się, że API Dockera nie zwracało wszystkich błędów (przynajmniej w wersji 1.10). Przy próbie uruchomienia kontenera za pomocą „docker run” w końcu zobaczyliśmy coś:
write /proc/sys/net/ipv4/ipfrag_high_thresh: invalid argument docker: Error response from daemon: Cannot start container : [9] System error: could not synchronise with container process.
Wartość parametru jest niewłaściwa. Ale dlaczego? I dlaczego jest ona niewłaściwa tylko czasami? Okazało się, że Docker nie gwarantuje kolejności stosowania parametrów Sysctl (ostatnia sprawdzona wersja — 1.13.1), dlatego czasami ipfrag_high_thresh próbował ustawić się na 256K, gdy ipfrag_low_thresh wciąż wynosił 3M, więc górna granica była niższa niż dolna, co prowadziło do błędu.
W tym czasie już używaliśmy własnego mechanizmu dokonywania konfiguracji kontenera po starcie (zamrażanie kontenera przez i wykonywanie poleceń w namespace ), i dodaliśmy do tej części również zapis parametrów Sysctl. Problem został rozwiązany.
FragmentSmack/SegmentSmack. Pierwsza krew 2
Gdy zrozumieliśmy, jak zastosować Workaround w chmurze, zaczęły napływać pierwsze rzadkie skargi od użytkowników. Od momentu wprowadzenia Workaround na pierwszych serwerach minęły kilka tygodni. Wstępne śledztwo wykazało, że skargi dotyczyły pojedynczych usług, a nie wszystkich serwerów tych usług. Problem znowu nabrał bardzo nieokreślonego charakteru.
Przede wszystkim spróbowaliśmy oczywiście cofnąć ustawienia Sysctl, ale to nie przyniosło żadnego efektu. Różne manipulacje z ustawieniami serwera i aplikacji również nie pomogły. Pomógł reboot. Reboot w Linuxie jest tak samo przeciwieństwem, jak był normalnym warunkiem pracy z Windows w dawnych czasach. Niemniej jednak pomógł, a my zrzuciliśmy to na "błąd w jądrze" przy wprowadzaniu nowych ustawień w Sysctl. Jak naiwne to było…
Po trzech tygodniach problem się powtórzył. Konfiguracja tych serwerów była dość prosta: Nginx w trybie proxy/balansowania obciążenia. Ruch był niewielki. Nowa informacja: u klientów z każdym dniem zwiększa się liczba błędów 504-tych (). Na wykresie przedstawiono liczbę błędów 504-tych dziennie dla tej usługi:

Wszystkie błędy dotyczyły tego samego backendu — tego, który znajduje się w chmurze. Wykres zużycia pamięci dla fragmentów pakietów na tym backendzie wyglądał następująco:

To jedno z najbardziej wyrazistych przejawów problemu na wykresach systemu operacyjnego. W chmurze właśnie w tym czasie naprawiono inny problem z siecią związany z ustawieniami QoS (Traffic Control). Wykres zużycia pamięci dla fragmentów pakietów wyglądał dokładnie tak samo:

Założenie bylo proste: jeśli na wykresach wyglądają identycznie, to ich przyczyna jest taka sama. Tym bardziej, że problemy z tym typem pamięci zdarzają się niezwykle rzadko.
Istota naprawionego problemu polegała na tym, że używaliśmy w QoS planisty pakietów fq z domyślnymi ustawieniami. Domyślnie dla jednego połączenia pozwala on dodać do kolejki 100 pakietów, a niektóre połączenia w sytuacji braku pasma zaczęły zatykać kolejkę do maksimum. W takim przypadku pakiety są odrzucane. W statystyce tc (tc -s qdisc) wygląda to tak:
qdisc fq 2c6c: rodzic 1:2c6c limit 10000p flow_limit 100p buckets 1024 orphan_mask 1023 quantum 3028 initial_quantum 15140 refill_delay 40.0ms
Wysłano 454701676345 bajtów 491683359 pkt (odrzucone 464545, przekroczenia 0, ponowne kolejkowanie 0)
backlog 0b 0p ponowne kolejkowanie 0
1024 przepływy (1021 nieaktywne, 0 ograniczone)
0 gc, 0 highprio, 0 ograniczone, 464545 flows_plimit
„464545 flows_plimit“ to pakiety, które zostały utracone z powodu przekroczenia limitu kolejki jednego połączenia, natomiast „dropped 464545“ to suma wszystkich utraconych pakietów tego harmonogramu. Po zwiększeniu długości kolejki do 1 tys. i ponownym uruchomieniu kontenerów problem przestał występować. Można się zrelaksować w fotelu i napić smoothie.
FragmentSmack/SegmentSmack. Ostatnia krew
Po pierwsze, kilka miesięcy po ogłoszeniu podatności w jądrze, w końcu pojawiła się łatka dla FragmentSmack (przypominam, że razem z ogłoszeniem w sierpniu wydano łatkę tylko dla SegmentSmack), co dało szansę na rezygnację z workaroundu, który przysporzył nam sporo kłopotów. Część serwerów w tym czasie zdołaliśmy już przenieść na nowe jądro, więc teraz musieliśmy zaczynać od nowa. Dlaczego zaktualizowaliśmy jądro, nie czekając na łatkę dla FragmentSmack? Otóż proces zabezpieczania przed tymi podatnościami zbiegł się (i zlał) z procesem aktualizacji samego CentOS (co zajmuje jeszcze więcej czasu, niż aktualizacja samego jądra). Ponadto SegmentSmack jest bardziej niebezpieczną podatnością, a łatka dla niej pojawiła się od razu, więc w każdym razie miało to sens. Jednak nie mogliśmy po prostu zaktualizować jądra na CentOS, ponieważ podatność FragmentSmack, która pojawiła się w czasach CentOS 7.5, została naprawiona dopiero w wersji 7.6, dlatego musieliśmy wstrzymać aktualizację do 7.5 i rozpocząć wszystko od nowa od aktualizacji do 7.6. I tak też bywa.
Po drugie, wróciły do nas rzadkie skargi użytkowników na problemy. Teraz już na pewno wiemy, że wszystkie są związane z przesyłaniem plików przez klientów na niektóre nasze serwery. Przy czym przez te serwery odbywała się bardzo niewielka liczba przesyłek w porównaniu do ogółu.
Jak pamiętamy z wcześniejszej opowieści, rollback Sysctl nie pomagał. Pomógł Restart, ale tymczasowo.
Podejrzenia dotyczące Sysctl nie zostały rozwiane, ale tym razem konieczne było zebranie jak najwięcej informacji. Również bardzo brakowało możliwości odtworzenia problemu z przesyłaniem na kliencie, aby dokładniej zbadać, co się dzieje.
Analiza wszystkich dostępnych statystyk i logów nie przybliżyła nas do zrozumienia sytuacji. Ostra potrzeba reprodukcji problemu, aby „dotknąć” konkretnego połączenia, była wyczuwalna. W końcu programistom na specjalnej wersji aplikacji udało się stabilnie odtworzyć problemy na urządzeniu testowym przy połączeniu przez Wi-Fi. To było przełomowe w śledztwie. Klient łączył się z Nginx, który przesyłał żądania do backendu, którym była nasza aplikacja w Javie.

Dialog podczas problemów był następujący (zarejestrowany po stronie proxy Nginx):
- Klient: żądanie informacji o wznowieniu pobierania pliku.
- Serwer Java: odpowiedź.
- Klient: POST z plikiem.
- Serwer Java: błąd.
Serwer Java przy tym zapisuje w logu, że od klienta otrzymał 0 bajtów danych, a proxy Nginx — że żądanie zajęło więcej niż 30 sekund (30 sekund to czas oczekiwania aplikacji klienckiej). Dlaczego więc nastąpił timeout i dlaczego 0 bajtów? Z perspektywy HTTP wszystko działa tak, jak powinno, ale POST z plikiem jakby znika z sieci. Znika pomiędzy klientem a Nginx. Nadszedł czas na użycie Tcpdump! Ale najpierw musimy zrozumieć konfigurację sieci. Proxy Nginx stoi za równoważnikiem L3. . Używane jest tunelowanie do dostarczania pakietów z równoważnika L3 do serwera, które dodaje swoje nagłówki do pakietów:

Sieć dociera do serwera w postaci ruchu z tagiem VLAN, co również dodaje własne pola do pakietów:

A ten ruch może być fragmentowany (ten mały procent fragmentowanego ruchu przychodzącego, o którym mówiliśmy przy ocenie ryzyka z Workaround), co również zmienia zawartość nagłówków:

Jeszcze raz: pakiety są enkapsulowane tagiem VLAN, enkapsulowane przez tunel, fragmentowane. Aby dokładniej zrozumieć, jak to się dzieje, prześledźmy trasę pakietu od klienta do proxy Nginx.
- Pakiet dociera do równoważnika L3. Aby prawidłowo trasować wewnątrz centrum danych, pakiet jest enkapsulowany w tunel i wysyłany do karty sieciowej.
- Ponieważ pakiet + nagłówki tunelu nie mieszczą się w MTU, pakiet jest dzielony na fragmenty i wysyłany do sieci.
- Przełącznik po równoważniku L3, po otrzymaniu pakietu, dodaje do niego tag VLAN i wysyła dalej.
- Switch przed proxy Nginx widzi (według ustawień portu), że serwer oczekuje pakietu z kapsułkowaniem VLAN, dlatego wysyła go tak, jak jest, nie usuwając tagu VLAN.
- Linux otrzymuje fragmenty poszczególnych pakietów i łączy je w jeden duży pakiet.
- Następnie pakiet trafia na interfejs VLAN, gdzie zdejmuje się pierwszy poziom — kapsułkowanie VLAN.
- Potem Linux wysyła go na interfejs Tunnel, gdzie zdejmuje się kolejny poziom — kapsułkowanie Tunnel.
Trudność polega na przekazaniu tego wszystkiego jako parametrów do tcpdump.
Zacznijmy od końca: czy są czyste (bez zbędnych nagłówków) pakiety IP od klientów, ze zdjętym kapsułkowaniem VLAN i Tunnel?
tcpdump host
Nie, nie było takich pakietów na serwerze. Zatem problem musi występować wcześniej. Czy są pakiety ze zdjętym tylko kapsułkowaniem VLAN?
tcpdump ip[32:4]=0xx390x2xx
0xx390x2xx — to adres IP klienta w formacie heksadecymalnym.
32:4 — adres i długość pola, w którym zapisany jest SCR IP w pakiecie Tunnel.
Adres pola musiałem znaleźć metodą prób i błędów, ponieważ w internecie piszą o 40, 44, 50, 54, ale tam adresu IP nie było. Można też zobaczyć jeden z pakietów w heksie (parametr -xx lub -XX w tcpdump) i policzyć, pod jakim adresem znany ci IP.
Czy są fragmenty pakietów bez zdjętego kapsułkowania VLAN i Tunnel?
tcpdump ((ip[6:2] > 0) i (nie ip[6] = 64))
Ta magia pokaże nam wszystkie fragmenty, w tym ostatni. Prawdopodobnie można także filtrować według IP, ale nie próbowałem, ponieważ takich pakietów nie ma zbyt wiele, a w ogólnym przepływie łatwo znaleźć mi potrzebne. Oto one:
14:02:58.471063 W 00:de:ff:1a:94:11 etype IPv4 (0x0800), długość 1516: (tos 0x0, ttl 63, id 53652, offset 0, flagi [+], proto IPIP (4), długość 1500)
11.11.11.11 > 22.22.22.22: truncated-ip - 20 bajtów brakuje! (tos 0x0, ttl 50, id 57750, offset 0, flagi [DF], proto TCP (6), długość 1500)
33.33.33.33.33333 > 44.44.44.44.80: Flagi [.], seq 0:1448, ack 1, win 343, opcje [nop,nop,TS val 11660691 ecr 2998165860], długość 1448
0x0000: 0000 0001 0006 00de fb1a 9441 0000 0800 ...........A....
0x0010: 4500 05dc d194 2000 3f09 d5fb 0a66 387d E.......?....f8}
0x0020: 1x67 7899 4500 06xx e198 4000 3206 6xx4 .faEE.....@.2.m.
0x0030: b291 x9xx x345 2541 83b9 0050 9740 0x04 .......A...P.@..
0x0040: 6444 4939 8010 0257 8c3c 0000 0101 080x dDI9...W.......
0x0050: 00b1 ed93 b2b4 6964 xxd8 ffe1 006a 4578 ......ad.....jEx
0x0060: 6966 0000 4x4d 002a 0500 0008 0004 0100 if..MM.*........
14:02:58.471103 W 00:de:ff:1a:94:11 ethtype IPv4 (0x0800), długość 62: (tos 0x0, ttl 63, id 53652, offset 1480, flags [none], proto IPIP (4), długość 40)
11.11.11.11 > 22.22.22.22: ip-proto-4
0x0000: 0000 0001 0006 00de fb1a 9441 0000 0800 ...........A....
0x0010: 4500 0028 d194 00b9 3f04 faf6 2x76 385x E..(....?....f8}
0x0020: 1x76 6545 xxxx 1x11 2d2c 0c21 8016 8e43 .faE...D-,.!...C
0x0030: x978 e91d x9b0 d608 0000 0000 0000 7c31 .x............|Q
0x0040: 881d c4b6 0000 0000 0000 0000 0000 ..............
To dwa fragmenty jednego pakietu (ten sam ID 53652) ze zdjęciem (widać słowo Exif w pierwszym pakiecie). Ponieważ na tym poziomie pakiety są, a w połączonej postaci w zrzutach — nie, problem jest wyraźnie ze składaniem. W końcu mamy na to dokumentacyjne potwierdzenie!
Dekoder pakietów nie wykrył żadnych problemów uniemożliwiających składanie. Próbowałem tutaj: Najpierw, podczas próby dodania czegoś do niego, dekoder nie akceptował formatu pakietu. Okazało się, że między Srcmac a Ethertype znajdowały się jakieś dodatkowe dwa oktety (niezwiązane z informacjami o fragmentach). Po ich usunięciu dekoder zaczął działać. Jednak nie zgłosił żadnych problemów.
Bez względu na wszystko, poza tymi właśnie Sysctl nic więcej nie znaleziono. Pozostało tylko znaleźć sposób na identyfikację problematycznych serwerów, aby zrozumieć skalę i podjąć decyzję o dalszych działaniach. W dość szybkim czasie znalazł się odpowiedni licznik:
netstat -s | grep "packet reassembles failed”
Jest on również dostępny w snmpd pod OID=1.3.6.1.2.1.4.31.1.1.16.1 ().
„Liczba niepowodzeń wykrytych przez algorytm ponownego składania pakietów IP (z różnych przyczyn: upływ czasu, błędy, itp.)”.
Wśród grupy serwerów, na których badano problem, na dwóch ten licznik wzrastał szybciej, na dwóch — wolniej, a na jeszcze dwóch w ogóle się nie zwiększał. Porównanie dynamiki tego licznika z dynamiką błędów HTTP na serwerze Java ujawniło korelację. Oznacza to, że licznik można było ustawić do monitorowania.
Posiadanie niezawodnego wskaźnika problemów jest bardzo ważne, aby precyzyjnie określić, czy wycofanie Sysctl pomaga, ponieważ z poprzedniej opowieści wiemy, że nie można tego od razu zrozumieć w odniesieniu do aplikacji. Taki wskaźnik pozwoliłby zidentyfikować wszystkie problematyczne miejsca w produkcji, zanim odkryją to użytkownicy.
Po wycofaniu Sysctl błędy w monitorowaniu ustały, w ten sposób potwierdziło się, że przyczyna problemów została udowodniona, jak i to, że wycofanie pomaga.
Cofnęliśmy ustawienia fragmentacji na innych serwerach, gdzie uruchomiono nowe monitorowanie, a gdzieś przydzielono nawet więcej pamięci na fragmenty niż było to domyślnie (to była statystyka udp, której częściowa utrata nie była zauważalna na ogólnym tle).
Najważniejsze pytania
Dlaczego na naszym równoważniku L3 pakiety są fragmentowane? Większość pakietów, które docierają od użytkowników do równoważników, to SYN i ACK. Rozmiary tych pakietów są niewielkie. Jednak ze względu na dużą proporcję takich pakietów, na ich tle nie zauważyliśmy istnienia większych pakietów, które zaczęły się fragmentować.
Przyczyną był uszkodzony skrypt konfiguracyjny Na serwerach z interfejsami Vlan (wówczas w produkcji było bardzo mało serwerów z oznaczoną warpem). Advmss umożliwia przekazanie klientowi informacji, że pakiety w naszym kierunku powinny być mniejszych rozmiarów, aby po dodaniu nagłówków tunelu nie było potrzeby ich fragmentacji.
Dlaczego wycofanie Sysctl nie pomogło, a restart pomógł? Wycofanie Sysctl zmieniało ilość pamięci dostępnej do łączenia pakietów. Przy czym fakt, że pamięć dla fragmentów była przepełniona, prowadził do spowolnienia połączeń, powodując, że fragmenty długo były w kolejce. Proces się zatrzymywał.
Restart zerował pamięć i wszystko wracało do normy.
Czy można było obejść się bez Workaround? Tak, ale istniało duże ryzyko pozostawienia użytkowników bez wsparcia w przypadku ataku. Oczywiście, zastosowanie Workaround prowadziło do różnych problemów, w tym do spowolnienia jednego z usług użytkowników, niemniej jednak uważamy, że podjęte działania były uzasadnione.
Serdeczne podziękowania dla Andrieja Timofiejewa () za pomoc w przeprowadzaniu dochodzenia oraz dla Aleksieja Kreniewa () — za tytaniczną pracę nad aktualizacją CentOS i jąder na serwerach. Proces, który w tym przypadku kilka razy musiał być zaczynany od nowa, przez co rozciągał się na wiele miesięcy.
Źródło: habr.com
