In einem früheren Artikel habe ich gezeigt, wie Property-Based Testing unsere Herangehensweise an Tests grundlegend verändert. Während wir dort Invarianten und allgemeine Wahrheiten über unser System definieren, wechseln wir nun die Perspektive: Wir werden zum Gegner unserer eigenen Software.
Beim Fuzz Testing, auch Fuzzing genannt, wird ein Programm bewusst so getestet, dass es zum Absturz gebracht wird. Anstelle von „Dieses Verhalten sollte immer wahr sein“ wie beim Property-Based Testing fragen wir beim Fuzzing: „In welcher Situation können wir diese Software zum Versagen bringen?“
Interessant ist, dass Property-Based Testing und Fuzzing auf den ersten Blick völlig unterschiedlich wirken, tatsächlich aber Gemeinsamkeiten teilen. Zugleich sind sie für völlig unterschiedliche Probleme optimiert. In diesem Artikel erkläre ich dir, welche Testmethode sich für welche Situation eignet.
Was ist Fuzz Testing?
Wenn wir Software entwickeln, schreiben wir normalerweise Unit Tests, um sicherzustellen, dass unser Code die erwarteten Fälle abdeckt. Wir können auch Mutation Testing verwenden, um zu überprüfen, ob unsere Tests robust genug sind, um Logikfehler zu erkennen. Es gibt jedoch eine Klasse von Fehlern, die „unbekannten Unbekannten“, die oft übersehen werden, wie beispielsweise fehlerhafte Eingaben, Randfälle in Bibliotheksabhängigkeiten oder unerwartete Verhaltensweisen der Umgebung.
Fuzz Testing ist eine automatisierte Testmethode, bei der ungültige, unerwartete oder zufällige Daten als Eingabe für ein Softwaresystem verwendet werden, entweder als Ganzes oder teilweise, wie zum Beispiel für ein Subsystem, eine Komponente, eine Klasse oder eine Methode. Die Software wird dann auf Ausnahmen wie Abstürze, fehlgeschlagene integrierte Zusicherungen oder Memory Leaks überwacht. Im Gegensatz zu Unit Tests, bei denen die Korrektheit anhand bekannter Eingaben geprüft wird, testen wir beim Fuzz Testing die Robustheit anhand einer unendlichen Menge unerwarteter Eingaben.
Wir crashen die CSV-Party
Vor einiger Zeit habe ich eine kleine Bibliothek namens csv-parser entwickelt, um Daten aus CSV-Dateien typsicher in PHP-Variablen zu parsen. Diese Bibliothek ist gut getestet, mit einer Abdeckung von 100 % der Zeilen und Verzweigungen, einer Pfadabdeckung von über 70 % und einer abgedeckten Code-Mutationsrate von 100 %. Trotzdem hat das Fuzz Testing einen Fehler im Code des Parsers aufgedeckt.
Schauen wir uns mal genauer an, wie Fuzz Testing umgesetzt werden kann, und untersuchen wir die „Garbage“-Daten, die zu einer Fehlfunktion des CSV-Parsers geführt haben.
Ich habe die von Nikita Popov entwickelte Bibliothek PHP-Fuzzer verwendet, um Fuzz Testing zu implementieren. PHP-Fuzzer ist ein abdeckungsgesteuerter (coverage-guided) Fuzzer für PHP. Er funktioniert wie folgt:
- Instrumentierung: Er modifiziert den Quellcode, um zu verfolgen, welche Teile des Codes ausgeführt werden
- Korpus-basierte Evolution: Er beginnt mit einem Korpus gültiger Eingaben (wie unseren bestehenden CSV-Fixtures)
- Mutation: Er verändert diese Eingaben nach dem Zufallsprinzip (durch Umdrehen von Bits, Hinzufügen von Zeichen usw.)
- Feedback-Schleife: Wenn eine Mutation einen neuen Teil des Codes erreicht, wird sie im Korpus gespeichert, um weiter mutiert zu werden. Wenn sie einen Absturz oder eine nicht abgefangene Ausnahme verursacht, wird sie als Fehler gemeldet.
Wir brauchen ein PHP-Skript, das von PHP-Fuzzer ausgeführt wird, um den Code, den wir fuzzen wollen, vorzubereiten und auszuführen. So könnte ein solches fuzzer.php-Skript zum Fuzzing der csv-parser Bibliothek aussehen:
<?php declare(strict_types=1); use PhpFuzzer\Config; use SebastianBergmann\CsvParser\Exception; use SebastianBergmann\CsvParser\FieldDefinition; use SebastianBergmann\CsvParser\ObjectMapper; use SebastianBergmann\CsvParser\Parser; use SebastianBergmann\CsvParser\Schema; use SebastianBergmann\CsvParser\Type; /** * This file is meant to be loaded by the php-fuzzer harness, * which provides $config before executing this script. */ assert(isset($config) && $config instanceof Config); /** * Build a Schema object that describes a 5-column CSV layout: * - Column 1: a string field * - Column 2: an integer field * - Column 3: a float field * - Column 4: a boolean field * - Column 5: an object field, using a custom inline ObjectMapper */ $schema = Schema::from( FieldDefinition::from(1, 'string', Type::string()), FieldDefinition::from(2, 'integer', Type::integer()), FieldDefinition::from(3, 'float', Type::float()), FieldDefinition::from(4, 'boolean', Type::boolean()), FieldDefinition::from(5, 'object', Type::object( new class implements ObjectMapper { public function map(string $value): stdClass { $obj = new stdClass; $obj->value = $value; return $obj; } }) ), ); /** * Register the fuzz target function with the fuzzer. * The fuzzer will repeatedly call this function with * different randomly generated/mutated byte strings. */ $config->setTarget( static function (string $input) use ($schema): void { /** * Guard against inputs shorter than 2 bytes, * since the function needs at least 2 bytes * for the separator and enclosure characters. */ if (strlen($input) < 2) { return; } /** * Use first byte of fuzz input as the CSV field separator character. * Use second byte of fuzz input as the CSV enclosure/quote character. * Everything after the first two bytes is treated as the raw CSV data to parse. */ $separator = $input[0]; $enclosure = $input[1]; $csvData = substr($input, 2); $parser = new Parser; try { $parser->setSeparator($separator); } catch (Exception) { // Ignore invalid separator } try { $parser->setEnclosure($enclosure); } catch (Exception) { // Ignore invalid enclosure } /** * Wrap the CSV data in a data:// stream URI. * The parser expects a file path or stream, so this converts the in-memory * string into a readable stream without writing a temporary file to disk. */ $csvFile = 'data://text/plain;base64,' . base64_encode($csvData); try { foreach ($parser->parse($csvFile, $schema) as $row) { // Just iterate to trigger any potential issues } } catch (Exception) { // Expected exceptions are fine } }, );
Hier ist der Anfang der Ausgabe, wenn wir PHP-Fuzzer mit unserem oben gezeigten Skript fuzzer.php ausführen:
❯ ./vendor/bin/php-fuzzer fuzz fuzzer.php corpus NEW run: 1 (3998/s), ft: 2 (7997/s), corp: 1 (1b), len: 1/1, t: 0s, mem: 3469kb NEW run: 6 (5565/s), ft: 18 (16696/s), corp: 2 (3b), len: 2/2, t: 0s, mem: 3580kb NEW run: 17 (12247/s), ft: 29 (20892/s), corp: 3 (6b), len: 3/3, t: 0s, mem: 3582kb NEW run: 18 (12138/s), ft: 31 (20904/s), corp: 4 (9b), len: 3/3, t: 0s, mem: 3584kb NEW run: 174 (78449/s), ft: 35 (15780/s), corp: 5 (13b), len: 4/4, t: 0s, mem: 3587kb NEW run: 736 (162329/s), ft: 38 (8381/s), corp: 6 (19b), len: 6/6, t: 0s, mem: 3589kb NEW run: 1458 (182361/s), ft: 43 (5378/s), corp: 7 (26b), len: 7/7, t: 0s, mem: 3595kb REDUCE run: 1772 (183473/s), ft: 43 (4452/s), corp: 7 (25b), len: 5/7, t: 0s, mem: 3596kb REDUCE run: 2014 (184738/s), ft: 43 (3944/s), corp: 7 (24b), len: 6/7, t: 0s, mem: 3598kb NEW run: 5821 (204281/s), ft: 53 (1860/s), corp: 8 (34b), len: 10/10, t: 0s, mem: 3599kb NEW run: 6659 (204014/s), ft: 56 (1716/s), corp: 9 (54b), len: 20/20, t: 0s, mem: 3603kb NEW run: 6870 (203447/s), ft: 58 (1718/s), corp: 10 (74b), len: 20/20, t: 0s, mem: 3606kb CRASH in /path/to/csv-parser/crash-64f7467e63f83c2267a2e9d6688fa7bc.txt! Error: [2] The float-string "55555555555555555555" is not representable as an int, cast occurred in /path/to/csv-parser/src/schema/type/IntegerType.php on line 21
Schauen wir uns den Befehl einmal genauer an:
-
./vendor/bin/php-fuzzerist die ausführbare Datei von PHP-Fuzzer -
fuzzist der Name des PHP-Fuzzer-Befehls, den wir ausführen wollen -
fuzzer.phpist der Pfad zu unserem Fuzzing-Skript (siehe oben) -
corpusist der Pfad zu dem Verzeichnis, in dem PHP-Fuzzer den Korpus speichern soll
Der Fuzzer hat numerische Zeichenfolgen generiert, die weit über dem Maximalwert lagen, den eine 64-Bit-Ganzzahl speichern kann. Seit PHP 8.5 gibt es eine Warnung, wenn so eine „Float-Zeichenkette“ in einen int umgewandelt werden soll. Der Code läuft zwar weiter, aber diese Warnung kann in fehlerempfindlichen Umgebungen Probleme verursachen oder die Protokolle überfluten. Das Wichtigste ist natürlich, dass die numerische Zeichenkette aus der CSV-Datei nicht richtig in eine PHP-Variable überführt wird.
Diese Änderungen wurden an der csv-parser Bibliothek vorgenommen, basierend auf den Ergebnissen des Fuzz Testings.
Wie PHP-Fuzzer funktioniert
Beim abdeckungsgesteuerten Fuzzing werden die Programmeingaben immer wieder verändert und das Programm wird dann ausgeführt, während die Ausführung von Codezweigen oder Basisblöcken gemessen wird. Eingaben, die die Codeabdeckung erhöhen, indem sie neue Zweige oder Pfade erreichen, werden als interessante Startpunkte gespeichert und zukünftige Mutationen werden auf sie ausgerichtet. Diese Rückkopplungsschleife treibt den Fuzzer mit der Zeit systematisch tiefer in den Zustandsraum des Programms und erhöht so die Wahrscheinlichkeit, dass Fehler ausgelöst werden.
Im Gegensatz zu herkömmlichen Quellen für Codeabdeckungsinformationen wie den PCOV- oder Xdebug-Erweiterungen für den PHP-Interpreter, die die Ausführung auf Engine-Ebene überwachen, nutzt PHP-Fuzzer einen Ansatz, der auf Code-Transformation basiert. Dadurch kann es den Ausführungsfluss verfolgen, ohne dass spezielle PHP-Erweiterungen installiert werden müssen.
PHP-Fuzzer nutzt aus mehreren Gründen Code-Transformation anstelle einer PHP-Erweiterung. Sowohl PCOV als auch Xdebug beeinträchtigen die Leistung der Codeausführung. Die Codeabdeckungsinstrumentierung mithilfe von Codetransformation kann optimiert werden, um den spezifischen Anforderungen eines Fuzzers gerecht zu werden, der nur wissen muss, ob eine Kante getroffen wurde, anstatt vollständige Software-Metriken zur Codeabdeckung zu benötigen. Darüber hinaus erfordert Fuzzing oft mehr als nur Zeilenabdeckung. Es erfordert auch Kantenabdeckung (Verfolgung von Übergängen zwischen Blöcken) und in einigen Fällen Wertprofilinformationen (Verfolgung der in Vergleichen verwendeten Werte). Die Codetransformation macht es einfach, benutzerdefinierte Logik einzufügen, um diese Informationen zu sammeln.
Zusammenfassend lässt sich sagen, dass PHP-Fuzzer deinen Code beim Laden umschreibt und Hunderte von winzigen Prüfpunkten einfügt, die jedes Mal, wenn ein neuer Pfad erkundet wird, an die Engine des Fuzzers zurückmelden.
Zwei Seiten einer Medaille?
Sowohl Fuzz Testing als auch eigenschaftsbasierte Tests generieren automatisch Eingaben und testen damit unseren Code. Sie können auch Eingaben, die Fehler verursachen, vereinfachen und auf ein Minimum reduzieren. Der entscheidende Unterschied liegt in der Programmierarbeit:
- Eigenschaftsbasierte Tests verlangen von uns ein tiefes Verständnis des Systems. Insbesondere müssen wir die Eigenschaften festlegen, die immer zutreffen sollten, und die Form der „interessanten“ Eingaben definieren. Der Aufwand ist zwar hoch, aber dafür laufen die Tests schnell, ähnlich wie Unit Tests, und lassen sich leicht in Continuous Integration-Pipelines integrieren.
- Fuzz Testing ist in seiner Grundform eine Black-Box-Methode, bei der der Fuzzer nur wenige Annahmen über das System trifft und Eingaben zufällig mutiert. Abdeckungsgesteuertes Fuzzing, wie beispielsweise mit PHP-Fuzzer, ist eine Grey-Box-Methode: Es nutzt Rückmeldungen zur Codeabdeckung, um Mutationen gezielt in Richtung unerforschter Pfade zu lenken, ohne eine vollständige Spezifikation des Systems zu benötigen. Um eine gute Abdeckung zu erreichen, muss der Fuzzer lange laufen – oft Stunden, Tage oder sogar Wochen.
Ein wichtiger Punkt ist, dass beim eigenschaftsbasierten Testen das Test Oracle explizit ist: Wir schreiben Zusicherungen, die unsere Erwartungen an das System definieren. Beim Fuzz Testing ist das Test Oracle jedoch oft implizit. Zum Beispiel ist „der Code sollte nicht abstürzen“ eine einfache, aber mächtige Eigenschaft.
Fazit
Das CSV-Parser-Beispiel zeigt, was Fuzz Testing so stark macht: Obwohl mit herkömmlichen Tests eine 100-prozentige Codeabdeckung erreicht wurde, wurde ein Fehler übersehen, der durch Fuzzing sofort aufgedeckt wurde. Eine umfassende Codeabdeckung garantiert keine umfassende Sicherheit. Eingabeprozessoren sind der kritische Schnittpunkt zwischen vertrauenswürdigen Systemen und nicht vertrauenswürdigen Daten, was sie zu einem Hauptziel für Angriffe macht. Während herkömmliche Tests das erwartete Verhalten anhand bekannter Eingaben überprüfen, sucht abdeckungsgesteuertes Fuzzing systematisch nach Schwachstellen, indem es unerwartete Vektoren nutzt, die Angreifer ausnutzen: jede Eingabe, die neue Codepfade erreicht, löst weitere Mutationen aus und vertieft so nach und nach die Erforschung des Zustandsraums der Anwendung.
Anstatt Chaos zu verhindern, entsteht Sicherheit dadurch, dass wir daraus lernen: indem wir zu Gegnern unserer eigenen Software werden und fehlerhafte Eingaben Lücken in der Validierungslogik aufdecken lassen. Jeder Absturz bietet eine Chance, die Abwehrmechanismen zu stärken. Der gehärtete Parser, der nun Grenzen explizit validiert, ist ein Beweis für dieses Prinzip: Resilienz entsteht nicht durch perfekte Vorausschau, sondern durch das Überstehen von Chaos. Durch die Integration von Fuzz Testing in kontinuierliche Arbeitsabläufe können Teams ihre Abwehrmechanismen vor den Angreifern einem Stresstest unterziehen und eine kontinuierliche Regressionserkennung ermöglichen.
Wenn du dein Verständnis von Fuzzing vertiefen möchtest, empfehle ich dir diese beiden ergänzenden Präsentationen: „Demystifying Fuzzer Behaviour“ gibt dir grundlegendes Wissen, indem es die Interaktion zwischen Fuzzern und Programmen anhand von Grundprinzipien untersucht und dir hilft, ein mentales Modell der Funktionsweise von Fuzzern zu entwickeln. „What the PHUZZ?!“ wendet diese Konzepte konkret auf die Sicherheit von Webanwendungen an und zeigt, wie durch abdeckungsgesteuertes Fuzzing Schwachstellen wie SQL Injection, Remote Code Execution und Cross-Site Scripting in PHP-Webanwendungen entdeckt werden können. Zusammen bilden sie einen natürlichen Übergang von der Theorie zur Praxis.