| # 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 |