Fuzzing w projekcie TAMA

9 Marca 2021

W projekcie TAMA, oprócz używania narzędzi do statycznej analizy kodu oraz testowania z użyciem sanitizers, jednym ze sposobów w jaki zwiększamy bezpieczeństwo jest fuzz testing. Fuzzing to technika testowania oprogramowania, która polega na wysyłaniu do programu różnych prawidłowych/nieprawidłowych/losowych danych i obserwowaniu zachowania programu.

W tym artykule opiszę w jaki sposób fuzzujemy nasz filtr pakietów z użyciem kompilatora clang i libFuzzer.

Kod do przetestowania

Działanie filtra pakietów

Nasz filtr pakietów – GlaDDoS – w uproszczeniu wykonuje taki algorytm (pseudokod):

uproszczony algorytm (pseudokod) GlaDDoS

W tym algorytmie można dostrzec dwa miejsca w których operujemy na danych, które mogą powodować błędy:

  • decode-funkcja, która pobiera N bajtów i zwraca strukturę pakietu np. (proto=IPv4, src=1.2.3.4, dst=5.6.7.8);
  • metoda process każdego filtra, która operuje już na zdekodowanym pakiecie. Logika tych metod jest zależna od binarnych danych które otrzymaliśmy.

Kod C++

A tak wygląda kod w C++.

Kod C++

Klasa bazowa Filter otrzymuje w metodzie process pakiet i zwraca informację, czy pakiet powinien być przesłany dalej lub odrzucony. Podklasy implementujące jej interfejs to np.:

  • InvalidPacketFilter – odrzuca pakiety błędne (niepoprawna suma kontrolna, błędy w nagłówkach) i nielogiczne (pakiety TCP z flagami SYN i RST itp.);
  • GEOFilter – odrzuca pakiety, które pochodzą z krajów znajdujących się na czarnej liście.

Następnie, klasa Pipeline posiada listę filtrów i konfigurację filtrowania dla chronionych przez nas adresów. Konfiguracja zawiera np. informację, które filtry są dla danego adresu włączone lub które kraje są na białej/czarnej liście.

Filtry pipeline

Fuzzing z użyciem libFuzzer

Kompilator clang pozwala bardzo łatwo rozpocząć fuzzing. W nowym pliku źródłowym należy jedynie zdefiniować funkcję, którą wywoła fuzzer. Powinna przyjmować dwa argumenty -wskaźnik na dane i rozmiar:

Kompilator clang

Przed rozpoczęciem testowania, czasem trzeba będzie zrobić jakąś globalną inicjalizację. Inicjalizację można zrobić przez definicję funkcji, która będzie wywołana przez libFuzzer tylko raz przy starcie programu.

W naszym przypadku musimy zainicjalizować Pipeline, ponieważ domyślnie wszystkie filtry są wyłączone, więc żaden nie będzie testowany – tutaj, konfigurujemy Pipeline tak, by każdy możliwy adres IPv4 miał włączone wszystkie filtry.

Filtry IPv4 w Pipeline

Kompilacja i uruchomienie:

kompilacja i uruchomienie

Po uruchomieniu fuzzer będzie działać w nieskończoność lub dopóki program nie wykona błędnej operacji, którą wykryje AddressSanitizer lub UBSan.

Użycie z CMake

W naszym projekcie używamy CMake do konfiguracji systemu budowania. Aby łatwo budować fuzzer, w katalogu w którym znajduje się jego plik źródłowy mamy plik CMakeLists.txt:

CMakeLists.txt

Oraz, w głównym CMakeLists.txt dla projektu mamy opcję włączenia budowania z fuzzerem:

opcja buforowania

Dzięki temu, przy budowaniu projektu można łatwo zbudować również fuzzer:

fuszer

Custom mutator

Gdy rozpoczęliśmy fuzzing, początkowo byliśmy zdziwieni, że fuzzer nie znajduje żadnych błędów – nawet tych oczywistych, przygotowanych dla testów. Po analizie i sprawdzeniu pokrycia kodu przez fuzzer odkryliśmy, że tylko pierwszy filtr był testowany.

Działo się to dlatego, że pierwszy filtr – InvalidPacketFilter – sprawdza sumę kontrolną IPv4 w pakiecie i odrzuca każdy pakiet dla którego się ona nie zgadza. Oczywiście fuzzer nie wie jak wygenerować pakiety z poprawną sumą kontrolną. Z tego powodu fuzzer utknął na pierwszym filtrze i nie zwiększał swojego pokrycia.

Aby rozwiązać ten problem, użyliśmy własny „mutator” danych wejściowych. Mutator to funkcja, która w jakiś sposób zmienia wygenerowane dane wejściowe, by pokryć większą część kodu.

W naszym przypadku, przed każdym LLVMFuzzerTestOneInput najpierw robimy standardową mutację danych, a potem obliczamy sumę kontrolną i nadpisujemy ją w nagłówku IPv4.

Mutacja danych_1

Mutacja danych_2

Seed corpus

Fuzzer można uruchomić z własnymi danymi wejściowymi. Nie jest to konieczne, bo libFuzzer sam potrafi wygenerować dane, które powodują poszerzenie pokrycia. Niemniej jednak dzięki temu, szybko można je poszerzyć.

Katalog, który zawiera pliki z danymi wejściowymi nazywa się „corpus”. W naszym przypadku w każdym pliku będzie znajdować się pakiet binarny. Dla GlaDDoSa wygenerowaliśmy te pakiety przy użyciu narzędzia scapy.

scapy

Po uruchomieniu takiego skryptu, możemy wywołać fuzzer na wygenerowanym corpusie:

corpus

Debugging

Gdy podczas działania fuzzera w programie wystąpi fatalny błąd, to fuzzer zakończy swoją pracę i zapisze dane, które wygenerował i powodują błąd oraz pokaże nazwę pliku:

fuzzing error

Mając takie dane, możemy przeanalizować pakiet z użyciem narzędzia scapy oraz znaleźć przyczynę błędu:

przyczyna błędu

przyczyna błędu_2

W tym przykładzie, TCP data offset jest za duży. Przez to nasz program próbuje czytać dane poza zakresem i otrzymuje fatalny sygnał SIGSEGV.

Podsumowanie

Fuzzing jest bardzo przydatną techniką znajdowania błędów w programach. W projekcie TAMA używamy dedykowanego serwera do fuzzingu.

Do tej pory fuzzer wygenerował około 3 biliony pakietów i znalazł już kilkanaście krytycznych błędów, które mogłyby znacząco narazić naszą infrastrukturę oraz klientów na realne straty. Zastosowanie go jako składowej w procesie wytwórczym nie tylko ułatwia nam wykrycie błędów -także uszczelnia sam proces. Dzięki identyfikowaniu błędów -zarówno tych krytycznych, jak i tych mniej znaczących -wyciągnęliśmy wnioski i nauczyliśmy się jak pisać lepszy, bezpieczniejszy kod.

Fuzz testing na pewno sprawdził się u nas i będziemy kontynuować stosowanie tej techniki i usprawnianie procesu fuzzingu.

Opublikowane przez: Piotr Mierzwiński

Inne artykuły_