blob: 96f7fde237166bbd841c837967d5a7a0026bed46 [file] [log] [blame]
#!/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()