├── .github └── workflows │ └── main.yml ├── .gitignore ├── LICENSE ├── README.md ├── bin └── php-fuzzer ├── box.json ├── composer.json ├── example ├── php.dict ├── target_css_parser.php ├── target_php_parser.php ├── target_simple.php └── target_tolerant_php_parser.php ├── phpstan-baseline.neon ├── phpstan.neon ├── phpunit.xml.dist ├── scoper.inc.php ├── src ├── Config.php ├── Corpus.php ├── CorpusEntry.php ├── CoverageRenderer.php ├── DictionaryParser.php ├── Fuzzer.php ├── FuzzerException.php ├── FuzzingContext.php ├── Instrumentation │ ├── Context.php │ ├── FileInfo.php │ ├── Instrumentor.php │ ├── MutableString.php │ └── Visitor.php ├── Mutation │ ├── Dictionary.php │ ├── Mutator.php │ └── RNG.php └── Util.php └── test ├── DictionaryParserTest.php ├── Instrumentation └── InstrumentorTest.php ├── Mutation └── MutatorTest.php └── PhpFuzzer └── UtilTest.php /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: Main 2 | on: [push, pull_request] 3 | 4 | jobs: 5 | tests: 6 | runs-on: "ubuntu-latest" 7 | name: "PHP ${{ matrix.php-version }} Tests" 8 | strategy: 9 | matrix: 10 | php-version: 11 | - "7.4" 12 | - "8.0" 13 | - "8.1" 14 | - "8.2" 15 | - "8.3" 16 | - "8.4" 17 | steps: 18 | - name: "Checkout" 19 | uses: "actions/checkout@v3" 20 | - name: "Install PHP" 21 | uses: "shivammathur/setup-php@v2" 22 | - name: "Install dependencies" 23 | run: "composer install --no-progress" 24 | - name: "Run tests" 25 | run: "vendor/bin/phpunit" 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | vendor/ 3 | composer.lock 4 | .phpunit.result.cache 5 | *.swp -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 2 | 3 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 4 | 5 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | PHP Fuzzer 2 | ========== 3 | 4 | This library implements a [fuzzer](https://en.wikipedia.org/wiki/Fuzzing) for PHP, 5 | which can be used to find bugs in libraries (particularly parsing libraries) by feeding 6 | them "random" inputs. Feedback from edge coverage instrumentation is used to guide the 7 | choice of "random" inputs, such that new code paths are visited. 8 | 9 | Installation 10 | ------------ 11 | 12 | **Phar (recommended)**: You can download a phar package of this library from the 13 | [releases page](https://github.com/nikic/PHP-Fuzzer/releases). Using the phar is recommended, 14 | because it avoids dependency conflicts with libraries using PHP-Parser. 15 | 16 | **Composer**: `composer global require nikic/php-fuzzer` 17 | 18 | Usage 19 | ----- 20 | 21 | First, a definition of the target function is necessary. Here is an example target for 22 | finding bugs in [microsoft/tolerant-php-parser](https://github.com/microsoft/tolerant-php-parser): 23 | 24 | ```php 25 | setTarget(function(string $input) use($parser) { 36 | $parser->parseSourceFile($input); 37 | }); 38 | 39 | // Optional: Many targets don't exhibit bugs on large inputs that can't also be 40 | // produced with small inputs. Limiting the length may improve performance. 41 | $config->setMaxLen(1024); 42 | // Optional: A dictionary can be used to provide useful fragments to the fuzzer, 43 | // such as language keywords. This is particularly important if these 44 | // cannot be easily discovered by the fuzzer, because they are handled 45 | // by a non-instrumented PHP extension function such as token_get_all(). 46 | $config->addDictionary('example/php.dict'); 47 | ``` 48 | 49 | The fuzzer is run against a corpus of initial "interesting" inputs, which can for example 50 | be seeded based on existing unit tests. If no corpus is specified, a temporary corpus 51 | directory will be created instead. 52 | 53 | ```shell script 54 | # Run without initial corpus 55 | php-fuzzer fuzz target.php 56 | # Run with initial corpus (one input per file) 57 | php-fuzzer fuzz target.php corpus/ 58 | ``` 59 | 60 | If fuzzing is interrupted, it can later be resumed by specifying the same corpus directory. 61 | 62 | Once a crash has been found, it is written into a `crash-HASH.txt` file. It is provided in the 63 | form it was originally found, which may be unnecessarily complex and contain fragments not 64 | relevant to the crash. As such, you likely want to reduce the crashing input first: 65 | 66 | ```shell script 67 | php-fuzzer minimize-crash target.php crash-HASH.txt 68 | ``` 69 | 70 | This will product a sequence of successively smaller `minimized-HASH.txt` files. If you want to 71 | quickly check the exception trace produced for a crashing input, you can use the `run-single` 72 | command: 73 | 74 | ```shell script 75 | php-fuzzer run-single target.php minimized-HASH.txt 76 | ``` 77 | 78 | Finally, it is possible to generate a HTML code coverage report, which shows which code blocks in 79 | the target are hit when executing inputs from a given corpus: 80 | 81 | ```shell script 82 | php-fuzzer report-coverage target.php corpus/ coverage_dir/ 83 | ``` 84 | 85 | Additionally configuration options can be shown with `php-fuzzer --help`. 86 | 87 | Bug types 88 | --------- 89 | 90 | The fuzzer by default detects three kinds of bugs: 91 | 92 | * `Error` exceptions thrown by the fuzzing target. While `Exception` exceptions are considered a normal result for 93 | malformed input, uncaught `Error` exceptions always indicate programming error. They are most commonly produced by 94 | PHP itself, for example when calling a method on `null`. 95 | * Thrown notices and warnings (unless they are suppressed). The fuzzer registers an error handler that converts these 96 | to `Error` exceptions. 97 | * Timeouts. If the target runs longer than the specified timeout (default: 3s), it is assumed that the target has gone 98 | into an infinite loop. This is realized using `pcntl_alarm()` and an async signal handler that throws an `Error` on 99 | timeout. 100 | 101 | Notably, none of these check whether the output of the target is correct, they only determine that the target does not 102 | misbehave egregiously. One way to check output correctness is to compare two different implementations that are supposed 103 | to produce identical results: 104 | 105 | ```php 106 | $fuzzer->setTarget(function(string $input) use($parser1, $parser2) { 107 | $result1 = $parser1->parse($input); 108 | $result2 = $parser2->parse($input); 109 | if ($result1 != $result2) { 110 | throw new Error('Results do not match!'); 111 | } 112 | }); 113 | ``` 114 | 115 | Technical 116 | --------- 117 | 118 | Many of the technical details of this fuzzer are based on [libFuzzer](https://llvm.org/docs/LibFuzzer.html) 119 | from the LLVM project. The following describes some of the implementation details. 120 | 121 | ### Instrumentation 122 | 123 | To work efficiently, fuzzing requires feedback regarding the code-paths that were executed while testing a particular 124 | fuzzing input. This coverage feedback is collected by "instrumenting" the fuzzing target. The 125 | [include-interceptor](https://github.com/nikic/include-interceptor) library is used to transform the code of all 126 | included files on the fly. The [PHP-Parser](https://github.com/nikic/PHP-Parser) library is used to parse the code and 127 | find all the places where additional instrumentation code needs to be inserted. 128 | 129 | Inside every basic block, the following code is inserted, where `BLOCK_INDEX` is a unique, per-block integer: 130 | 131 | ```php 132 | $___key = (\PhpFuzzer\FuzzingContext::$prevBlock << 28) | BLOCK_INDEX; 133 | \PhpFuzzer\FuzzingContext::$edges[$___key] = (\PhpFuzzer\FuzzingContext::$edges[$___key] ?? 0) + 1; 134 | \PhpFuzzer\FuzzingContext::$prevBlock = BLOCK_INDEX; 135 | ``` 136 | 137 | This assumes that the block index is at most 28-bit large and counts the number of `(prev_block, cur_block)` pairs 138 | that are observed during execution. The generated code is unfortunately fairly expensive, due to the need to deal with 139 | uninitialized edge counts, and the use of static properties. In the future, it would be possible to create a PHP 140 | extension that can collect the coverage feedback much more efficiently. 141 | 142 | In some cases, basic blocks are part of expressions, in which case we cannot easily insert additional code. In these 143 | cases we instead insert a call to a method that contains the above code: 144 | 145 | ```php 146 | if ($foo && $bar) { ... } 147 | // becomes 148 | if ($foo && \PhpFuzzer\FuzzingContext::traceBlock(BLOCK_INDEX, $bar)) { ... } 149 | ``` 150 | 151 | In the future, it would be beneficial to also instrument comparisons, such that we can automatically determine 152 | dictionary entries from comparisons like `$foo == "SOME_STRING"`. 153 | 154 | ### Features 155 | 156 | Fuzzing inputs are considered "interesting" if they contain new features that have not been observed with other inputs 157 | that are already part of the corpus. This library uses course-grained edge hit counts as features: 158 | 159 | ft = (approx_hits << 56) | (prev_block << 28) | cur_block 160 | 161 | The approximate hit count reduces the actual hit count to 8 categories (based on AFL): 162 | 163 | 0: 0 hits 164 | 1: 1 hit 165 | 2: 2 hits 166 | 3: 3 hits 167 | 4: 4-7 hits 168 | 5: 8-15 hits 169 | 6: 16-127 hits 170 | 7: >=128 hits 171 | 172 | As such, each input is associated with a set of integers representing features. Additionally, it has a set of "unique 173 | features", which are features not seen in any other corpus inputs at the time the input was tested. 174 | 175 | If an input has unique features, then it is added to the corpus (NEW). If an input B was created by mutating an input A, 176 | but input B is shorter and has all the unique features of input A, then A is replaced by B in the corpus (REDUCE). 177 | 178 | ### Mutation 179 | 180 | On each iteration, a random input from the current corpus is chosen, and then mutated using a sequence of mutators. The 181 | following mutators (taken from libFuzzer) are currently implemented: 182 | 183 | * `EraseBytes`: Remove a number of bytes. 184 | * `InsertByte`: Insert a new random byte. 185 | * `InsertRepeatedBytes`: Insert a random byte repeated multiple times. 186 | * `ChangeByte`: Replace a byte with a random byte. 187 | * `ChangeBit`: Flip a single bit. 188 | * `ShuffleBytes`: Shuffle a small substring. 189 | * `ChangeASCIIInt`: Change an ASCII integer by incrementing/decrementing/doubling/halving. 190 | * `ChangeBinInt`: Change a binary integer by adding a small random amount. 191 | * `CopyPart`: Copy part of the string into another part, either by overwriting or inserting. 192 | * `CrossOver`: Cross over with another corpus entry with multiple strategies. 193 | * `AddWordFromManualDictionary`: Insert or overwrite with a word from the dictionary (if any). 194 | 195 | Mutation is subject to a maximum length constrained. While an overall maximum length can be specified by the target 196 | (`setMaxLength()`), the fuzzer also performs automatic length control (`--len-control-factor`). The maximum length 197 | is initially set to a very low value and then increased by `log(maxlen)` whenever no action (NEW or REDUCE) has been 198 | taken for the last `len_control_factor * log(maxlen)` runs. 199 | 200 | The higher the length control factor, the more aggressively the fuzzer will explore short inputs before allowing longer 201 | inputs. This significantly reduces the size of the generated corpus, but makes initial exploration slower. 202 | 203 | Findings 204 | -------- 205 | 206 | * [tolerant-php-parser](https://github.com/microsoft/tolerant-php-parser): 207 | [#305](https://github.com/microsoft/tolerant-php-parser/issues/305) 208 | * [PHP-CSS-Parser](https://github.com/sabberworm/PHP-CSS-Parser): 209 | [#181](https://github.com/sabberworm/PHP-CSS-Parser/issues/181) 210 | [#182](https://github.com/sabberworm/PHP-CSS-Parser/issues/182) 211 | [#183](https://github.com/sabberworm/PHP-CSS-Parser/issues/183) 212 | [#184](https://github.com/sabberworm/PHP-CSS-Parser/issues/184) 213 | * [league/uri](https://github.com/thephpleague/uri): 214 | [#150](https://github.com/thephpleague/uri/issues/150) 215 | * [amphp/http-client](https://github.com/amphp/http-client) 216 | [#236](https://github.com/amphp/http-client/issues/236) 217 | * [amphp/hpack](https://github.com/amphp/hpack) 218 | [#8](https://github.com/amphp/hpack/issues/8) 219 | * [phpmyadmin/sql-parser](https://github.com/phpmyadmin/sql-parser): 220 | [#508](https://github.com/phpmyadmin/sql-parser/issues/508) 221 | [#510](https://github.com/phpmyadmin/sql-parser/pull/510) 222 | * [club-1/sphinx-inventory-parser](https://github.com/club-1/sphinx-inventory-parser): 223 | [#7](https://github.com/club-1/sphinx-inventory-parser/pull/7) 224 | -------------------------------------------------------------------------------- /bin/php-fuzzer: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env php 2 | handleCliArgs()); 24 | -------------------------------------------------------------------------------- /box.json: -------------------------------------------------------------------------------- 1 | { 2 | "compactors": [ 3 | "KevinGH\\Box\\Compactor\\PhpScoper" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nikic/php-fuzzer", 3 | "license": "MIT", 4 | "require": { 5 | "php": ">= 7.4", 6 | "nikic/php-parser": "^5.0", 7 | "nikic/include-interceptor": "^0.1.1", 8 | "ulrichsg/getopt-php": "^4.0" 9 | }, 10 | "require-dev": { 11 | "phpunit/phpunit": "^9", 12 | "phpstan/phpstan": "^1.10" 13 | }, 14 | "suggest": { 15 | "ext-pcntl": "Needed for timeout support" 16 | }, 17 | "autoload": { 18 | "psr-4": { 19 | "PhpFuzzer\\": "src/" 20 | } 21 | }, 22 | "bin": ["bin/php-fuzzer"] 23 | } 24 | -------------------------------------------------------------------------------- /example/php.dict: -------------------------------------------------------------------------------- 1 | "" 4 | "exit" 5 | "die" 6 | "fn" 7 | "function" 8 | "const" 9 | "return" 10 | "yield" 11 | "yield from" 12 | "try" 13 | "catch" 14 | "finally" 15 | "throw" 16 | "if" 17 | "elseif" 18 | "endif" 19 | "else" 20 | "while" 21 | "endwhile" 22 | "do" 23 | "for" 24 | "endfor" 25 | "foreach" 26 | "endforeach" 27 | "declare" 28 | "enddeclare" 29 | "instanceof" 30 | "as" 31 | "switch" 32 | "endswitch" 33 | "case" 34 | "default" 35 | "break" 36 | "continue" 37 | "goto" 38 | "echo" 39 | "print" 40 | "class" 41 | "interface" 42 | "trait" 43 | "extends" 44 | "implements" 45 | "new" 46 | "clone" 47 | "var" 48 | "int" 49 | "integer" 50 | "float" 51 | "double" 52 | "real" 53 | "string" 54 | "binary" 55 | "array" 56 | "object" 57 | "bool" 58 | "boolean" 59 | "unset" 60 | "eval" 61 | "include" 62 | "include_once" 63 | "require" 64 | "require_once" 65 | "namespace" 66 | "use" 67 | "insteadof" 68 | "global" 69 | "isset" 70 | "empty" 71 | "__halt_compiler" 72 | "static" 73 | "abstract" 74 | "final" 75 | "private" 76 | "protected" 77 | "public" 78 | "unset" 79 | "list" 80 | "callable" 81 | "enum" 82 | "readonly" 83 | "__class__" 84 | "__trait__" 85 | "__function__" 86 | "__method__" 87 | "__line__" 88 | "__file__" 89 | "__dir__" 90 | "__namespace__" 91 | -------------------------------------------------------------------------------- /example/target_css_parser.php: -------------------------------------------------------------------------------- 1 | setTarget(function(string $input) { 14 | $parser = new Sabberworm\CSS\Parser($input); 15 | $parser->parse(); 16 | }); 17 | -------------------------------------------------------------------------------- /example/target_php_parser.php: -------------------------------------------------------------------------------- 1 | setTarget(function(string $input) use($parser, $prettyPrinter) { 23 | $stmts = $parser->parse($input); 24 | $prettyPrinter->prettyPrintFile($stmts); 25 | }); 26 | 27 | $config->setMaxLen(1024); 28 | $config->addDictionary(__DIR__ . '/php.dict'); 29 | $config->setAllowedExceptions([PhpParser\Error::class]); 30 | -------------------------------------------------------------------------------- /example/target_simple.php: -------------------------------------------------------------------------------- 1 | setTarget(function(string $input) { 5 | if (strlen($input) >= 4 && $input[0] == 'z' && $input[3] == 'k') { 6 | throw new Error('Bug!'); 7 | } 8 | }); 9 | -------------------------------------------------------------------------------- /example/target_tolerant_php_parser.php: -------------------------------------------------------------------------------- 1 | setTarget(function(string $input) use($parser) { 16 | $parser->parseSourceFile($input); 17 | }); 18 | 19 | $config->setMaxLen(1024); 20 | $config->addDictionary(__DIR__ . '/php.dict'); 21 | -------------------------------------------------------------------------------- /phpstan-baseline.neon: -------------------------------------------------------------------------------- 1 | parameters: 2 | ignoreErrors: 3 | - 4 | message: "#^Method PhpFuzzer\\\\Fuzzer\\:\\:handleFuzzCommand\\(\\) has parameter \\$getOpt with no value type specified in iterable type GetOpt\\\\GetOpt\\.$#" 5 | count: 1 6 | path: src/Fuzzer.php 7 | 8 | - 9 | message: "#^Method PhpFuzzer\\\\Fuzzer\\:\\:handleMinimizeCrashCommand\\(\\) has parameter \\$getOpt with no value type specified in iterable type GetOpt\\\\GetOpt\\.$#" 10 | count: 1 11 | path: src/Fuzzer.php 12 | 13 | - 14 | message: "#^Method PhpFuzzer\\\\Fuzzer\\:\\:handleReportCoverage\\(\\) has parameter \\$getOpt with no value type specified in iterable type GetOpt\\\\GetOpt\\.$#" 15 | count: 1 16 | path: src/Fuzzer.php 17 | 18 | - 19 | message: "#^Method PhpFuzzer\\\\Fuzzer\\:\\:handleRunSingleCommand\\(\\) has parameter \\$getOpt with no value type specified in iterable type GetOpt\\\\GetOpt\\.$#" 20 | count: 1 21 | path: src/Fuzzer.php 22 | 23 | - 24 | message: "#^Access to an undefined property PhpParser\\\\Node\\:\\:\\$stmts\\.$#" 25 | count: 1 26 | path: src/Instrumentation/Visitor.php 27 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - phpstan-baseline.neon 3 | 4 | parameters: 5 | level: 6 6 | paths: 7 | - src 8 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | test 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /scoper.inc.php: -------------------------------------------------------------------------------- 1 | ['PhpFuzzer\Config'], 5 | ]; 6 | -------------------------------------------------------------------------------- /src/Config.php: -------------------------------------------------------------------------------- 1 | > */ 15 | public array $allowedExceptions = [\Exception::class]; 16 | public int $maxLen = PHP_INT_MAX; 17 | 18 | public function __construct() { 19 | $this->dictionary = new Dictionary(); 20 | } 21 | 22 | /** 23 | * Set the fuzzing target. 24 | */ 25 | public function setTarget(\Closure $target): void { 26 | $this->target = $target; 27 | } 28 | 29 | /** 30 | * Set which exceptions are not considered as fuzzing failures. 31 | * Defaults to just "Exception", considering all "Errors" failures. 32 | * 33 | * @param list> $allowedExceptions 34 | */ 35 | public function setAllowedExceptions(array $allowedExceptions): void { 36 | $this->allowedExceptions = $allowedExceptions; 37 | } 38 | 39 | public function setMaxLen(int $maxLen): void { 40 | $this->maxLen = $maxLen; 41 | } 42 | 43 | public function addDictionary(string $path): void { 44 | if (!is_file($path)) { 45 | throw new FuzzerException('Dictionary "' . $path . '" does not exist'); 46 | } 47 | 48 | $parser = new DictionaryParser(); 49 | $this->dictionary->addWords($parser->parse(file_get_contents($path))); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Corpus.php: -------------------------------------------------------------------------------- 1 | */ 13 | private array $seenFeatures = []; 14 | 15 | /** @var array */ 16 | private array $seenCrashFeatures = []; 17 | 18 | private int $totalLen = 0; 19 | private int $maxLen = 0; 20 | 21 | public function computeUniqueFeatures(CorpusEntry $entry): void { 22 | $entry->uniqueFeatures = []; 23 | foreach ($entry->features as $feature => $_) { 24 | if (!isset($this->seenFeatures[$feature])) { 25 | $entry->uniqueFeatures[$feature] = true; 26 | } 27 | } 28 | } 29 | 30 | public function addEntry(CorpusEntry $entry): void { 31 | $this->entriesByHash[$entry->hash] = $entry; 32 | $this->entriesByIndex[] = $entry; 33 | foreach ($entry->uniqueFeatures as $feature => $_) { 34 | $this->seenFeatures[$feature] = true; 35 | } 36 | $len = \strlen($entry->input); 37 | $this->totalLen += $len; 38 | $this->maxLen = max($this->maxLen, $len); 39 | } 40 | 41 | // Returns whether the new entry has been added. The old one will always be removed. 42 | public function replaceEntry(CorpusEntry $origEntry, CorpusEntry $newEntry): bool { 43 | unset($this->entriesByHash[$origEntry->hash]); 44 | $this->entriesByIndex = array_values($this->entriesByHash); // TODO optimize 45 | if (isset($this->entriesByHash[$newEntry->hash])) { 46 | // The new entry is already part of the corpus, nothing to do. 47 | return false; 48 | } 49 | 50 | $this->entriesByHash[$newEntry->hash] = $newEntry; 51 | $this->entriesByIndex[] = $newEntry; 52 | $this->totalLen -= \strlen($origEntry->input); 53 | $this->totalLen += \strlen($newEntry->input); 54 | return true; 55 | } 56 | 57 | public function getRandomEntry(RNG $rng): ?CorpusEntry { 58 | if (empty($this->entriesByHash)) { 59 | return null; 60 | } 61 | 62 | return $rng->randomElement($this->entriesByIndex); 63 | } 64 | 65 | public function getNumCorpusEntries(): int { 66 | return \count($this->entriesByHash); 67 | } 68 | 69 | public function getNumFeatures(): int { 70 | return \count($this->seenFeatures); 71 | } 72 | 73 | public function getTotalLen(): int { 74 | return $this->totalLen; 75 | } 76 | 77 | public function getMaxLen(): int { 78 | return $this->maxLen; 79 | } 80 | 81 | /** 82 | * @return array 83 | */ 84 | public function getSeenBlockMap(): array { 85 | $blocks = []; 86 | foreach ($this->seenFeatures as $feature => $_) { 87 | $targetBlock = $feature & ((1 << 28) - 1); 88 | $blocks[$targetBlock] = true; 89 | } 90 | return $blocks; 91 | } 92 | 93 | public function addCrashEntry(CorpusEntry $entry): bool { 94 | // TODO: Also handle "absent feature"? 95 | $hasNewFeature = false; 96 | foreach ($entry->features as $feature => $_) { 97 | if (!isset($this->seenCrashFeatures[$feature])) { 98 | $hasNewFeature = true; 99 | $this->seenCrashFeatures[$feature] = true; 100 | } 101 | } 102 | return $hasNewFeature; 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/CorpusEntry.php: -------------------------------------------------------------------------------- 1 | */ 9 | public array $features; 10 | public ?string $crashInfo; 11 | public ?string $path; 12 | /** @var array */ 13 | public array $uniqueFeatures; 14 | 15 | /** 16 | * @param array $features 17 | */ 18 | public function __construct(string $input, array $features, ?string $crashInfo) { 19 | $this->input = $input; 20 | $this->hash = \md5($input); 21 | $this->features = $features; 22 | $this->crashInfo = $crashInfo; 23 | $this->path = null; 24 | } 25 | 26 | public function hasAllUniqueFeaturesOf(CorpusEntry $other): bool { 27 | foreach ($other->uniqueFeatures as $feature => $_) { 28 | if (!isset($this->features[$feature])) { 29 | return false; 30 | } 31 | } 32 | return true; 33 | } 34 | 35 | public function storeAtPath(string $path): void { 36 | assert($this->path === null); 37 | $this->path = $path; 38 | $result = file_put_contents($this->path, $this->input); 39 | assert($result === \strlen($this->input)); 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/CoverageRenderer.php: -------------------------------------------------------------------------------- 1 | outDir = $outDir; 12 | } 13 | 14 | /** 15 | * @param FileInfo[] $fileInfos 16 | * @param array $seenBlocks 17 | */ 18 | public function render(array $fileInfos, array $seenBlocks): void { 19 | @mkdir($this->outDir); 20 | 21 | $overview = "\n"; 22 | 23 | $prefix = Util::getCommonPathPrefix(array_keys($fileInfos)); 24 | $totalNumCovered = 0; 25 | $totalNumTotal = 0; 26 | ksort($fileInfos); 27 | foreach ($fileInfos as $path => $fileInfo) { 28 | $posToBlockIndex = array_flip($fileInfo->blockIndexToPos); 29 | ksort($posToBlockIndex); 30 | 31 | $code = file_get_contents($path); 32 | $result = '
';
33 |             $lastPos = 0;
34 |             $numCovered = 0;
35 |             $numTotal = count($posToBlockIndex);
36 |             foreach ($posToBlockIndex as $pos => $blockIndex) {
37 |                 $result .= htmlspecialchars(\substr($code, $lastPos, $pos - $lastPos));
38 |                 $covered = isset($seenBlocks[$blockIndex]);
39 |                 $numCovered += $covered;
40 |                 $color = $covered ? "green" : "red";
41 |                 $result .= '' . $code[$pos] . '';
42 |                 $lastPos = $pos + 1;
43 |             }
44 |             $result .= htmlspecialchars(\substr($code, $lastPos));
45 |             $result .= '
'; 46 | 47 | $shortPath = str_replace($prefix, '', $path); 48 | $outPath = $this->outDir . '/' . $shortPath . '.html'; 49 | @mkdir(dirname($outPath), 0777, true); 50 | file_put_contents($outPath, $result); 51 | 52 | $overview .= << 54 | 55 | 56 | 57 | HTML; 58 | 59 | $totalNumCovered += $numCovered; 60 | $totalNumTotal += $numTotal; 61 | } 62 | 63 | $overview .= ""; 64 | $overview .= '
$shortPath$numCovered/$numTotal
Total$totalNumCovered/$totalNumTotal
'; 65 | file_put_contents($this->outDir . '/index.html', $overview); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/DictionaryParser.php: -------------------------------------------------------------------------------- 1 | 8 | */ 9 | public function parse(string $code): array { 10 | $lines = explode("\n", $code); 11 | $dictionary = []; 12 | foreach ($lines as $idx => $line) { 13 | $line = trim($line); 14 | if (\strlen($line) === 0) { 15 | continue; 16 | } 17 | 18 | if ($line[0] === '#') { 19 | continue; 20 | } 21 | 22 | $regex = '/(?:\w+=)?"([^"\\\\]*(?:(?:\\\\(?:["\\\\]|x[0-9a-zA-Z]{2}))[^"\\\\]*)*)"/'; 23 | if (!preg_match($regex, $line, $match)) { 24 | throw new \Exception('Line ' . ($idx+1) . ' of dictionary is invalid'); 25 | } 26 | 27 | $escapedDictEntry = $match[1]; 28 | $dictionary[] = preg_replace_callback('/\\\\(["\\\\]|x[0-9a-zA-Z]{2})/', function($match) { 29 | $escape = $match[1]; 30 | if ($escape[0] === 'x') { 31 | return chr(hexdec(substr($escape, 1))); 32 | } 33 | return $escape; 34 | }, $escapedDictEntry); 35 | } 36 | return $dictionary; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/Fuzzer.php: -------------------------------------------------------------------------------- 1 | */ 32 | private array $fileInfos = []; 33 | private ?string $lastInput = null; 34 | 35 | private int $runs = 0; 36 | private int $lastInterestingRun = 0; 37 | private int $initialFeatures; 38 | private float $startTime; 39 | private int $mutationDepthLimit = 5; 40 | private int $maxRuns = PHP_INT_MAX; 41 | private int $lenControlFactor = 200; 42 | private int $timeout = 3; 43 | 44 | // Counts all crashes, including duplicates 45 | private int $crashes = 0; 46 | private int $maxCrashes = 100; 47 | 48 | public function __construct() { 49 | $this->outputDir = getcwd(); 50 | $this->instrumentor = new Instrumentor( 51 | FuzzingContext::class, PhpVersion::getHostVersion()); 52 | $this->rng = new RNG(); 53 | $this->config = new Config(); 54 | $this->mutator = new Mutator($this->rng, $this->config->dictionary); 55 | $this->corpus = new Corpus(); 56 | 57 | // Instrument everything apart from our src/ directory. 58 | $fileFilter = FileFilter::createAllWhitelisted(); 59 | $fileFilter->addBlackList(__DIR__); 60 | // Only intercept file:// streams. Interception of phar:// streams may run into 61 | // incorrect stat() handling during path resolution in PHP. 62 | $protocols = ['file']; 63 | $this->interceptor = new Interceptor(function(string $path) use($fileFilter) { 64 | if (!$fileFilter->test($path)) { 65 | return null; 66 | } 67 | 68 | $code = file_get_contents($path); 69 | $fileInfo = new FileInfo(); 70 | $instrumentedCode = $this->instrumentor->instrument($code, $fileInfo); 71 | $this->fileInfos[$path] = $fileInfo; 72 | return $instrumentedCode; 73 | }, $protocols); 74 | } 75 | 76 | private function loadTarget(string $path): void { 77 | if (!is_file($path)) { 78 | throw new FuzzerException('Target "' . $path . '" does not exist'); 79 | } 80 | 81 | $this->targetPath = $path; 82 | $this->startInstrumentation(); 83 | // Unbind $this and make config available as $config variable. 84 | (static function(Config $config) use($path) { 85 | $fuzzer = $config; // For backwards compatibility. 86 | require $path; 87 | })($this->config); 88 | } 89 | 90 | public function setCorpusDir(string $path): void { 91 | $this->corpusDir = $path; 92 | if (!is_dir($this->corpusDir)) { 93 | throw new FuzzerException('Corpus directory "' . $this->corpusDir . '" does not exist'); 94 | } 95 | } 96 | 97 | public function setCoverageDir(string $path): void { 98 | $this->coverageDir = $path; 99 | } 100 | 101 | public function startInstrumentation(): void { 102 | $this->interceptor->setUp(); 103 | } 104 | 105 | public function fuzz(): void { 106 | if (!$this->loadCorpus()) { 107 | return; 108 | } 109 | 110 | // Start with a short maximum length, increase if we fail to make progress. 111 | $maxLen = min($this->config->maxLen, max(4, $this->corpus->getMaxLen())); 112 | 113 | // Don't count runs while loading the corpus. 114 | $this->runs = 0; 115 | $this->startTime = microtime(true); 116 | while ($this->runs < $this->maxRuns) { 117 | $origEntry = $this->corpus->getRandomEntry($this->rng); 118 | $input = $origEntry !== null ? $origEntry->input : ""; 119 | $crossOverEntry = $this->corpus->getRandomEntry($this->rng); 120 | $crossOverInput = $crossOverEntry !== null ? $crossOverEntry->input : null; 121 | for ($m = 0; $m < $this->mutationDepthLimit; $m++) { 122 | $input = $this->mutator->mutate($input, $maxLen, $crossOverInput); 123 | $entry = $this->runInput($input); 124 | if ($entry->crashInfo) { 125 | if ($this->corpus->addCrashEntry($entry)) { 126 | $entry->storeAtPath($this->outputDir . '/crash-' . $entry->hash . '.txt'); 127 | $this->printCrash('CRASH', $entry); 128 | } else { 129 | echo "DUPLICATE CRASH\n"; 130 | } 131 | if (++$this->crashes >= $this->maxCrashes) { 132 | echo "Maximum of {$this->maxCrashes} crashes reached, aborting\n"; 133 | return; 134 | } 135 | break; 136 | } 137 | 138 | $this->corpus->computeUniqueFeatures($entry); 139 | if ($entry->uniqueFeatures) { 140 | $this->corpus->addEntry($entry); 141 | $entry->storeAtPath($this->corpusDir . '/' . $entry->hash . '.txt'); 142 | 143 | $this->lastInterestingRun = $this->runs; 144 | $this->printAction('NEW', $entry); 145 | break; 146 | } 147 | 148 | if ($origEntry !== null && 149 | \strlen($entry->input) < \strlen($origEntry->input) && 150 | $entry->hasAllUniqueFeaturesOf($origEntry) 151 | ) { 152 | // Preserve unique features of original entry, 153 | // even if they are not unique anymore at this point. 154 | $entry->uniqueFeatures = $origEntry->uniqueFeatures; 155 | if ($this->corpus->replaceEntry($origEntry, $entry)) { 156 | $entry->storeAtPath($this->corpusDir . '/' . $entry->hash . '.txt'); 157 | } 158 | unlink($origEntry->path); 159 | 160 | $this->lastInterestingRun = $this->runs; 161 | $this->printAction('REDUCE', $entry); 162 | break; 163 | } 164 | } 165 | 166 | if ($maxLen < $this->config->maxLen) { 167 | // Increase max length if we haven't made progress in a while. 168 | $logMaxLen = (int) log($maxLen, 2); 169 | if (($this->runs - $this->lastInterestingRun) > $this->lenControlFactor * $logMaxLen) { 170 | $maxLen = min($this->config->maxLen, $maxLen + $logMaxLen); 171 | $this->lastInterestingRun = $this->runs; 172 | } 173 | } 174 | } 175 | } 176 | 177 | private function isAllowedException(\Throwable $e): bool { 178 | foreach ($this->config->allowedExceptions as $allowedException) { 179 | if ($e instanceof $allowedException) { 180 | return true; 181 | } 182 | } 183 | return false; 184 | } 185 | 186 | private function runInput(string $input): CorpusEntry { 187 | $this->runs++; 188 | if (\extension_loaded('pcntl')) { 189 | \pcntl_alarm($this->timeout); 190 | } 191 | 192 | // Remember the last input in case PHP generates a fatal error. 193 | $this->lastInput = $input; 194 | FuzzingContext::reset(); 195 | $crashInfo = null; 196 | try { 197 | ($this->config->target)($input); 198 | } catch (\ParseError $e) { 199 | echo "PARSE ERROR $e\n"; 200 | echo "INSTRUMENTATION BROKEN? -- ABORTING"; 201 | exit(-1); 202 | } catch (\Throwable $e) { 203 | if (!$this->isAllowedException($e)) { 204 | $crashInfo = (string) $e; 205 | } 206 | } 207 | 208 | $features = $this->edgeCountsToFeatures(FuzzingContext::$edges); 209 | return new CorpusEntry($input, $features, $crashInfo); 210 | } 211 | 212 | /** 213 | * @param array $edgeCounts 214 | * @return array 215 | */ 216 | private function edgeCountsToFeatures(array $edgeCounts): array { 217 | $features = []; 218 | foreach ($edgeCounts as $edge => $count) { 219 | $feature = $this->edgeCountToFeature($edge, $count); 220 | $features[$feature] = true; 221 | } 222 | return $features; 223 | } 224 | 225 | private function edgeCountToFeature(int $edge, int $count): int { 226 | if ($count < 4) { 227 | $encodedCount = $count - 1; 228 | } else if ($count < 8) { 229 | $encodedCount = 3; 230 | } else if ($count < 16) { 231 | $encodedCount = 4; 232 | } else if ($count < 32) { 233 | $encodedCount = 5; 234 | } else if ($count < 128) { 235 | $encodedCount = 6; 236 | } else { 237 | $encodedCount = 7; 238 | } 239 | return $encodedCount << 56 | $edge; 240 | } 241 | 242 | private function loadCorpus(): bool { 243 | $it = new \RecursiveIteratorIterator( 244 | new \RecursiveDirectoryIterator($this->corpusDir), 245 | \RecursiveIteratorIterator::LEAVES_ONLY 246 | ); 247 | $entries = []; 248 | foreach ($it as $file) { 249 | if (!$file->isFile()) { 250 | continue; 251 | } 252 | 253 | $path = $file->getPathname(); 254 | $input = file_get_contents($path); 255 | $entry = $this->runInput($input); 256 | $entry->path = $path; 257 | if ($entry->crashInfo) { 258 | $this->printCrash("CORPUS CRASH", $entry); 259 | return false; 260 | } 261 | 262 | $entries[] = $entry; 263 | } 264 | 265 | // Favor short entries. 266 | usort($entries, function (CorpusEntry $a, CorpusEntry $b) { 267 | return \strlen($a->input) <=> \strlen($b->input); 268 | }); 269 | foreach ($entries as $entry) { 270 | $this->corpus->computeUniqueFeatures($entry); 271 | if ($entry->uniqueFeatures) { 272 | $this->corpus->addEntry($entry); 273 | } 274 | } 275 | $this->initialFeatures = $this->corpus->getNumFeatures(); 276 | return true; 277 | } 278 | 279 | private function printAction(string $action, CorpusEntry $entry): void { 280 | $time = microtime(true) - $this->startTime; 281 | $mem = memory_get_usage(); 282 | $numFeatures = $this->corpus->getNumFeatures(); 283 | $numNewFeatures = $numFeatures - $this->initialFeatures; 284 | $maxLen = $this->corpus->getMaxLen(); 285 | $maxLenLen = \strlen((string) $maxLen); 286 | echo sprintf( 287 | "%-6s run: %d (%4.0f/s), ft: %d (%.0f/s), corp: %d (%s), len: %{$maxLenLen}d/%d, t: %.0fs, mem: %s\n", 288 | $action, $this->runs, $this->runs / $time, 289 | $numFeatures, $numNewFeatures / $time, 290 | $this->corpus->getNumCorpusEntries(), 291 | $this->formatBytes($this->corpus->getTotalLen()), 292 | \strlen($entry->input), $maxLen, 293 | $time, $this->formatBytes($mem)); 294 | } 295 | 296 | private function formatBytes(int $bytes): string { 297 | if ($bytes < 10 * 1024) { 298 | return $bytes . 'b'; 299 | } else if ($bytes < 10 * 1024 * 1024) { 300 | $kiloBytes = (int) round($bytes / 1024); 301 | return $kiloBytes . 'kb'; 302 | } else { 303 | $megaBytes = (int) round($bytes / (1024 * 1024)); 304 | return $megaBytes . 'mb'; 305 | } 306 | } 307 | 308 | private function printCrash(string $prefix, CorpusEntry $entry): void { 309 | echo "$prefix in $entry->path!\n"; 310 | echo $entry->crashInfo . "\n"; 311 | } 312 | 313 | public function renderCoverage(): void { 314 | if ($this->coverageDir === null) { 315 | throw new FuzzerException('Missing coverage directory'); 316 | } 317 | 318 | $renderer = new CoverageRenderer($this->coverageDir); 319 | $renderer->render($this->fileInfos, $this->corpus->getSeenBlockMap()); 320 | } 321 | 322 | private function minimizeCrash(string $path): void { 323 | if (!is_file($path)) { 324 | throw new FuzzerException("Crash input \"$path\" does not exist"); 325 | } 326 | 327 | $input = file_get_contents($path); 328 | $entry = $this->runInput($input); 329 | if (!$entry->crashInfo) { 330 | throw new FuzzerException("Crash input did not crash"); 331 | } 332 | 333 | while ($this->runs < $this->maxRuns) { 334 | $newInput = $input; 335 | for ($m = 0; $m < $this->mutationDepthLimit; $m++) { 336 | $newInput = $this->mutator->mutate($newInput, $this->config->maxLen, null); 337 | if (\strlen($newInput) >= \strlen($input)) { 338 | continue; 339 | } 340 | 341 | $newEntry = $this->runInput($newInput); 342 | if (!$newEntry->crashInfo) { 343 | continue; 344 | } 345 | 346 | $newEntry->storeAtPath(getcwd() . '/minimized-' . md5($newInput) . '.txt'); 347 | 348 | $len = \strlen($newInput); 349 | $this->printCrash("CRASH with length $len", $newEntry); 350 | $input = $newInput; 351 | } 352 | } 353 | } 354 | 355 | public function handleCliArgs(): int { 356 | $getOpt = new GetOpt([ 357 | Option::create('h', 'help', GetOpt::NO_ARGUMENT) 358 | ->setDescription('Display this help'), 359 | Option::create(null, 'dict', GetOpt::REQUIRED_ARGUMENT) 360 | ->setArgumentName('file') 361 | ->setDescription('Use dictionary file'), 362 | Option::create(null, 'max-runs', GetOpt::REQUIRED_ARGUMENT) 363 | ->setArgumentName('num') 364 | ->setDescription('Limit maximum target executions'), 365 | Option::create(null, 'timeout', GetOpt::REQUIRED_ARGUMENT) 366 | ->setArgumentName('seconds') 367 | ->setDescription('Timeout for one target execution'), 368 | Option::create(null, 'len-control-factor', GetOpt::REQUIRED_ARGUMENT) 369 | ->setArgumentName('num') 370 | ->setDescription('A higher value will increase the maximum length more slowly'), 371 | ]); 372 | $getOpt->addOperand(Operand::create('target', Operand::REQUIRED)); 373 | 374 | $getOpt->addCommand(Command::create('fuzz', [$this, 'handleFuzzCommand']) 375 | ->addOperand(Operand::create('corpus', Operand::OPTIONAL)) 376 | ->setDescription('Fuzz the target to find bugs')); 377 | $getOpt->addCommand(Command::create('minimize-crash', [$this, 'handleMinimizeCrashCommand']) 378 | ->addOperand(Operand::create('input', Operand::REQUIRED)) 379 | ->setDescription('Reduce the size of a crashing input')); 380 | $getOpt->addCommand(Command::create('run-single', [$this, 'handleRunSingleCommand']) 381 | ->addOperand(Operand::create('input', Operand::REQUIRED)) 382 | ->setDescription('Run single input through target')); 383 | $getOpt->addCommand(Command::create('report-coverage', [$this, 'handleReportCoverage']) 384 | ->addOperand(Operand::create('corpus', Operand::REQUIRED)) 385 | ->addOperand(Operand::create('coverage-dir', Operand::REQUIRED)) 386 | ->setDescription('Generate a HTML coverage report')); 387 | 388 | try { 389 | $getOpt->process(); 390 | } catch (ArgumentException $e) { 391 | echo $e->getMessage() . PHP_EOL; 392 | echo PHP_EOL . $getOpt->getHelpText(); 393 | return 1; 394 | } 395 | 396 | if ($getOpt->getOption('help')) { 397 | echo $getOpt->getHelpText(); 398 | return 0; 399 | } 400 | 401 | /** @var Command|null $command The CommandInterface is missing the getHandler() method. */ 402 | $command = $getOpt->getCommand(); 403 | if (!$command) { 404 | echo 'Missing command' . PHP_EOL; 405 | echo PHP_EOL . $getOpt->getHelpText(); 406 | return 1; 407 | } 408 | 409 | $opts = $getOpt->getOptions(); 410 | if (isset($opts['max-runs'])) { 411 | $this->maxRuns = (int) $opts['max-runs']; 412 | } 413 | if (isset($opts['timeout'])) { 414 | $this->timeout = (int) $opts['timeout']; 415 | } 416 | if (isset($opts['len-control-factor'])) { 417 | $this->lenControlFactor = (int) $opts['len-control-factor']; 418 | } 419 | 420 | try { 421 | if (isset($opts['dict'])) { 422 | $this->config->addDictionary($opts['dict']); 423 | } 424 | 425 | $this->loadTarget($getOpt->getOperand('target')); 426 | 427 | $this->setupTimeoutHandler(); 428 | $this->setupErrorHandler(); 429 | $this->setupShutdownHandler(); 430 | $command->getHandler()($getOpt); 431 | } catch (FuzzerException $e) { 432 | echo $e->getMessage() . PHP_EOL; 433 | return 1; 434 | } 435 | return 0; 436 | } 437 | 438 | private function createTemporaryCorpusDirectory(): string { 439 | do { 440 | $corpusDir = sys_get_temp_dir(). '/corpus-' . mt_rand(); 441 | } while (file_exists($corpusDir)); 442 | if (!@mkdir($corpusDir)) { 443 | throw new FuzzerException("Failed to create temporary corpus directory $corpusDir"); 444 | } 445 | return $corpusDir; 446 | } 447 | 448 | private function handleFuzzCommand(GetOpt $getOpt): void { 449 | $corpusDir = $getOpt->getOperand('corpus'); 450 | if ($corpusDir === null) { 451 | $corpusDir = $this->createTemporaryCorpusDirectory(); 452 | echo "Using $corpusDir as corpus directory\n"; 453 | } 454 | $this->setCorpusDir($corpusDir); 455 | $this->fuzz(); 456 | } 457 | 458 | private function handleRunSingleCommand(GetOpt $getOpt): void { 459 | $inputPath = $getOpt->getOperand('input'); 460 | if (!is_file($inputPath)) { 461 | throw new FuzzerException('Input "' . $inputPath . '" does not exist'); 462 | } 463 | 464 | $input = file_get_contents($inputPath); 465 | $entry = $this->runInput($input); 466 | $entry->path = $inputPath; 467 | if ($entry->crashInfo) { 468 | $this->printCrash('CRASH', $entry); 469 | } 470 | } 471 | 472 | private function handleMinimizeCrashCommand(GetOpt $getOpt): void { 473 | if ($this->maxRuns === PHP_INT_MAX) { 474 | $this->maxRuns = 100000; 475 | } 476 | $this->minimizeCrash($getOpt->getOperand('input')); 477 | } 478 | 479 | private function handleReportCoverage(GetOpt $getOpt): void { 480 | $this->setCorpusDir($getOpt->getOperand('corpus')); 481 | $this->setCoverageDir($getOpt->getOperand('coverage-dir')); 482 | $this->loadCorpus(); 483 | $this->renderCoverage(); 484 | } 485 | 486 | private function setupTimeoutHandler(): void { 487 | if (\extension_loaded('pcntl')) { 488 | \pcntl_signal(SIGALRM, function() { 489 | throw new \Error("Timeout of {$this->timeout} seconds exceeded"); 490 | }); 491 | \pcntl_async_signals(true); 492 | } 493 | } 494 | 495 | private function setupErrorHandler(): void { 496 | set_error_handler(function($errno, $errstr, $errfile, $errline) { 497 | if (!(error_reporting() & $errno)) { 498 | return true; 499 | } 500 | 501 | throw new \Error(sprintf( 502 | '[%d] %s in %s on line %d', $errno, $errstr, $errfile, $errline)); 503 | }); 504 | } 505 | 506 | private function setupShutdownHandler(): void { 507 | // If a fatal error occurs, at least recover the crashing input. 508 | // TODO: We could support fork mode to continue fuzzing after this (and allow minimization). 509 | register_shutdown_function(function() { 510 | $error = error_get_last(); 511 | if ($error === null || $error['type'] != E_ERROR || $this->lastInput === null) { 512 | return; 513 | } 514 | 515 | $crashInfo = "Fatal error: {$error['message']} in {$error['file']} on line {$error['line']}"; 516 | $entry = new CorpusEntry($this->lastInput, [], $crashInfo); 517 | $entry->storeAtPath($this->outputDir . '/crash-' . $entry->hash . '.txt'); 518 | $this->printCrash('CRASH', $entry); 519 | }); 520 | } 521 | } 522 | -------------------------------------------------------------------------------- /src/FuzzerException.php: -------------------------------------------------------------------------------- 1 | */ 9 | public static $edges = []; 10 | 11 | public static function reset(): void { 12 | self::$prevBlock = 0; 13 | self::$edges = []; 14 | } 15 | 16 | /** 17 | * @template T 18 | * @param int $blockIndex 19 | * @param T $returnValue 20 | * @return T 21 | */ 22 | public static function traceBlock($blockIndex, $returnValue) { 23 | $key = self::$prevBlock << 28 | $blockIndex; 24 | self::$edges[$key] = (self::$edges[$key] ?? 0) + 1; 25 | return $returnValue; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Instrumentation/Context.php: -------------------------------------------------------------------------------- 1 | runtimeContextName = $runtimeContextName; 15 | } 16 | 17 | public function getNewBlockIndex(int $pos): int { 18 | $blockIndex = $this->blockIndex++; 19 | $this->fileInfo->blockIndexToPos[$blockIndex] = $pos; 20 | return $blockIndex; 21 | } 22 | } -------------------------------------------------------------------------------- /src/Instrumentation/FileInfo.php: -------------------------------------------------------------------------------- 1 | */ 7 | public array $blockIndexToPos = []; 8 | } 9 | -------------------------------------------------------------------------------- /src/Instrumentation/Instrumentor.php: -------------------------------------------------------------------------------- 1 | parser = (new ParserFactory())->createForVersion($phpVersion); 17 | $this->traverser = new NodeTraverser(); 18 | $this->context = new Context($runtimeContextName); 19 | $this->traverser->addVisitor(new Visitor($this->context)); 20 | } 21 | 22 | public function instrument(string $code, FileInfo $fileInfo): string { 23 | $mutableStr = new MutableString($code); 24 | $this->context->fileInfo = $fileInfo; 25 | $this->context->code = $mutableStr; 26 | $stmts = $this->parser->parse($code); 27 | $this->traverser->traverse($stmts); 28 | return $mutableStr->getModifiedString(); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/Instrumentation/MutableString.php: -------------------------------------------------------------------------------- 1 | [[pos, len, newString, order]] */ 9 | private array $modifications = []; 10 | 11 | public function __construct(string $string) { 12 | $this->string = $string; 13 | } 14 | 15 | public function replace(int $pos, int $len, string $newString, int $order = 0): void { 16 | $this->modifications[] = [$pos, $len, $newString, $order]; 17 | } 18 | 19 | public function insert(int $pos, string $newString, int $order = 0): void { 20 | $this->replace($pos, 0, $newString, $order); 21 | } 22 | 23 | public function getOrigString(): string { 24 | return $this->string; 25 | } 26 | 27 | public function getModifiedString(): string { 28 | // Sort by position 29 | usort($this->modifications, function($a, $b) { 30 | return ($a[0] <=> $b[0]) ?: ($a[3] <=> $b[3]); 31 | }); 32 | 33 | $result = ''; 34 | $startPos = 0; 35 | foreach ($this->modifications as list($pos, $len, $newString)) { 36 | $result .= substr($this->string, $startPos, $pos - $startPos); 37 | $result .= $newString; 38 | $startPos = $pos + $len; 39 | } 40 | $result .= substr($this->string, $startPos); 41 | return $result; 42 | } 43 | } 44 | 45 | -------------------------------------------------------------------------------- /src/Instrumentation/Visitor.php: -------------------------------------------------------------------------------- 1 | context = $context; 19 | } 20 | 21 | public function leaveNode(Node $node) { 22 | // In these cases it is sufficient to insert a stub at the start. 23 | if ($node instanceof Expr\Closure || 24 | $node instanceof Stmt\Case_ || 25 | $node instanceof Stmt\Catch_ || 26 | $node instanceof Stmt\ClassMethod || 27 | $node instanceof Stmt\Else_ || 28 | $node instanceof Stmt\ElseIf_ || 29 | $node instanceof Stmt\Finally_ || 30 | $node instanceof Stmt\Function_ 31 | ) { 32 | if ($node->stmts === null) { 33 | return null; 34 | } 35 | 36 | $this->insertInlineBlockStubInStmts($node); 37 | return null; 38 | } 39 | 40 | // In these cases we should additionally insert one after the node. 41 | if ($node instanceof Stmt\Do_ || 42 | $node instanceof Stmt\If_ || 43 | $node instanceof Stmt\For_ || 44 | $node instanceof Stmt\Foreach_ || 45 | $node instanceof Stmt\While_ 46 | ) { 47 | $this->insertInlineBlockStubInStmts($node); 48 | $this->appendInlineBlockStub($node); 49 | return null; 50 | } 51 | 52 | // In these cases we need to insert one after the node only. 53 | if ($node instanceof Stmt\Label || 54 | $node instanceof Stmt\Switch_ || 55 | $node instanceof Stmt\TryCatch 56 | ) { 57 | $this->appendInlineBlockStub($node); 58 | return null; 59 | } 60 | 61 | // For short-circuiting operators, insert a tracing call into one branch. 62 | if ($node instanceof Expr\BinaryOp\BooleanAnd || 63 | $node instanceof Expr\BinaryOp\BooleanOr || 64 | $node instanceof Expr\BinaryOp\LogicalAnd || 65 | $node instanceof Expr\BinaryOp\LogicalOr || 66 | $node instanceof Expr\BinaryOp\Coalesce 67 | ) { 68 | $this->insertTracingCall($node->right); 69 | return null; 70 | } 71 | 72 | // Same as previous case, just different subnode name. 73 | if ($node instanceof Expr\Ternary) { 74 | $this->insertTracingCall($node->else); 75 | return null; 76 | } 77 | 78 | // Same as previous case, just different subnode name. 79 | if ($node instanceof Expr\AssignOp\Coalesce) { 80 | $this->insertTracingCall($node->expr); 81 | return null; 82 | } 83 | 84 | // Instrument call to arrow function. 85 | if ($node instanceof Expr\ArrowFunction) { 86 | $this->insertTracingCall($node->expr); 87 | return null; 88 | } 89 | 90 | if ($node instanceof Node\MatchArm) { 91 | $this->insertTracingCall($node->body); 92 | return null; 93 | } 94 | 95 | // Wrap the yield, so that a tracing call occurs after the yield resumes. 96 | if ($node instanceof Expr\Yield_ || 97 | $node instanceof Expr\YieldFrom 98 | ) { 99 | $this->insertTracingCall($node); 100 | return null; 101 | } 102 | 103 | // TODO: Comparison instrumentation? 104 | // TODO: Avoid redundant instrumentation? 105 | return null; 106 | } 107 | 108 | private function insertInlineBlockStubInStmts(Node $node): void { 109 | $stub = $this->generateInlineBlockStub($node->getStartFilePos()); 110 | $stmts = $node->stmts; 111 | if (!empty($stmts)) { 112 | /** @var Stmt $firstStmt */ 113 | $firstStmt = $stmts[0]; 114 | $startPos = $firstStmt->getStartFilePos(); 115 | $endPos = $firstStmt->getEndFilePos(); 116 | // Wrap the statement in {} in case this is a single "stmt;" block. 117 | $this->context->code->insert($startPos, "{ $stub ", 0); 118 | $this->context->code->insert($endPos + 1, " }", 1); 119 | return; 120 | } 121 | 122 | // We have an empty statement list. This may be represented as "{}", ";" 123 | // or, in case of a "case" statement, nothing. 124 | $endPos = $node->getEndFilePos(); 125 | $endChar = $this->context->code->getOrigString()[$endPos]; 126 | if ($endChar === '}') { 127 | $this->context->code->insert($endPos, " $stub "); 128 | } else if ($endChar === ';') { 129 | $this->context->code->replace($endPos, 1, "{ $stub }"); 130 | } else if ($endChar === ':') { 131 | $this->context->code->insert($endPos + 1, " $stub"); 132 | } else { 133 | throw new \Error("Unexpected end char '$endChar'"); 134 | } 135 | } 136 | 137 | private function appendInlineBlockStub(Stmt $stmt): void { 138 | $endPos = $stmt->getEndFilePos(); 139 | $stub = $this->generateInlineBlockStub($endPos); 140 | $this->context->code->insert($endPos + 1, " $stub"); 141 | } 142 | 143 | private function generateInlineBlockStub(int $pos): string { 144 | // We generate the following code: 145 | // $___key = (Context::$prevBlock << 28) | BLOCK_INDEX; 146 | // Context::$edges[$___key] = (Context::$edges[$___key] ?? 0) + 1; 147 | // Context::$prevBlock = BLOCK_INDEX; 148 | // We use a 28-bit block index to leave 8-bits to encode a logarithmic trip count. 149 | // TODO: When I originally picked this format, I forgot about the initialization issue. 150 | // TODO: It probably makes sense to switch this to something that can be pre-initialized. 151 | $blockIndex = $this->context->getNewBlockIndex($pos); 152 | $contextName = $this->context->runtimeContextName; 153 | return "\$___key = (\\$contextName::\$prevBlock << 28) | $blockIndex; " 154 | . "\\$contextName::\$edges[\$___key] = (\\$contextName::\$edges[\$___key] ?? 0) + 1; " 155 | . "\\$contextName::\$prevBlock = $blockIndex;"; 156 | } 157 | 158 | private function insertTracingCall(Expr $expr): void { 159 | $startPos = $expr->getStartFilePos(); 160 | $endPos = $expr->getEndFilePos(); 161 | $blockIndex = $this->context->getNewBlockIndex($startPos); 162 | $contextName = $this->context->runtimeContextName; 163 | 164 | $this->context->code->insert($startPos, "\\$contextName::traceBlock($blockIndex, ", 1); 165 | $this->context->code->insert($endPos + 1, ")", 0); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /src/Mutation/Dictionary.php: -------------------------------------------------------------------------------- 1 | */ 7 | public array $dict = []; 8 | 9 | public function isEmpty(): bool { 10 | return empty($this->dict); 11 | } 12 | 13 | /** 14 | * @param list $words 15 | */ 16 | public function addWords(array $words): void { 17 | $this->dict = [...$this->dict, ...$words]; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Mutation/Mutator.php: -------------------------------------------------------------------------------- 1 | */ 12 | private array $mutators; 13 | private ?string $crossOverWith = null; // TODO: Get rid of this 14 | 15 | public function __construct(RNG $rng, Dictionary $dictionary) { 16 | $this->rng = $rng; 17 | $this->dictionary = $dictionary; 18 | $this->mutators = [ 19 | [$this, 'mutateEraseBytes'], 20 | [$this, 'mutateInsertByte'], 21 | [$this, 'mutateInsertRepeatedBytes'], 22 | [$this, 'mutateChangeByte'], 23 | [$this, 'mutateChangeBit'], 24 | [$this, 'mutateShuffleBytes'], 25 | [$this, 'mutateChangeASCIIInt'], 26 | [$this, 'mutateChangeBinInt'], 27 | [$this, 'mutateCopyPart'], 28 | [$this, 'mutateCrossOver'], 29 | [$this, 'mutateAddWordFromManualDictionary'], 30 | ]; 31 | } 32 | 33 | /** 34 | * @return list 35 | */ 36 | public function getMutators(): array { 37 | return $this->mutators; 38 | } 39 | 40 | private function randomBiasedChar(): string { 41 | if ($this->rng->randomBool()) { 42 | return $this->rng->randomChar(); 43 | } 44 | $chars = "!*'();:@&=+$,/?%#[]012Az-`~.\xff\x00"; 45 | return $chars[$this->rng->randomPos($chars)]; 46 | } 47 | 48 | public function mutateEraseBytes(string $str, int $maxLen): ?string { 49 | $len = \strlen($str); 50 | if ($len <= 1) { 51 | return null; 52 | } 53 | 54 | $minNumBytes = $maxLen < $len ? $len - $maxLen : 0; 55 | $maxNumBytes = min($minNumBytes + ($len >> 1), $len); 56 | $numBytes = $this->rng->randomIntRange($minNumBytes, $maxNumBytes); 57 | $pos = $this->rng->randomInt($len - $numBytes + 1); 58 | return \substr($str, 0, $pos) 59 | . \substr($str, $pos + $numBytes); 60 | } 61 | 62 | public function mutateInsertByte(string $str, int $maxLen): ?string { 63 | if (\strlen($str) >= $maxLen) { 64 | return null; 65 | } 66 | 67 | $pos = $this->rng->randomPosOrEnd($str); 68 | return \substr($str, 0, $pos) 69 | . $this->randomBiasedChar() 70 | . \substr($str, $pos); 71 | } 72 | 73 | public function mutateInsertRepeatedBytes(string $str, int $maxLen): ?string { 74 | $minNumBytes = 3; 75 | $len = \strlen($str); 76 | if ($len + $minNumBytes >= $maxLen) { 77 | return null; 78 | } 79 | 80 | $maxNumBytes = min($maxLen - $len, 128); 81 | $numBytes = $this->rng->randomIntRange($minNumBytes, $maxNumBytes); 82 | $pos = $this->rng->randomPosOrEnd($str); 83 | // TODO: Biasing? 84 | $char = $this->rng->randomChar(); 85 | return \substr($str, 0, $pos) 86 | . str_repeat($char, $numBytes) 87 | . \substr($str, $pos); 88 | } 89 | 90 | public function mutateChangeByte(string $str, int $maxLen): ?string { 91 | if ($str === '' || \strlen($str) > $maxLen) { 92 | return null; 93 | } 94 | 95 | $pos = $this->rng->randomPos($str); 96 | $str[$pos] = $this->randomBiasedChar(); 97 | return $str; 98 | } 99 | 100 | public function mutateChangeBit(string $str, int $maxLen): ?string { 101 | if ($str === '' || \strlen($str) > $maxLen) { 102 | return null; 103 | } 104 | 105 | $pos = $this->rng->randomPos($str); 106 | $bit = 1 << $this->rng->randomInt(8); 107 | $str[$pos] = \chr(\ord($str[$pos]) ^ $bit); 108 | return $str; 109 | } 110 | 111 | public function mutateShuffleBytes(string $str, int $maxLen): ?string { 112 | $len = \strlen($str); 113 | if ($str === '' || $len > $maxLen) { 114 | return null; 115 | } 116 | $numBytes = $this->rng->randomInt(min($len, 8)) + 1; 117 | $pos = $this->rng->randomInt($len - $numBytes + 1); 118 | // TODO: This does not use the RNG! 119 | return \substr($str, 0, $pos) 120 | . \str_shuffle(\substr($str, $pos, $numBytes)) 121 | . \substr($str, $pos + $numBytes); 122 | 123 | } 124 | 125 | public function mutateChangeASCIIInt(string $str, int $maxLen): ?string { 126 | $len = \strlen($str); 127 | if ($str === '' || $len > $maxLen) { 128 | return null; 129 | } 130 | 131 | $beginPos = $this->rng->randomPos($str); 132 | while ($beginPos < $len && !\ctype_digit($str[$beginPos])) { 133 | $beginPos++; 134 | } 135 | if ($beginPos === $len) { 136 | return null; 137 | } 138 | $endPos = $beginPos; 139 | while ($endPos < $len && \ctype_digit($str[$endPos])) { 140 | $endPos++; 141 | } 142 | // TODO: We won't be able to get large unsigned integers here. 143 | $int = (int) \substr($str, $beginPos, $endPos - $beginPos); 144 | switch ($this->rng->randomInt(4)) { 145 | case 0: 146 | $int++; 147 | break; 148 | case 1: 149 | $int--; 150 | break; 151 | case 2: 152 | $int >>= 1; 153 | break; 154 | case 3: 155 | $int <<= 1; 156 | break; 157 | default: 158 | throw new \Error("Cannot happen"); 159 | } 160 | 161 | $intStr = (string) $int; 162 | if ($len - ($endPos - $beginPos) + \strlen($intStr) > $maxLen) { 163 | return null; 164 | } 165 | 166 | return \substr($str, 0, $beginPos) 167 | . $intStr 168 | . \substr($str, $endPos); 169 | } 170 | 171 | public function mutateChangeBinInt(string $str, int $maxLen): ?string { 172 | $len = \strlen($str); 173 | if ($len > $maxLen) { 174 | return null; 175 | } 176 | 177 | $packCodes = [ 178 | 'C' => 1, 179 | 'n' => 2, 'v' => 2, 180 | 'N' => 4, 'V' => 4, 181 | 'J' => 8, 'P' => 8, 182 | ]; 183 | $packCode = $this->rng->randomElement(array_keys($packCodes)); 184 | $numBytes = $packCodes[$packCode]; 185 | if ($numBytes > $len) { 186 | return null; 187 | } 188 | 189 | $pos = $this->rng->randomInt($len - $numBytes + 1); 190 | if ($pos < 64 && $this->rng->randomInt(4) == 0) { 191 | $int = $len; 192 | } else { 193 | $int = \unpack($packCode, $str, $pos)[1]; 194 | $add = $this->rng->randomIntRange(-10, 10); 195 | $int += $add; 196 | if ($add == 0 && $this->rng->randomBool()) { 197 | $int = -$int; 198 | } 199 | } 200 | return \substr($str, 0, $pos) 201 | . \pack($packCode, $int) 202 | . \substr($str, $pos + $numBytes); 203 | } 204 | 205 | private function copyPartOf(string $from, string $to): string { 206 | $toLen = \strlen($to); 207 | $fromLen = \strlen($from); 208 | $toBeg = $this->rng->randomPos($to); 209 | $numBytes = $this->rng->randomInt($toLen - $toBeg) + 1; 210 | $numBytes = \min($numBytes, $fromLen); 211 | $fromBeg = $this->rng->randomInt($fromLen - $numBytes + 1); 212 | return \substr($to, 0, $toBeg) 213 | . \substr($from, $fromBeg, $numBytes) 214 | . \substr($to, $toBeg + $numBytes); 215 | } 216 | 217 | private function insertPartOf(string $from, string $to, int $maxLen): ?string { 218 | $toLen = \strlen($to); 219 | if ($toLen >= $maxLen) { 220 | return null; 221 | } 222 | 223 | $fromLen = \strlen($from); 224 | $maxNumBytes = min($maxLen - $toLen, $fromLen); 225 | $numBytes = $this->rng->randomInt($maxNumBytes) + 1; 226 | $fromBeg = $this->rng->randomInt($fromLen - $numBytes + 1); 227 | $toInsertPos = $this->rng->randomPosOrEnd($to); 228 | return \substr($to, 0, $toInsertPos) 229 | . \substr($from, $fromBeg, $numBytes) 230 | . \substr($to, $toInsertPos); 231 | } 232 | 233 | private function crossOver(string $str1, string $str2, int $maxLen): string { 234 | $maxLen = $this->rng->randomInt($maxLen) + 1; 235 | $len1 = \strlen($str1); 236 | $len2 = \strlen($str2); 237 | $pos1 = 0; 238 | $pos2 = 0; 239 | $result = ''; 240 | $usingStr1 = true; 241 | while (\strlen($result) < $maxLen && ($pos1 < $len1 || $pos2 < $len2)) { 242 | $maxLenLeft = $maxLen - \strlen($result); 243 | if ($usingStr1) { 244 | if ($pos1 < $len1) { 245 | $maxExtraLen = min($len1 - $pos1, $maxLenLeft); 246 | $extraLen = $this->rng->randomInt($maxExtraLen) + 1; 247 | $result .= \substr($str1, $pos1, $extraLen); 248 | $pos1 += $extraLen; 249 | } 250 | } else { 251 | if ($pos2 < $len2) { 252 | $maxExtraLen = min($len2 - $pos2, $maxLenLeft); 253 | $extraLen = $this->rng->randomInt($maxExtraLen) + 1; 254 | $result .= \substr($str2, $pos2, $extraLen); 255 | $pos2 += $extraLen; 256 | } 257 | } 258 | $usingStr1 = !$usingStr1; 259 | } 260 | return $result; 261 | } 262 | 263 | public function mutateCopyPart(string $str, int $maxLen): ?string { 264 | $len = \strlen($str); 265 | if ($str === '' || $len > $maxLen) { 266 | return null; 267 | } 268 | if ($len == $maxLen || $this->rng->randomBool()) { 269 | return $this->copyPartOf($str, $str); 270 | } else { 271 | return $this->insertPartOf($str, $str, $maxLen); 272 | } 273 | } 274 | 275 | public function mutateCrossOver(string $str, int $maxLen): ?string { 276 | if ($this->crossOverWith === null) { 277 | return null; 278 | } 279 | $len = \strlen($str); 280 | if ($len > $maxLen || $len === 0 || \strlen($this->crossOverWith) === 0) { 281 | return null; 282 | } 283 | switch ($this->rng->randomInt(3)) { 284 | case 0: 285 | return $this->crossOver($str, $this->crossOverWith, $maxLen); 286 | case 1: 287 | if ($len == $maxLen) { 288 | return $this->insertPartOf($this->crossOverWith, $str, $maxLen); 289 | } 290 | /* fallthrough */ 291 | case 2: 292 | return $this->copyPartOf($this->crossOverWith, $str); 293 | default: 294 | throw new \Error("Cannot happen"); 295 | } 296 | } 297 | 298 | public function mutateAddWordFromManualDictionary(string $str, int $maxLen): ?string { 299 | $len = \strlen($str); 300 | if ($len > $maxLen) { 301 | return null; 302 | } 303 | if ($this->dictionary->isEmpty()) { 304 | return null; 305 | } 306 | 307 | $word = $this->rng->randomElement($this->dictionary->dict); 308 | $wordLen = \strlen($word); 309 | if ($this->rng->randomBool()) { 310 | // Insert word. 311 | if ($len + $wordLen > $maxLen) { 312 | return null; 313 | } 314 | 315 | $pos = $this->rng->randomPosOrEnd($str); 316 | return \substr($str, 0, $pos) 317 | . $word 318 | . \substr($str, $pos); 319 | } else { 320 | // Overwrite with word. 321 | if ($wordLen > $len) { 322 | return null; 323 | } 324 | 325 | $pos = $this->rng->randomInt($len - $wordLen + 1); 326 | return \substr($str, 0, $pos) 327 | . $word 328 | . \substr($str, $pos + $wordLen); 329 | } 330 | } 331 | 332 | public function mutate(string $str, int $maxLen, ?string $crossOverWith): string { 333 | $this->crossOverWith = $crossOverWith; 334 | while (true) { 335 | $mutator = $this->rng->randomElement($this->mutators); 336 | $newStr = $mutator($str, $maxLen); 337 | if (null !== $newStr) { 338 | assert(\strlen($newStr) <= $maxLen, 'Mutator ' . $mutator[1]); 339 | return $newStr; 340 | } 341 | } 342 | } 343 | } 344 | -------------------------------------------------------------------------------- /src/Mutation/RNG.php: -------------------------------------------------------------------------------- 1 | randomInt(256)); 17 | } 18 | 19 | public function randomPos(string $str): int { 20 | $len = \strlen($str); 21 | if ($len === 0) { 22 | throw new \Error("String must not be empty!"); 23 | } 24 | return $this->randomInt($len); 25 | } 26 | 27 | public function randomPosOrEnd(string $str): int { 28 | return $this->randomInt(\strlen($str) + 1); 29 | } 30 | 31 | /** 32 | * @template T 33 | * @param list $array 34 | * @return T 35 | */ 36 | public function randomElement(array $array) { 37 | return $array[$this->randomInt(\count($array))]; 38 | } 39 | 40 | public function randomBool(): bool { 41 | return (bool) \mt_rand(0, 1); 42 | } 43 | 44 | public function randomString(int $len): string { 45 | $result = ''; 46 | for ($i = 0; $i < $len; $i++) { 47 | $result .= $this->randomChar(); 48 | } 49 | return $result; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Util.php: -------------------------------------------------------------------------------- 1 | $strings 10 | */ 11 | public static function getCommonPathPrefix(array $strings): string { 12 | if (empty($strings)) { 13 | return ''; 14 | } 15 | 16 | $prefix = $strings[0]; 17 | foreach ($strings as $string) { 18 | $prefixLen = \strspn($prefix ^ $string, "\0"); 19 | $prefix = \substr($prefix, 0, $prefixLen); 20 | } 21 | 22 | if ($prefix === '') { 23 | return $prefix; 24 | } 25 | 26 | $len = \strlen($prefix); 27 | while ($prefix[$len-1] !== '/' && $prefix[$len-1] !== '\\') { 28 | --$len; 29 | } 30 | 31 | return \substr($prefix, 0, $len); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /test/DictionaryParserTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expected, $dictionaryParser->parse($code)); 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /test/Instrumentation/InstrumentorTest.php: -------------------------------------------------------------------------------- 1 | $x; 36 | $x ?? $y; 37 | $x ??= $y; 38 | match ($x) { 39 | 1, 2 => $y, 40 | default => $z, 41 | }; 42 | switch ($x) { 43 | case 1: 44 | case 2: 45 | $x; 46 | default: 47 | $x; 48 | } 49 | } 50 | interface Foo { 51 | public function bar(); 52 | } 53 | CODE; 54 | 55 | $expected = <<<'CODE' 56 | \InstrumentationContext::traceBlock(19, $x); 78 | $x ?? \InstrumentationContext::traceBlock(20, $y); 79 | $x ??= \InstrumentationContext::traceBlock(21, $y); 80 | match ($x) { 81 | 1, 2 => \InstrumentationContext::traceBlock(22, $y), 82 | default => \InstrumentationContext::traceBlock(23, $z), 83 | }; 84 | switch ($x) { 85 | case 1: $___key = (\InstrumentationContext::$prevBlock << 28) | 24; \InstrumentationContext::$edges[$___key] = (\InstrumentationContext::$edges[$___key] ?? 0) + 1; \InstrumentationContext::$prevBlock = 24; 86 | case 2: 87 | { $___key = (\InstrumentationContext::$prevBlock << 28) | 25; \InstrumentationContext::$edges[$___key] = (\InstrumentationContext::$edges[$___key] ?? 0) + 1; \InstrumentationContext::$prevBlock = 25; $x; } 88 | default: 89 | { $___key = (\InstrumentationContext::$prevBlock << 28) | 26; \InstrumentationContext::$edges[$___key] = (\InstrumentationContext::$edges[$___key] ?? 0) + 1; \InstrumentationContext::$prevBlock = 26; $x; } 90 | } $___key = (\InstrumentationContext::$prevBlock << 28) | 27; \InstrumentationContext::$edges[$___key] = (\InstrumentationContext::$edges[$___key] ?? 0) + 1; \InstrumentationContext::$prevBlock = 27; 91 | } 92 | interface Foo { 93 | public function bar(); 94 | } 95 | CODE; 96 | 97 | $expectedCoverage = <<<'CODE' 98 | !$x; 120 | $x ?? !$y; 121 | $x ??= !$y; 122 | match ($x) { 123 | 1, 2 => !$y, 124 | default => !$z, 125 | }; 126 | switch ($x) { 127 | !case 1: 128 | !case 2: 129 | $x; 130 | !default: 131 | $x; 132 | !} 133 | } 134 | interface Foo { 135 | public function bar(); 136 | } 137 | CODE; 138 | 139 | $instrumentor = new Instrumentor( 140 | 'InstrumentationContext', PhpVersion::getNewestSupported()); 141 | $fileInfo = new FileInfo(); 142 | $output = $instrumentor->instrument($input, $fileInfo); 143 | $this->assertSame($expected, $output); 144 | 145 | // The number of lines should be preserved. 146 | $inputNewlines = substr_count($input, "\n"); 147 | $outputNewlines = substr_count($output, "\n"); 148 | $this->assertSame($inputNewlines, $outputNewlines); 149 | 150 | $str = new MutableString($input); 151 | foreach ($fileInfo->blockIndexToPos as $pos) { 152 | $str->insert($pos, '!'); 153 | } 154 | $this->assertSame($expectedCoverage, $str->getModifiedString()); 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /test/Mutation/MutatorTest.php: -------------------------------------------------------------------------------- 1 | getMutators(); 13 | foreach ($mutators as $mutator) { 14 | for ($i = 0; $i < $tries; $i++) { 15 | $maxLen = $rng->randomInt(100); 16 | $len = $rng->randomInt(100); 17 | $input = $rng->randomString($len); 18 | $result = $mutator($input, $maxLen); 19 | if ($result === null) { 20 | continue; 21 | } 22 | $this->assertTrue(\strlen($result) <= $maxLen, "$mutator[1] violated maximum length constraint"); 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /test/PhpFuzzer/UtilTest.php: -------------------------------------------------------------------------------- 1 | assertSame($expectedPrefix, Util::getCommonPathPrefix($paths)); 13 | } 14 | 15 | public function provideTestGetCommonPathPrefix(): array { 16 | return [ 17 | [[], ''], 18 | [['/foo/bar/baz.php'], '/foo/bar/'], 19 | [['C:\foo\bar\baz.php'], 'C:\foo\bar\\'], 20 | [['bar', 'foo'], ''], 21 | [['/foo/bar/abc.php', '/foo/bar/abd.php'], '/foo/bar/'], 22 | [['C:\foo\bar\abc.php', 'C:\foo\bar\abd.php'], 'C:\foo\bar\\'], 23 | [['/foo/abc/bar.php', '/foo/abd/bar.php'], '/foo/'], 24 | [['C:\foo\abc\bar.php', 'C:\foo\abd\bar.php'], 'C:\foo\\'], 25 | ]; 26 | } 27 | } 28 | --------------------------------------------------------------------------------