LLVM LibFuzzer – Fast track #2

Słowo wstępu

Kolejna część szybkiego wprowadzenia do zagadnień związanych z LibFuzzerem – w tym poście skupię się na nieco “głębszym” wykorzystaniu wbudowanych funkcjonalności, tunigu wydajności fuzzera oraz pokryciu kodu za pomocą SanitizerCoverage.

Bez zbędnego rozwlekania, przechodzimy do konkretów 🙂

Jak robić to lepiej?

W moim rozumieniu: lepiej = większy code coverage || więcej execs/s.. Generalnie w przypadku fuzzerów zawsze należy pamiętać o prawidłowości: więcej execs/s -> większy korpus -> większy code coverage -> więcej potencjalnych crashy.

Autorzy LibFuzzera jako minimum przyjmują 1000 execs/s dla efektywnego fuzzingu.

Słowniki

Słowniki znacząco przyczyniają się do większego pokrycia kodu – fuzzujemy po prostu mądrze, nie tracąc cykli CPU na generowanie bezwartościowego inputu.

Pliki słowników wykorzystywane przez LibFuzzer są kompatybilne z AFL, więc możemy skorzystać z dostarczonych razem z tym fuzzerem. Jeżeli nie znajdziemy tam słownika kompatybilnego z naszym programem, warto poświęcić chwilę i napisać go samemu – ROI jest na prawdę duże 🙂

Dobre słowniki można znaleźć w następujących miejscach:

Leniwi czytelnicy mogą skorzystać z gotowego skryptu pozwalającego na wygenerowanie słownika na podstawie dostarczonej specyfikacji.

Rozmiar korpusu testowego

W większości przypadków im mniejsze pliki tym lepiej (aczkolwiek nie polecam obsesyjnie się tego trzymać). Nie ma sensu fuzzować biblioteki regex za pomocą przypadków testowych o rozmiarze 100KB i więcej. Polecam przetestować empirycznie własny fuzzer pod kątem wielkości pojedyńczego przypadku testowego – przełącznik “-max_len“.

Osobiście, po kilku iteracjach fuzzingu danego projektu, dzielę korpus testowy względem rozmiaru – pliki mniejsze niż 1, 2, 4… KB i sprawdzam jak wygląda pokrycie kodu dla danego rozmiaru. Następnie idę na rozsądny kompromis (przynajmniej tak mi się wydaje) pomiędzy pokryciem a rozmiarem 😉

Im wyższą wartość niepowodującą degradacji szybkości uda się nam znaleźć, tym otrzymamy lepsze pokrycie kodu.

Kod fuzzera

Tutaj pomoże ortodoksyjne trzymanie się zasady KISS (albo po polsku DUPA) 😉

Parę prostych wskazówek:

  • Nie inicjalizujmy za każdym razem (jeżeli nie musimy) mechanizmów fuzzowanej biblioteki
  • Korzystajmy ze stosu zamiast z alokacji na heapie – jest dużo szybszy. Dla przykładu wywołanie funkcji memstet() dla 1 MB zaalkokowanego na heapie to 5x wolniejsza operacja, od takiej samej, tylko, że na stosie
  • Zwalniajmy wszystkie zasoby, które są wykorzystywane podczas iteracji fuzzera. W przeciwnym razie pamięć RAM zostanie “zeżarta” na naszych oczach

Bardzo fajny przykład optymalizacji został przedstawiony na slajdach jednego z modułów warsztatu LibFuzzer Workshopzachęcam do zapoznania się z całym materiałem 🙂

Badanie pokrycia kodu

Przed uruchomieniem fuzzera warto sprawdzić w jakim stopniu nasz korpus testowy pokrywa badany przez nas kod. Niestety do tego będzie potrzebna ponowna kompilacja fuzzera, z innymi przełącznikami (tutaj na przykładzie fuzzera pcre2):


clang++ -std=c++11 pcre2_fuzzer.cc -I pcre/src -Wl,--whole-archive pcre/.libs/*.a -Wl,-no-whole-archive ~/Desktop/libFuzzer.a -O2 -fno-omit-frame-pointer -g -fsanitize=address -fsanitize-coverage=edge,indirect-calls,8bit-counters,trace-cmp,trace-div,trace-gep -o pcre2_fuzzer_coverage

Uwaga! Przełącznik 8bit-counters nie będzie wspierany. W przypadku chęci wykorzystania innych metod badania pokrycia warto wcześniej rzucić okiem na dokumentację SanCova (sporo metod pozostanie bez wsparcia i nie będą rozwijane).

Następnie odpalamy skompilowaną binarkę:

ASAN_OPTIONS=coverage=1 ./pcre2_fuzzer_coverage corpus_dir/ -runs=0

INFO: Seed: 3765786233
INFO: Loaded 0 modules (0 guards):
Loading corpus dir: regex_min/
Loaded 1024/8815 files from regex_min/
Loaded 2048/8815 files from regex_min/
Loaded 4096/8815 files from regex_min/
Loaded 8192/8815 files from regex_min/
INFO: -max_len is not provided, using 2035
#0    READ units: 8814
#2048    pulse  exec/s: 409 rss: 132Mb
#4096    pulse  exec/s: 178 rss: 143Mb
Slowest unit: 16 s:
artifact_prefix='./'; Test unit written to ./slow-unit-2c9a2da85c6b915818510370f6ff6680c8515d94
Slowest unit: 18 s:
artifact_prefix='./'; Test unit written to ./slow-unit-53e82f19e2337033fcc43d1c9da61da8e5ff90af
#8192    pulse  exec/s: 38 rss: 232Mb
#8814    INITED exec/s: 36 rss: 243Mb
ERROR: no interesting inputs were found. Is the code instrumented for coverage? Exiting.

Nie przejmujemy się komunikatem i dodajemy symbole do pliku .sancov :

sancov -symbolize pcre2_fuzzer_coverage pcre2_fuzzer_cov.18822.sancov > pcre2.symcov

Kolejnym krokiem jest odpalenie raportu w HTMLu dotyczący pokrycia kodu dla poszczególnych plików testowanego projektu:

python coverage-report-server.py --symcov pcre2.symcov --srcpath .

Efekt końcowy:

Wielowątkowość

Kiedy mamy już dobre pokrycie i szybki fuzzer warto odpalić go w kilku instancjach aby wycisnąć ostatnie soki i z fuzzera i z CPU 🙂

Libfuzzer ma, przynajmniej dla mnie, niecodzienny model zarządzania kilkoma instancjami. Składa się z dwóch części:

  • worker – przełącznik -workers, liczba równoległych instancji fuzzera – najczęściej taka sama jak liczba logicznych procesorów. Domyślnie jest to liczba logicznych CPU / 2
  • job – przełącznik -jobs, job jest rozumiany jako proces do znalezienia inputu powodującego awarię (crash, hang, brak pamięci, wyciek pamięci)

Uruchamiając fuzzer w poniższy sposób:

./myfuzzer -workers=4 -jobs=4 corpus_dir/

Znalezienie crasha spowoduje zabicie jednej instancji fuzzera i fuzzing na trzech lub mniejszej liczbie procesorów (w zależności od liczby znalezionych crashy). Czasami może okazać się, że nasze fuzzery przestały całkowicie pracować – dlatego polecam uruchamianie z dużo większą liczbą jobów:

./myfuzzer -workers=4 -jobs=4000 corpus_dir/

Spowoduje to “ciągły” fuzzing, tak jak w przypadku AFLa, który, moim zdaniem, w tej kwestii dużo lepiej podchodzi do problemu.

Leave a Reply

Your email address will not be published. Required fields are marked *