Software-Testing mit Fuzzing an einem einfachen Beispiel: Durch systematisch variierte Eingaben zu stabilerer Software
Diese Geschichte kennt jeder von uns: Für ein Software-Projekt will man nicht alles selber entwickeln, sondern Bibliotheken verwenden. Beim Review einer solchen Abhängigkeit sind uns Zweifel an der Sicherheit des Codes gekommen. Deshalb haben wir die Bibliothek genauer unter die Lupe genommen. Mittels Fuzzing mit AFL++
konnten wir mit wenig Aufwand Speicherfehler und Logikprobleme identifizieren. Diese Schwachstellen haben wir behoben und als Pull Request eingereicht. Nach unserer Analyse und Fehlerkorrektur haben wir nun mehr Vertrauen in die Bibliothek.
Für ein Kundenprojekt wollten wir eine kleine Open-Source-Bibliothek zum Parsen von CoAP, lizenziert unter BSD-3-Clause, (PicoCoAP) einsetzen. Bei CoAP handelt es sich um das Constrained Application Protocol, einem HTTP ähnlichen Protokoll für Embedded-Systems. Die Bibliothek wirkte kompakt und passend für unseren Anwendungsfall. Nach einem Review des Codes kamen uns Zweifel, ob sich die Bibliothek bei allen möglichen Eingabedaten korrekt verhält. Um unsere Vermutung zu bestätigen und allfällige Fehler zu korrigieren, entschieden wir uns, die Bibliothek zu testen.
Das Testen auf Ausnahmefälle ist keine neue Disziplin. Es gibt einige etablierte Methoden:
Unit-Tests helfen dabei, erwartbares Verhalten zu prüfen. Sie helfen jedoch nicht bei unerwarteten Eingaben.
Manuelle Tests mit ungültigen oder zufälligen Daten sind möglich, aber aufwendig systematisch durchzuführen und decken meist nur einen kleinen Teil möglicher Eingabedaten und interner Zustände ab.
Static Analyser (z. B. clang-tidy
, cppcheck
, etc.) können Hinweise auf Speicherprobleme oder uninitialisierte Werte liefern. Sie analysieren den Code, ohne ihn auszuführen.
Fuzzing (z.B. AFL++
, libFuzzer
(in LLVM
)) konfrontiert ein Programm automatisiert mit zufälligen oder manipulierten Eingaben, um Fehlverhalten oder Abstürze zu provozieren.
Da Unit und manuelle Tests unerwartetes Verhalten nur mit viel Aufwand erkennen können, haben wir diese Testmethoden nicht weiter verfolgt. Die statischen Analyzer von clang-tidy und cppcheck konnten in der erwähnten Bibliothek keine Schwachstellen entdecken. Aus diesem Grund haben wir uns entschieden, Fuzzing zu testen.
Wie funktioniert Fuzzing?
Fuzzing ist eine Technik, bei der Programme mit einer grossen Menge ungewöhnlicher oder fehlerhafter Eingaben konfrontiert werden, um deren Verhalten zu testen. Ziel ist es, Abstürze, undefiniertes Verhalten oder Speicherfehler zu provozieren. Aber: Fuzzing ist nicht einfach nur "Zufall testen". Moderne Fuzzer wie AFL++
arbeiten strategisch: Sie instrumentalisiert das Programm und beobachten, welche Codestellen ausgeführt werden. Anhand dieser Coverage generieren sie gezielt neue Inputs, die weitere, bisher ungetestete Pfade aufdecken. Mit jedem Durchlauf wird das Eingabeset besser. Der Fuzzer lernt, welche Eingaben das Programm in interessante Zustände bringen und erzeugt daraus neue Mutationen. So werden auch tieferliegende Fehler sichtbar, die bei Unit-Tests nie aufgefallen wären.
Dafür kommen genetische Algorithmen zum Einsatz, häufig inspiriert von evolutionären Strategien. Der Fuzzer generiert anhand einiger Seeds eine Population an Inputs, welche er anhand der Code-Coverage bewertet. Inputs mit hoher Coverage werden selektiert, gespeichert und als Ausgangspunkt für weitere Mutationen verwendet. Diese Mutationen folgen algorithmisch gesteuerten Heuristiken, etwa Bitflips, Byte-Insertion, Randwertmanipulation oder strukturellen Veränderungen (z. B. bei binären oder strukturierten Formaten). Durch diesen ständigen Zyklus aus Selektion, Variation und Bewertung entdeckt der Fuzzer auch komplexe, tief im Code versteckte Fehlerzustände.
Initialkonfiguration einer Fuzzing-Infrastruktur
Unser Fuzzing-Setup:
Installation von
AFL++
via Homebrew (brew install afl-fuzz
).Eine minimalistische Main-Funktion, die:
Eingaben über
stdin
entgegennimmt,diese in einen Buffer schreibt
Funktionen aus der Bibliothek auf diesen Buffer anwendet
das Ergebnis ggf. ausgeben
Wir haben das Programm mit afl-clang-fast
kompiliert und instrumentalisiert. Anschliessen lief der Fuzzer über mehrere Stunden mit verschiedenen Seeds.
Ergebnisse
Bei unseren Tests haben wir uns primär aus das Parsing von Daten aus CoAP-Paketen konzentriert. Dabei wurden schnell Abstürze und Endlos-Loops in der Library gefunden. Die gefundenen Abstürze und Loops haben wir mit GDB
analysiert. Dafür ist es notwendig das Projekt mit der Option -g
zu kompilieren. Zudem empfiehlt sich den AddressSanitizers zu aktivieren (-fsanitize=address
), um "out of bounds" Schreibaktionen zu erkennen, auch wenn diese den Fuzzing-Prozess verlangsamt (bei uns: von 1200 Executions/Sekunde auf 20 Executions/Sekunde). Eine mögliche Alternative wäre es die libfuzz
von llvm zu verwenden, die performanter ist, was allerdings aus Zeitgründen nicht mehr verfolgt wurde.
So konnten wir unter anderem:
Fehlende Pointer-End-Checks identifizieren.
Einen Logikfehler bei einem Precondition-Check entdecken.
Unsere gefundenen Probleme haben wir bereits in GitHub eingereicht (Pull Request).
Schlussfolgerung
Fuzzing ist keine Allzweckwaffe, aber eine extrem wertvolle Ergänzung zu bestehenden Testmethoden. Besonders bei:
Code, den man nicht selbst geschrieben hat
Bibliotheken mit speicherintensivem oder Pointer-lastigem Verhalten
Legacy-Projekten mit unbekannter Testabdeckung
Mit wenig Aufwand konnten Fehler in einer Bibliothek gefunden und korrigiert werden. Wir haben nun mehr Vertrauen in die Qualität der Bibliothek und werden in Zukunft dieses Vorgehen bei weiteren Abhängigkeiten in unserer Software einsetzen.