• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1# Copyright 2020 Google LLC
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7#      http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14"""A module to handle running a fuzz target for a specified amount of time."""
15import collections
16import logging
17import os
18import shutil
19import stat
20
21import clusterfuzz.environment
22import clusterfuzz.fuzz
23
24import config_utils
25
26logging.basicConfig(
27    format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
28    level=logging.DEBUG)
29
30# Use a fixed seed for determinism. Use len_control=0 since we don't have enough
31# time fuzzing for len_control to make sense (probably).
32LIBFUZZER_OPTIONS = ['-seed=1337', '-len_control=0']
33
34# The number of reproduce attempts for a crash.
35REPRODUCE_ATTEMPTS = 10
36
37REPRODUCE_TIME_SECONDS = 30
38
39# Seconds on top of duration until a timeout error is raised.
40BUFFER_TIME = 10
41
42# Log message if we can't check if crash reproduces on an recent build.
43COULD_NOT_TEST_ON_CLUSTERFUZZ_MESSAGE = (
44    'Could not run previous build of target to determine if this code change '
45    '(pr/commit) introduced crash. Assuming crash was newly introduced.')
46
47FuzzResult = collections.namedtuple('FuzzResult',
48                                    ['testcase', 'stacktrace', 'corpus_path'])
49
50
51class ReproduceError(Exception):
52  """Error for when we can't attempt to reproduce a crash."""
53
54
55def get_fuzz_target_corpus_dir(workspace, target_name):
56  """Returns the directory for storing |target_name|'s corpus in |workspace|."""
57  return os.path.join(workspace.corpora, target_name)
58
59
60def get_fuzz_target_pruned_corpus_dir(workspace, target_name):
61  """Returns the directory for storing |target_name|'s puned corpus in
62  |workspace|."""
63  return os.path.join(workspace.pruned_corpora, target_name)
64
65
66class FuzzTarget:  # pylint: disable=too-many-instance-attributes
67  """A class to manage a single fuzz target.
68
69  Attributes:
70    target_name: The name of the fuzz target.
71    duration: The length of time in seconds that the target should run.
72    target_path: The location of the fuzz target binary.
73    workspace: The workspace for storing things related to fuzzing.
74  """
75
76  # pylint: disable=too-many-arguments
77  def __init__(self, target_path, duration, workspace, clusterfuzz_deployment,
78               config):
79    """Represents a single fuzz target.
80
81    Args:
82      target_path: The location of the fuzz target binary.
83      duration: The length of time  in seconds the target should run.
84      workspace: The path used for storing things needed for fuzzing.
85      clusterfuzz_deployment: The object representing the ClusterFuzz
86          deployment.
87      config: The config of this project.
88    """
89    self.target_path = target_path
90    self.target_name = os.path.basename(self.target_path)
91    self.duration = int(duration)
92    self.workspace = workspace
93    self.clusterfuzz_deployment = clusterfuzz_deployment
94    self.config = config
95    self.latest_corpus_path = get_fuzz_target_corpus_dir(
96        self.workspace, self.target_name)
97    os.makedirs(self.latest_corpus_path, exist_ok=True)
98    self.pruned_corpus_path = get_fuzz_target_pruned_corpus_dir(
99        self.workspace, self.target_name)
100    os.makedirs(self.pruned_corpus_path, exist_ok=True)
101
102  def _download_corpus(self):
103    """Downloads the corpus for the target from ClusterFuzz and returns the path
104    to the corpus. An empty directory is provided if the corpus can't be
105    downloaded or is empty."""
106    self.clusterfuzz_deployment.download_corpus(self.target_name,
107                                                self.latest_corpus_path)
108    return self.latest_corpus_path
109
110  def prune(self):
111    """Prunes the corpus and returns the result."""
112    self._download_corpus()
113    with clusterfuzz.environment.Environment(config_utils.DEFAULT_ENGINE,
114                                             self.config.sanitizer,
115                                             self.target_path,
116                                             interactive=True):
117      engine_impl = clusterfuzz.fuzz.get_engine(config_utils.DEFAULT_ENGINE)
118      result = engine_impl.minimize_corpus(self.target_path, [],
119                                           [self.latest_corpus_path],
120                                           self.pruned_corpus_path,
121                                           self.workspace.artifacts,
122                                           self.duration)
123
124    return FuzzResult(None, result.logs, self.pruned_corpus_path)
125
126  def fuzz(self):
127    """Starts the fuzz target run for the length of time specified by duration.
128
129    Returns:
130      FuzzResult namedtuple with stacktrace and testcase if applicable.
131    """
132    logging.info('Running fuzzer: %s.', self.target_name)
133
134    self._download_corpus()
135    corpus_path = self.latest_corpus_path
136
137    logging.info('Starting fuzzing')
138    with clusterfuzz.environment.Environment(config_utils.DEFAULT_ENGINE,
139                                             self.config.sanitizer,
140                                             self.target_path,
141                                             interactive=True) as env:
142      engine_impl = clusterfuzz.fuzz.get_engine(config_utils.DEFAULT_ENGINE)
143      options = engine_impl.prepare(corpus_path, env.target_path, env.build_dir)
144      options.merge_back_new_testcases = False
145      options.analyze_dictionary = False
146      options.arguments.extend(LIBFUZZER_OPTIONS)
147
148      result = engine_impl.fuzz(self.target_path, options,
149                                self.workspace.artifacts, self.duration)
150
151    # Libfuzzer timeout was reached.
152    if not result.crashes:
153      logging.info('Fuzzer %s finished with no crashes discovered.',
154                   self.target_name)
155      return FuzzResult(None, None, self.latest_corpus_path)
156
157    # Only report first crash.
158    crash = result.crashes[0]
159    logging.info('Fuzzer: %s. Detected bug:\n%s', self.target_name,
160                 crash.stacktrace)
161
162    if self.is_crash_reportable(crash.input_path):
163      # We found a bug in the fuzz target and we will report it.
164      return FuzzResult(crash.input_path, result.logs, self.latest_corpus_path)
165
166    # We found a bug but we won't report it.
167    return FuzzResult(None, None, self.latest_corpus_path)
168
169  def free_disk_if_needed(self, delete_fuzz_target=True):
170    """Deletes things that are no longer needed from fuzzing this fuzz target to
171    save disk space if needed."""
172    if not self.config.low_disk_space:
173      logging.info('Not freeing disk space after running fuzz target.')
174      return
175    logging.info('Deleting corpus and seed corpus of %s to save disk.',
176                 self.target_name)
177
178    # Delete the seed corpus, corpus, and fuzz target.
179    for corpus_path in [self.latest_corpus_path, self.pruned_corpus_path]:
180      # Use ignore_errors=True to fix
181      # https://github.com/google/oss-fuzz/issues/5383.
182      shutil.rmtree(corpus_path, ignore_errors=True)
183
184    target_seed_corpus_path = self.target_path + '_seed_corpus.zip'
185    if os.path.exists(target_seed_corpus_path):
186      os.remove(target_seed_corpus_path)
187
188    if delete_fuzz_target:
189      logging.info('Deleting fuzz target: %s.', self.target_name)
190      os.remove(self.target_path)
191    logging.info('Done deleting.')
192
193  def is_reproducible(self, testcase, target_path):
194    """Checks if the testcase reproduces.
195
196      Args:
197        testcase: The path to the testcase to be tested.
198        target_path: The path to the fuzz target to be tested
199
200      Returns:
201        True if crash is reproducible and we were able to run the
202        binary.
203
204      Raises:
205        ReproduceError if we can't attempt to reproduce the crash.
206    """
207    if not os.path.exists(target_path):
208      logging.info('Target: %s does not exist.', target_path)
209      raise ReproduceError(f'Target {target_path} not found.')
210
211    os.chmod(target_path, stat.S_IRWXO)
212
213    logging.info('Trying to reproduce crash using: %s.', testcase)
214    with clusterfuzz.environment.Environment(config_utils.DEFAULT_ENGINE,
215                                             self.config.sanitizer,
216                                             target_path,
217                                             interactive=True):
218      for _ in range(REPRODUCE_ATTEMPTS):
219        engine_impl = clusterfuzz.fuzz.get_engine(config_utils.DEFAULT_ENGINE)
220        result = engine_impl.reproduce(target_path,
221                                       testcase,
222                                       arguments=[],
223                                       max_time=REPRODUCE_TIME_SECONDS)
224
225        if result.return_code != 0:
226          logging.info('Reproduce command returned: %s. Reproducible on %s.',
227                       result.return_code, target_path)
228
229          return True
230
231    logging.info('Reproduce command returned: 0. Not reproducible on %s.',
232                 target_path)
233    return False
234
235  def is_crash_reportable(self, testcase):
236    """Returns True if a crash is reportable. This means the crash is
237    reproducible but not reproducible on a build from the ClusterFuzz deployment
238    (meaning the crash was introduced by this PR/commit/code change).
239
240    Args:
241      testcase: The path to the testcase that triggered the crash.
242
243    Returns:
244      True if the crash was introduced by the current pull request.
245
246    Raises:
247      ReproduceError if we can't attempt to reproduce the crash on the PR build.
248    """
249    if not os.path.exists(testcase):
250      raise ReproduceError(f'Testcase {testcase} not found.')
251
252    try:
253      reproducible_on_code_change = self.is_reproducible(
254          testcase, self.target_path)
255    except ReproduceError as error:
256      logging.error('Could not check for crash reproducibility.'
257                    'Please file an issue:'
258                    'https://github.com/google/oss-fuzz/issues/new.')
259      raise error
260
261    if not reproducible_on_code_change:
262      logging.info('Crash is not reproducible.')
263      return self.config.report_unreproducible_crashes
264
265    logging.info('Crash is reproducible.')
266    return self.is_crash_novel(testcase)
267
268  def is_crash_novel(self, testcase):
269    """Returns whether or not the crash is new. A crash is considered new if it
270    can't be reproduced on an older ClusterFuzz build of the target."""
271    if not os.path.exists(testcase):
272      raise ReproduceError('Testcase %s not found.' % testcase)
273    clusterfuzz_build_dir = self.clusterfuzz_deployment.download_latest_build()
274    if not clusterfuzz_build_dir:
275      # Crash is reproducible on PR build and we can't test on a recent
276      # ClusterFuzz/OSS-Fuzz build.
277      logging.info(COULD_NOT_TEST_ON_CLUSTERFUZZ_MESSAGE)
278      return True
279
280    clusterfuzz_target_path = os.path.join(clusterfuzz_build_dir,
281                                           self.target_name)
282
283    try:
284      reproducible_on_clusterfuzz_build = self.is_reproducible(
285          testcase, clusterfuzz_target_path)
286    except ReproduceError:
287      # This happens if the project has ClusterFuzz builds, but the fuzz target
288      # is not in it (e.g. because the fuzz target is new).
289      logging.info(COULD_NOT_TEST_ON_CLUSTERFUZZ_MESSAGE)
290      return True
291
292    if reproducible_on_clusterfuzz_build:
293      logging.info('The crash is reproducible on previous build. '
294                   'Code change (pr/commit) did not introduce crash.')
295      return False
296    logging.info('The crash is not reproducible on previous build. '
297                 'Code change (pr/commit) introduced crash.')
298    return True
299