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