| #!/usr/bin/env python2.7 |
| # Copyright 2019 The Fuchsia Authors. All rights reserved. |
| # Use of this source code is governed by a BSD-style license that can be |
| # found in the LICENSE file. |
| |
| import argparse |
| import datetime |
| import errno |
| import glob |
| import os |
| import re |
| import subprocess |
| import time |
| import sys |
| |
| from device import Device |
| from host import Host |
| from cliutils import show_menu |
| |
| |
| class Fuzzer(object): |
| """Represents a Fuchsia fuzz target. |
| |
| This represents a binary fuzz target produced the Fuchsia build, referenced |
| by a component manifest, and included in a fuzz package. It provides an |
| interface for running the fuzzer in different common modes, allowing |
| specific command line arguments to libFuzzer to be abstracted. |
| |
| Attributes: |
| device: A Device where this fuzzer can be run |
| host: The build host that built the fuzzer |
| pkg: The GN fuzzers_package name |
| tgt: The GN fuzzers name |
| """ |
| |
| # Matches the prefixes in libFuzzer passed to |Fuzzer::DumpCurrentUnit| or |
| # |Fuzzer::WriteUnitToFileWithPrefix|. |
| ARTIFACT_PREFIXES = [ |
| 'crash', 'leak', 'mismatch', 'oom', 'slow-unit', 'timeout' |
| ] |
| |
| class NameError(ValueError): |
| """Indicates a supplied name is malformed or unusable.""" |
| pass |
| |
| class StateError(ValueError): |
| """Indicates a command isn't valid for the fuzzer in its current state.""" |
| pass |
| |
| @classmethod |
| def filter(cls, fuzzers, name): |
| """Filters a list of fuzzer names. |
| |
| Takes a list of fuzzer names in the form `pkg`/`tgt` and a name to filter |
| on. If the name is of the form 'x/y', the filtered list will include all |
| the fuzzer names where 'x' is a substring of `pkg` and y is a substring |
| of `tgt`; otherwise it includes all the fuzzer names where `name` is a |
| substring of either `pkg` or `tgt`. |
| |
| Returns: |
| A list of fuzzer names matching the given name. |
| |
| Raises: |
| FuzzerNameError: Name is malformed, e.g. of the form 'x/y/z'. |
| """ |
| if not name or name == '': |
| return fuzzers |
| names = name.split('/') |
| if len(names) == 2 and (names[0], names[1]) in fuzzers: |
| return [(names[0], names[1])] |
| if len(names) == 1: |
| return list( |
| set(Fuzzer.filter(fuzzers, '/' + name)) | |
| set(Fuzzer.filter(fuzzers, name + '/'))) |
| elif len(names) != 2: |
| raise Fuzzer.NameError('Malformed fuzzer name: ' + name) |
| filtered = [] |
| for pkg, tgt in fuzzers: |
| if names[0] in pkg and names[1] in tgt: |
| filtered.append((pkg, tgt)) |
| return filtered |
| |
| @classmethod |
| def from_args(cls, device, args): |
| """Constructs a Fuzzer from command line arguments, showing a |
| disambiguation menu if specified name matches more than one fuzzer.""" |
| |
| matches = Fuzzer.filter(device.host.fuzzers, args.name) |
| |
| if not matches: |
| sys.exit('No matching fuzzers found. Try `fx fuzz list`.') |
| |
| if len(matches) > 1: |
| print('More than one match found, please pick one from the list:') |
| choices = ["/".join(m) for m in matches] |
| fuzzer_name = show_menu(choices).split('/') |
| else: |
| fuzzer_name = matches[0] |
| |
| return cls( |
| device, fuzzer_name[0], fuzzer_name[1], args.output, |
| args.foreground, args.debug) |
| |
| def __init__( |
| self, device, pkg, tgt, output=None, foreground=False, debug=False): |
| self.device = device |
| self.host = device.host |
| self.pkg = pkg |
| self.tgt = tgt |
| if output: |
| self._output = output |
| else: |
| self._output = self.host.join( |
| 'test_data', 'fuzzing', self.pkg, self.tgt) |
| self._foreground = foreground |
| self._debug = debug |
| |
| def __str__(self): |
| return self.pkg + '/' + self.tgt |
| |
| def data_path(self, relpath=''): |
| """Canonicalizes the location of mutable data for this fuzzer.""" |
| return '/data/r/sys/fuchsia.com:%s:0#meta:%s.cmx/%s' % ( |
| self.pkg, self.tgt, relpath) |
| |
| def measure_corpus(self): |
| """Returns the number of corpus elements and corpus size as a pair.""" |
| try: |
| sizes = self.device.ls(self.data_path('corpus')) |
| return (len(sizes), sum(sizes.values())) |
| except subprocess.CalledProcessError: |
| return (0, 0) |
| |
| def list_artifacts(self): |
| """Returns a list of test unit artifacts, i.e. fuzzing crashes.""" |
| artifacts = [] |
| try: |
| lines = self.device.ls(self.data_path()) |
| for file, _ in lines.iteritems(): |
| for prefix in Fuzzer.ARTIFACT_PREFIXES: |
| if file.startswith(prefix): |
| artifacts.append(file) |
| return artifacts |
| except subprocess.CalledProcessError: |
| return [] |
| |
| def is_running(self): |
| """Checks the device and returns whether the fuzzer is running.""" |
| return self.tgt in self.device.getpids() |
| |
| def require_stopped(self): |
| """Raise an exception if the fuzzer is running.""" |
| if self.is_running(): |
| raise Fuzzer.StateError( |
| str(self) + ' is running and must be stopped first.') |
| |
| def results(self, relpath=None): |
| """Returns the path in the previously prepared results directory.""" |
| if relpath: |
| return os.path.join(self._output, 'latest', relpath) |
| else: |
| return os.path.join(self._output, 'latest') |
| |
| def url(self): |
| return 'fuchsia-pkg://fuchsia.com/%s#meta/%s.cmx' % (self.pkg, self.tgt) |
| |
| def _create(self, fuzzer_args, logfile=None): |
| # Disable exception handling in debug mode |
| if self._debug: |
| for signal in ['segv', 'bus', 'ill', 'fpe', 'abrt']: |
| fuzzer_args.append("-handle_{}=0".format(signal)) |
| |
| fuzz_cmd = ['run', self.url(), '-artifact_prefix=data/'] + fuzzer_args |
| |
| print('+ ' + ' '.join(fuzz_cmd)) |
| return self.device.ssh(fuzz_cmd) |
| |
| def start(self, fuzzer_args): |
| """Runs the fuzzer. |
| |
| Executes a fuzzer in the "normal" fuzzing mode. If the fuzzer is being |
| run in the foreground, it will block until the fuzzer exits. If the |
| fuzzer is being run in the background, it will return immediately after |
| the fuzzer has been started, and callers should subsequently call |
| Fuzzer.monitor(). |
| |
| The command will be like: |
| run fuchsia-pkg://fuchsia.com/<pkg>#meta/<tgt>.cmx \ |
| -artifact_prefix=data/ -dict=pkg/data/<tgt>/dictionary -jobs=1 data/corpus/ |
| |
| See also: https://llvm.org/docs/LibFuzzer.html#running |
| |
| Args: |
| fuzzer_args: Command line arguments to pass to libFuzzer |
| |
| Returns: |
| The fuzzer's process ID. May be 0 if the fuzzer stops immediately. |
| """ |
| self.require_stopped() |
| self.device.rm(self.data_path('fuzz-*.log')) |
| results = os.path.join( |
| self._output, |
| datetime.datetime.today().isoformat()) |
| try: |
| os.unlink(self.results()) |
| except OSError as e: |
| if e.errno != errno.ENOENT: |
| raise |
| try: |
| os.makedirs(results) |
| except OSError as e: |
| if e.errno != errno.EEXIST: |
| raise |
| os.symlink(results, self.results()) |
| |
| if len(filter(lambda x: x.startswith('-jobs='), fuzzer_args)) == 0: |
| if self._foreground: |
| fuzzer_args.append('-jobs=0') |
| else: |
| fuzzer_args.append('-jobs=1') |
| fuzzer_args.append('-dict=pkg/data/{}/dictionary'.format(self.tgt)) |
| self.device.ssh(['mkdir', '-p', self.data_path('corpus')]).check_call() |
| if len(filter(lambda x: not x.startswith('-'), fuzzer_args)) == 0: |
| fuzzer_args.append('data/corpus/') |
| |
| # Fuzzer logs are saved to fuzz-*.log when running in the background. |
| # We tee the output to fuzz-0.log when running in the foreground to |
| # make the rest of the plumbing look the same. |
| cmd = self._create(fuzzer_args) |
| if self._foreground: |
| cmd.stderr = subprocess.PIPE |
| proc = cmd.popen() |
| if self._foreground: |
| logfile = self.results('fuzz-0.log') |
| with open(logfile, 'w') as fd_out: |
| self.symbolize_log(proc.stderr, fd_out, echo=True) |
| proc.wait() |
| |
| def symbolize_log(self, fd_in, fd_out, echo=False): |
| """Constructs a symbolized fuzzer log from a device. |
| |
| Merges the provided fuzzer log with the symbolized system log for the |
| fuzzer process. |
| |
| Args: |
| fd_in: An object supporting readline(), such as a file or pipe. |
| fd_out: An object supporting write(), such as a file. |
| echo: If true, display text being written to fd_out. |
| """ |
| pid = -1 |
| sym = None |
| artifacts = [] |
| pid_pattern = re.compile(r'^==([0-9]+)==') |
| mut_pattern = re.compile(r'^MS: [0-9]*') # Fuzzer::DumpCurrentUnit |
| art_pattern = re.compile(r'Test unit written to data/(\S*)') |
| for line in iter(fd_in.readline, ''): |
| pid_match = pid_pattern.search(line) |
| mut_match = mut_pattern.search(line) |
| art_match = art_pattern.search(line) |
| if pid_match: |
| pid = int(pid_match.group(1)) |
| if mut_match: |
| if pid <= 0: |
| pid = self.device.guess_pid() |
| if not sym: |
| raw = self.device.dump_log(['--pid', str(pid)]) |
| sym = self.host.symbolize(raw) |
| fd_out.write(sym) |
| if echo: |
| print(sym.strip()) |
| if art_match: |
| artifacts.append(art_match.group(1)) |
| fd_out.write(line) |
| if echo: |
| print(line.strip()) |
| for artifact in artifacts: |
| self.device.fetch(self.data_path(artifact), self.results()) |
| |
| def monitor(self): |
| """Waits for a fuzzer to complete and symbolizes its logs. |
| |
| Polls the device to determine when the fuzzer stops. Retrieves, |
| combines and symbolizes the associated fuzzer and kernel logs. Fetches |
| any referenced test artifacts, e.g. crashes. |
| """ |
| while self.is_running(): |
| time.sleep(2) |
| self.device.fetch( |
| self.data_path('fuzz-*.log'), self.results(), retries=2) |
| logs = glob.glob(self.results('fuzz-*.log')) |
| artifacts = [] |
| for log in logs: |
| tmp = log + '.tmp' |
| with open(log, 'r') as fd_in: |
| with open(tmp, 'w') as fd_out: |
| self.symbolize_log(fd_in, fd_out, echo=False) |
| os.rename(tmp, log) |
| |
| def stop(self): |
| """Stops any processes with a matching component manifest on the device.""" |
| pids = self.device.getpids() |
| if self.tgt in pids: |
| self.device.ssh(['kill', str(pids[self.tgt])]).check_call() |
| |
| def repro(self, fuzzer_args): |
| """Runs the fuzzer with test input artifacts. |
| |
| Executes a command like: |
| run fuchsia-pkg://fuchsia.com/<pkg>#meta/<tgt>.cmx \ |
| -artifact_prefix=data -jobs=1 data/<artifact>... |
| |
| If host artifact paths are specified, they will be copied to the device |
| instance and used. Otherwise, the fuzzer will use all artifacts present |
| on the device. |
| |
| See also: https://llvm.org/docs/LibFuzzer.html#options |
| |
| Returns: Number of test input artifacts found. |
| """ |
| options = [] |
| artifacts = [] |
| for arg in fuzzer_args: |
| if arg.startswith('-'): |
| options.append(arg) |
| elif os.path.exists(arg): |
| artifact = os.path.basename(arg) |
| self.device.store(arg, self.data_path()) |
| artifacts.append(artifact) |
| else: |
| print('File not found, skipping: ' + arg) |
| if not artifacts: |
| artifacts = self.list_artifacts() |
| if not artifacts: |
| return 0 |
| self._create(options + ['data/' + a for a in artifacts]).call() |
| return len(artifacts) |
| |
| def merge(self, fuzzer_args): |
| """Attempts to minimizes the fuzzer's corpus. |
| |
| Executes a command like: |
| run fuchsia-pkg://fuchsia.com/<pkg>#meta/<tgt>.cmx \ |
| -artifact_prefix=data -jobs=1 \ |
| -merge=1 -merge_control_file=data/.mergefile \ |
| data/corpus/ data/corpus.prev/' |
| |
| See also: https://llvm.org/docs/LibFuzzer.html#corpus |
| |
| Returns: Same as measure_corpus |
| """ |
| self.require_stopped() |
| if self.measure_corpus() == (0, 0): |
| return (0, 0) |
| self.device.ssh(['mkdir', '-p', self.data_path('corpus')]).check_call() |
| self.device.ssh(['mkdir', '-p', |
| self.data_path('corpus.prev')]).check_call() |
| self.device.ssh( |
| ['mv', |
| self.data_path('corpus/*'), |
| self.data_path('corpus.prev')]).check_call() |
| # Save mergefile in case we are interrupted |
| fuzzer_args = [ |
| '-merge=1', '-merge_control_file=data/.mergefile' |
| ] + fuzzer_args |
| fuzzer_args.append('data/corpus/') |
| fuzzer_args.append('data/corpus.prev/') |
| self._create(fuzzer_args).check_call() |
| # Cleanup |
| self.device.rm(self.data_path('.mergefile')) |
| self.device.rm(self.data_path('corpus.prev'), recursive=True) |
| return self.measure_corpus() |