# Copyright 2021 Google LLC # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. """Module for running fuzzers.""" import enum import logging import os import shutil import sys import time import clusterfuzz_deployment import fuzz_target import generate_coverage_report import stack_parser import workspace_utils # pylint: disable=wrong-import-position,import-error sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) import utils class RunFuzzersResult(enum.Enum): """Enum result from running fuzzers.""" ERROR = 0 BUG_FOUND = 1 NO_BUG_FOUND = 2 class BaseFuzzTargetRunner: """Base class for fuzzer runners.""" def __init__(self, config): self.config = config self.workspace = workspace_utils.Workspace(config) self.clusterfuzz_deployment = ( clusterfuzz_deployment.get_clusterfuzz_deployment( self.config, self.workspace)) # Set by the initialize method. self.fuzz_target_paths = None def get_fuzz_targets(self): """Returns fuzz targets in out directory.""" return utils.get_fuzz_targets(self.workspace.out) def initialize(self): """Initialization method. Must be called before calling run_fuzz_targets. Returns True on success.""" # Use a seperate initialization function so we can return False on failure # instead of exceptioning like we need to do if this were done in the # __init__ method. logging.info('Using %s sanitizer.', self.config.sanitizer) # TODO(metzman) Add a check to ensure we aren't over time limit. if not self.config.fuzz_seconds or self.config.fuzz_seconds < 1: logging.error( 'Fuzz_seconds argument must be greater than 1, but was: %s.', self.config.fuzz_seconds) return False if not os.path.exists(self.workspace.out): logging.error('Out directory: %s does not exist.', self.workspace.out) return False if not os.path.exists(self.workspace.artifacts): os.makedirs(self.workspace.artifacts) elif (not os.path.isdir(self.workspace.artifacts) or os.listdir(self.workspace.artifacts)): logging.error('Artifacts path: %s exists and is not an empty directory.', self.workspace.artifacts) return False self.fuzz_target_paths = self.get_fuzz_targets() logging.info('Fuzz targets: %s', self.fuzz_target_paths) if not self.fuzz_target_paths: logging.error('No fuzz targets were found in out directory: %s.', self.workspace.out) return False return True def cleanup_after_fuzz_target_run(self, fuzz_target_obj): # pylint: disable=no-self-use """Cleans up after running |fuzz_target_obj|.""" raise NotImplementedError('Child class must implement method.') def run_fuzz_target(self, fuzz_target_obj): # pylint: disable=no-self-use """Fuzzes with |fuzz_target_obj| and returns the result.""" raise NotImplementedError('Child class must implement method.') @property def quit_on_bug_found(self): """Property that is checked to determine if fuzzing should quit after first bug is found.""" raise NotImplementedError('Child class must implement method.') def get_fuzz_target_artifact(self, target, artifact_name): """Returns the path of a fuzzing artifact named |artifact_name| for |fuzz_target|.""" artifact_name = (f'{target.target_name}-{self.config.sanitizer}-' f'{artifact_name}') return os.path.join(self.workspace.artifacts, artifact_name) def create_fuzz_target_obj(self, target_path, run_seconds): """Returns a fuzz target object.""" return fuzz_target.FuzzTarget(target_path, run_seconds, self.workspace, self.clusterfuzz_deployment, self.config) def run_fuzz_targets(self): """Runs fuzz targets. Returns True if a bug was found.""" fuzzers_left_to_run = len(self.fuzz_target_paths) # Make a copy since we will mutate it. fuzz_seconds = self.config.fuzz_seconds min_seconds_per_fuzzer = fuzz_seconds // fuzzers_left_to_run bug_found = False for target_path in self.fuzz_target_paths: # By doing this, we can ensure that every fuzz target runs for at least # min_seconds_per_fuzzer, but that other fuzzers will have longer to run # if one ends early. run_seconds = max(fuzz_seconds // fuzzers_left_to_run, min_seconds_per_fuzzer) target = self.create_fuzz_target_obj(target_path, run_seconds) start_time = time.time() result = self.run_fuzz_target(target) self.cleanup_after_fuzz_target_run(target) # It's OK if this goes negative since we take max when determining # run_seconds. fuzz_seconds -= time.time() - start_time fuzzers_left_to_run -= 1 if not result.testcase or not result.stacktrace: logging.info('Fuzzer %s finished running without crashes.', target.target_name) continue # TODO(metzman): Do this with filestore. testcase_artifact_path = self.get_fuzz_target_artifact( target, os.path.basename(result.testcase)) shutil.move(result.testcase, testcase_artifact_path) bug_summary_artifact_path = self.get_fuzz_target_artifact( target, 'bug-summary.txt') stack_parser.parse_fuzzer_output(result.stacktrace, bug_summary_artifact_path) bug_found = True if self.quit_on_bug_found: logging.info('Bug found. Stopping fuzzing.') return bug_found return bug_found class PruneTargetRunner(BaseFuzzTargetRunner): """Runner that prunes corpora.""" @property def quit_on_bug_found(self): return False def run_fuzz_target(self, fuzz_target_obj): """Prunes with |fuzz_target_obj| and returns the result.""" result = fuzz_target_obj.prune() logging.debug('Corpus path contents: %s.', os.listdir(result.corpus_path)) self.clusterfuzz_deployment.upload_corpus(fuzz_target_obj.target_name, result.corpus_path, replace=True) return result def cleanup_after_fuzz_target_run(self, fuzz_target_obj): # pylint: disable=no-self-use """Cleans up after pruning with |fuzz_target_obj|.""" fuzz_target_obj.free_disk_if_needed() class CoverageTargetRunner(BaseFuzzTargetRunner): """Runner that runs the 'coverage' command.""" @property def quit_on_bug_found(self): raise NotImplementedError('Not implemented for CoverageTargetRunner.') def get_fuzz_targets(self): """Returns fuzz targets in out directory.""" # We only want fuzz targets from the root because during the coverage build, # a lot of the image's filesystem is copied into /out for the purpose of # generating coverage reports. # TOOD(metzman): Figure out if top_level_only should be the only behavior # for this function. return utils.get_fuzz_targets(self.workspace.out, top_level_only=True) def run_fuzz_targets(self): """Generates a coverage report. Always returns False since it never finds any bugs.""" generate_coverage_report.generate_coverage_report( self.fuzz_target_paths, self.workspace, self.clusterfuzz_deployment, self.config) return False def run_fuzz_target(self, fuzz_target_obj): # pylint: disable=no-self-use """Fuzzes with |fuzz_target_obj| and returns the result.""" raise NotImplementedError('Not implemented for CoverageTargetRunner.') def cleanup_after_fuzz_target_run(self, fuzz_target_obj): # pylint: disable=no-self-use """Cleans up after running |fuzz_target_obj|.""" raise NotImplementedError('Not implemented for CoverageTargetRunner.') class CiFuzzTargetRunner(BaseFuzzTargetRunner): """Runner for fuzz targets used in CI (patch-fuzzing) context.""" @property def quit_on_bug_found(self): return True def cleanup_after_fuzz_target_run(self, fuzz_target_obj): # pylint: disable=no-self-use """Cleans up after running |fuzz_target_obj|.""" fuzz_target_obj.free_disk_if_needed() def run_fuzz_target(self, fuzz_target_obj): # pylint: disable=no-self-use return fuzz_target_obj.fuzz() class BatchFuzzTargetRunner(BaseFuzzTargetRunner): """Runner for fuzz targets used in batch fuzzing context.""" @property def quit_on_bug_found(self): return False def run_fuzz_target(self, fuzz_target_obj): """Fuzzes with |fuzz_target_obj| and returns the result.""" result = fuzz_target_obj.fuzz() logging.debug('Corpus path contents: %s.', os.listdir(result.corpus_path)) self.clusterfuzz_deployment.upload_corpus(fuzz_target_obj.target_name, result.corpus_path) return result def cleanup_after_fuzz_target_run(self, fuzz_target_obj): """Cleans up after running |fuzz_target_obj|.""" # This must be done after we upload the corpus, otherwise it will be deleted # before we get a chance to upload it. We can't delete the fuzz target # because it is needed when we upload the build. fuzz_target_obj.free_disk_if_needed(delete_fuzz_target=False) def run_fuzz_targets(self): result = super().run_fuzz_targets() self.clusterfuzz_deployment.upload_crashes() return result _RUN_FUZZERS_MODE_RUNNER_MAPPING = { 'batch': BatchFuzzTargetRunner, 'coverage': CoverageTargetRunner, 'prune': PruneTargetRunner, 'ci': CiFuzzTargetRunner, } def get_fuzz_target_runner(config): """Returns a fuzz target runner object based on the run_fuzzers_mode of |config|.""" runner = _RUN_FUZZERS_MODE_RUNNER_MAPPING[config.run_fuzzers_mode](config) logging.info('RUN_FUZZERS_MODE is: %s. Runner: %s.', config.run_fuzzers_mode, runner) return runner def run_fuzzers(config): # pylint: disable=too-many-locals """Runs fuzzers for a specific OSS-Fuzz project. Args: config: A RunFuzzTargetsConfig. Returns: A RunFuzzersResult enum value indicating what happened during fuzzing. """ fuzz_target_runner = get_fuzz_target_runner(config) if not fuzz_target_runner.initialize(): # We didn't fuzz at all because of internal (CIFuzz) errors. And we didn't # find any bugs. return RunFuzzersResult.ERROR if not fuzz_target_runner.run_fuzz_targets(): # We fuzzed successfully, but didn't find any bugs (in the fuzz target). return RunFuzzersResult.NO_BUG_FOUND # We fuzzed successfully and found bug(s) in the fuzz targets. return RunFuzzersResult.BUG_FOUND