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 re 19import shutil 20import stat 21import subprocess 22import sys 23 24import docker 25 26# pylint: disable=wrong-import-position,import-error 27sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) 28import utils 29 30logging.basicConfig( 31 format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', 32 level=logging.DEBUG) 33 34# Use a fixed seed for determinism. Use len_control=0 since we don't have enough 35# time fuzzing for len_control to make sense (probably). 36LIBFUZZER_OPTIONS = '-seed=1337 -len_control=0' 37 38# The number of reproduce attempts for a crash. 39REPRODUCE_ATTEMPTS = 10 40 41# Seconds on top of duration until a timeout error is raised. 42BUFFER_TIME = 10 43 44# Log message if we can't check if crash reproduces on an recent build. 45COULD_NOT_TEST_ON_RECENT_MESSAGE = ( 46 'Crash is reproducible. Could not run recent build of ' 47 'target to determine if this code change (pr/commit) introduced crash. ' 48 'Assuming this code change introduced crash.') 49 50FuzzResult = collections.namedtuple('FuzzResult', ['testcase', 'stacktrace']) 51 52 53class ReproduceError(Exception): 54 """Error for when we can't attempt to reproduce a crash.""" 55 56 57class FuzzTarget: 58 """A class to manage a single fuzz target. 59 60 Attributes: 61 target_name: The name of the fuzz target. 62 duration: The length of time in seconds that the target should run. 63 target_path: The location of the fuzz target binary. 64 out_dir: The location of where output artifacts are stored. 65 """ 66 67 # pylint: disable=too-many-arguments 68 def __init__(self, target_path, duration, out_dir, clusterfuzz_deployment, 69 config): 70 """Represents a single fuzz target. 71 72 Args: 73 target_path: The location of the fuzz target binary. 74 duration: The length of time in seconds the target should run. 75 out_dir: The location of where the output from crashes should be stored. 76 clusterfuzz_deployment: The object representing the ClusterFuzz 77 deployment. 78 config: The config of this project. 79 """ 80 self.target_path = target_path 81 self.target_name = os.path.basename(self.target_path) 82 self.duration = int(duration) 83 self.out_dir = out_dir 84 self.clusterfuzz_deployment = clusterfuzz_deployment 85 self.config = config 86 self.latest_corpus_path = None 87 88 def fuzz(self): 89 """Starts the fuzz target run for the length of time specified by duration. 90 91 Returns: 92 FuzzResult namedtuple with stacktrace and testcase if applicable. 93 """ 94 logging.info('Fuzzer %s, started.', self.target_name) 95 docker_container = utils.get_container_name() 96 command = ['docker', 'run', '--rm', '--privileged'] 97 if docker_container: 98 command += [ 99 '--volumes-from', docker_container, '-e', 'OUT=' + self.out_dir 100 ] 101 else: 102 command += ['-v', '%s:%s' % (self.out_dir, '/out')] 103 104 command += [ 105 '-e', 'FUZZING_ENGINE=libfuzzer', '-e', 106 'SANITIZER=' + self.config.sanitizer, '-e', 'CIFUZZ=True', '-e', 107 'RUN_FUZZER_MODE=interactive', docker.BASE_RUNNER_TAG, 'bash', '-c' 108 ] 109 110 run_fuzzer_command = 'run_fuzzer {fuzz_target} {options}'.format( 111 fuzz_target=self.target_name, 112 options=LIBFUZZER_OPTIONS + ' -max_total_time=' + str(self.duration)) 113 114 # If corpus can be downloaded use it for fuzzing. 115 self.latest_corpus_path = self.clusterfuzz_deployment.download_corpus( 116 self.target_name, self.out_dir) 117 if self.latest_corpus_path: 118 run_fuzzer_command = run_fuzzer_command + ' ' + self.latest_corpus_path 119 command.append(run_fuzzer_command) 120 121 logging.info('Running command: %s', ' '.join(command)) 122 process = subprocess.Popen(command, 123 stdout=subprocess.PIPE, 124 stderr=subprocess.PIPE) 125 126 try: 127 _, stderr = process.communicate(timeout=self.duration + BUFFER_TIME) 128 except subprocess.TimeoutExpired: 129 logging.error('Fuzzer %s timed out, ending fuzzing.', self.target_name) 130 return FuzzResult(None, None) 131 132 # Libfuzzer timeout was reached. 133 if not process.returncode: 134 logging.info('Fuzzer %s finished with no crashes discovered.', 135 self.target_name) 136 return FuzzResult(None, None) 137 138 # Crash was discovered. 139 logging.info('Fuzzer %s, ended before timeout.', self.target_name) 140 testcase = self.get_testcase(stderr) 141 if not testcase: 142 logging.error(b'No testcase found in stacktrace: %s.', stderr) 143 return FuzzResult(None, None) 144 145 utils.binary_print(b'Fuzzer: %s. Detected bug:\n%s' % 146 (self.target_name.encode(), stderr)) 147 if self.is_crash_reportable(testcase): 148 # We found a bug in the fuzz target and we will report it. 149 return FuzzResult(testcase, stderr) 150 151 # We found a bug but we won't report it. 152 return FuzzResult(None, None) 153 154 def free_disk_if_needed(self): 155 """Deletes things that are no longer needed from fuzzing this fuzz target to 156 save disk space if needed.""" 157 if not self.config.low_disk_space: 158 return 159 logging.info( 160 'Deleting corpus, seed corpus and fuzz target of %s to save disk.', 161 self.target_name) 162 163 # Delete the seed corpus, corpus, and fuzz target. 164 if self.latest_corpus_path and os.path.exists(self.latest_corpus_path): 165 # Use ignore_errors=True to fix 166 # https://github.com/google/oss-fuzz/issues/5383. 167 shutil.rmtree(self.latest_corpus_path, ignore_errors=True) 168 169 os.remove(self.target_path) 170 target_seed_corpus_path = self.target_path + '_seed_corpus.zip' 171 if os.path.exists(target_seed_corpus_path): 172 os.remove(target_seed_corpus_path) 173 logging.info('Done deleting.') 174 175 def is_reproducible(self, testcase, target_path): 176 """Checks if the testcase reproduces. 177 178 Args: 179 testcase: The path to the testcase to be tested. 180 target_path: The path to the fuzz target to be tested 181 182 Returns: 183 True if crash is reproducible and we were able to run the 184 binary. 185 186 Raises: 187 ReproduceError if we can't attempt to reproduce the crash. 188 """ 189 190 if not os.path.exists(target_path): 191 raise ReproduceError('Target %s not found.' % target_path) 192 193 os.chmod(target_path, stat.S_IRWXO) 194 195 target_dirname = os.path.dirname(target_path) 196 command = ['docker', 'run', '--rm', '--privileged'] 197 container = utils.get_container_name() 198 if container: 199 command += [ 200 '--volumes-from', container, '-e', 'OUT=' + target_dirname, '-e', 201 'TESTCASE=' + testcase 202 ] 203 else: 204 command += [ 205 '-v', 206 '%s:/out' % target_dirname, '-v', 207 '%s:/testcase' % testcase 208 ] 209 210 command += [ 211 '-t', docker.BASE_RUNNER_TAG, 'reproduce', self.target_name, '-runs=100' 212 ] 213 214 logging.info('Running reproduce command: %s.', ' '.join(command)) 215 for _ in range(REPRODUCE_ATTEMPTS): 216 _, _, returncode = utils.execute(command) 217 if returncode != 0: 218 logging.info('Reproduce command returned: %s. Reproducible on %s.', 219 returncode, target_path) 220 221 return True 222 223 logging.info('Reproduce command returned 0. Not reproducible on %s.', 224 target_path) 225 return False 226 227 def is_crash_reportable(self, testcase): 228 """Returns True if a crash is reportable. This means the crash is 229 reproducible but not reproducible on a build from the ClusterFuzz deployment 230 (meaning the crash was introduced by this PR/commit/code change). 231 232 Args: 233 testcase: The path to the testcase that triggered the crash. 234 235 Returns: 236 True if the crash was introduced by the current pull request. 237 238 Raises: 239 ReproduceError if we can't attempt to reproduce the crash on the PR build. 240 """ 241 if not os.path.exists(testcase): 242 raise ReproduceError('Testcase %s not found.' % testcase) 243 244 try: 245 reproducible_on_code_change = self.is_reproducible( 246 testcase, self.target_path) 247 except ReproduceError as error: 248 logging.error('Could not run target when checking for reproducibility.' 249 'Please file an issue:' 250 'https://github.com/google/oss-fuzz/issues/new.') 251 raise error 252 253 if not reproducible_on_code_change: 254 logging.info('Failed to reproduce the crash using the obtained testcase.') 255 return False 256 257 clusterfuzz_build_dir = self.clusterfuzz_deployment.download_latest_build( 258 self.out_dir) 259 if not clusterfuzz_build_dir: 260 # Crash is reproducible on PR build and we can't test on a recent 261 # ClusterFuzz/OSS-Fuzz build. 262 logging.info(COULD_NOT_TEST_ON_RECENT_MESSAGE) 263 return True 264 265 clusterfuzz_target_path = os.path.join(clusterfuzz_build_dir, 266 self.target_name) 267 try: 268 reproducible_on_clusterfuzz_build = self.is_reproducible( 269 testcase, clusterfuzz_target_path) 270 except ReproduceError: 271 # This happens if the project has ClusterFuzz builds, but the fuzz target 272 # is not in it (e.g. because the fuzz target is new). 273 logging.info(COULD_NOT_TEST_ON_RECENT_MESSAGE) 274 return True 275 276 if not reproducible_on_clusterfuzz_build: 277 logging.info('The crash is reproducible. The crash doesn\'t reproduce ' 278 'on old builds. This code change probably introduced the ' 279 'crash.') 280 return True 281 282 logging.info('The crash is reproducible on old builds ' 283 '(without the current code change).') 284 return False 285 286 def get_testcase(self, error_bytes): 287 """Gets the file from a fuzzer run stacktrace. 288 289 Args: 290 error_bytes: The bytes containing the output from the fuzzer. 291 292 Returns: 293 The path to the testcase or None if not found. 294 """ 295 match = re.search(rb'\bTest unit written to \.\/([^\s]+)', error_bytes) 296 if match: 297 return os.path.join(self.out_dir, match.group(1).decode('utf-8')) 298 return None 299