Skip to content

Writing an Adapter

Tyson Smith edited this page Aug 11, 2021 · 14 revisions

Overview

To run a fuzzer via Grizzly an Adapter must be created. Depending on the fuzzer and the use case an Adapter may only be a few lines of Python. Only the generate() method must be implemented but typically setup() is implemented as well.

The function of the Adapter is to act as the interface between Grizzly and the fuzzer. This includes handling input data (if provided), output data and execution of the fuzzer. A TestCase must be populated that Grizzly will then use to send the data to the browser. This must be done by the Adapter developer by using the API provided by TestCase and TestFile and in more advanced situations ServerMap.

NOTE: The API is subject to change without notice, although this will likely happen infrequently.

The most basic Adapter only needs to add a single TestFile to the TestCase and it must point to TestCase.landing_page (the test case entry point). In other words if the Adapter attaches a TestFile to the TestCase object and TestFile.file_name == TestCase.landing_page Grizzly will handle the rest. TestFiles can be added to the TestCase via TestCase.add_file(). There are other ways to do this as well see here.

To write an adapter see the Adapter template and for a working example see the no-op example adapter.

Basic Adapter

This example will work with Domato with very little modification but it is meant to act as an example so some assumptions are made. This is not meant to be used as is.

basic_adapter.py

from os import listdir, remove
from os.path import isdir, join as pathjoin
from shutil import rmtree
from subprocess import check_output
from tempfile import mkdtemp

from grizzly.adapter import Adapter, TestFile

class BasicExampleAdapter(Adapter):
    def setup(self, input_path, server_map):
        self.enable_harness()
        # create directory to temporarily store generated content
        self.fuzz["tmp"] = mkdtemp(prefix="fuzz_gen_")
        # command used to call the fuzzer to generate output
        self.fuzz["cmd"] = [
            input_path,  # binary to call
            "--no_of_files", "1",
            "--output_dir", self.fuzz["tmp"]
        ]
        # This is also a good place to make content
        # in other directories accessible via the web server.
        # For example assume that "/test/data/" contains:
        # - a.html
        # - b/c.js
        # - media/d.jpg
        # Using server_map.set_include("inc",  "/test/data/")
        # it can be made accessible via a test case as:
        # - /inc/a.html
        # - /inc/sub/include.js
        # - media/d.jpg
        # Content served via includes is automatically added
        # to the test case.

    def generate(self, testcase, _):
        # launch fuzzer to generate a single file
        check_output(self.fuzz["cmd"])
        # lookup the name of the newly generated file on disk
        gen_file = pathjoin(self.fuzz["tmp"], listdir(self.fuzz["tmp"])[0])
        # create a TestFile from the generated file
        test_file = TestFile.from_file(gen_file, testcase.landing_page)
        # remove generated file now that the data has been added to a test file
        remove(gen_file)
        # add test file to the testcase
        testcase.add_file(test_file)

    def shutdown(self):
        # remove temporary working directory if needed
        if isdir(self.fuzz["tmp"]):
            rmtree(self.fuzz["tmp"], ignore_errors=True)

Advanced Adapter

To write an Adapter for an existing fuzzer/test case generator here is a generic example that can be modified. We currently use variations of this Adapter to run internal fuzzers such as Avalanche and Dharma and external fuzzers such as Domato and Radamsa.

This example assumes that the fuzzer takes two arguments --count number of test cases to generate and --out the path to dump test cases in. It also assumes when launched it will generate single files HTML test cases and will exit when complete returning 0 on success.

example_adapter.py

from contextlib import contextmanager
from os import devnull, listdir, remove
from os.path import isdir, join as pathjoin
from shutil import rmtree
from subprocess import DEVNULL, Popen, TimeoutExpired
from tempfile import mkdtemp

from grizzly.adapter import Adapter, AdapterError

class ExampleError(AdapterError):
    pass

class ExampleAdapter(Adapter):
    def setup(self, input_path, _):
        """ perform necessary setup here"""
        self.enable_harness()
        self.fuzz["generator"] = FuzzGenerator(input_path)

    def generate(self, testcase, _):
        with self.fuzz["generator"].fuzzed_file() as fpath:
            testcase.add_from_file(fpath, file_name=testcase.landing_page)

    def shutdown(self):
        if "generator" in self.fuzz:
            self.fuzz["generator"].close()


class FuzzGenerator:
    GEN_TIMEOUT = 120  # timeout for external test case generator
    QUEUE_SIZE = 10  # number of test cases to generate per batch

    def __init__(self, tool):
        self._pool = mkdtemp(prefix="fuzz_gen_")
        # build the command to call the external test case generator
        # make sure to put tests in the correct temp path
        self._cmd = [tool, "--count", str(self.QUEUE_SIZE), "--out", self._pool]
        self._queue = None
        self._worker = None
        self._launch()

    def _launch(self):
        # launch external test case generator / fuzzer
        self._worker = Popen(self._cmd, shell=False, stdout=DEVNULL, stderr=DEVNULL)

    def close(self):
        if self._worker is not None:
            if self._worker.poll() is None:
                self._worker.terminate()
            self._worker.wait()
        if isdir(self._pool):
            rmtree(self._pool, ignore_errors=True)

    @contextmanager
    def fuzzed_file(self):
        if self._worker is not None:
            try:
                self._worker.wait(timeout=self.GEN_TIMEOUT)
            except TimeoutExpired:
                self._worker.terminate()
                raise ExampleError("Generator timed out (%ds)" % (self.GEN_TIMEOUT,)) from None
            if self._worker.returncode != 0:
                raise ExampleError("Generator returned %d" % (self._worker.returncode,))
            self._worker = None
            self._queue = listdir(self._pool)
            if not self._queue:
                raise ExampleError("Generator failed to generate test cases")
        elif not self._queue:
            raise ExampleError("Queue is empty and worker is not running")
        pending = pathjoin(self._pool, self._queue.pop())
        try:
            yield pending
        finally:
            remove(pending)
        if not self._queue:
            self._launch()

Package and Install

In order to run an adapter it must be installed. The first step is to create a package for use with Setuptools, more information can be found here.

Here is a simple example for demo purposes:

setup.py

from setuptools import setup

setup(
    name='grizzly-adapter-example',
    version='0.0.1',
    packages=['example_adapter'],
    install_requires=[
        'grizzly-framework',
    ],
    entry_points={
       "grizzly_adapters": ["example = example_adapter.ExampleAdapter"]
    },
)

The directory structure for this should look like:

adapter_dev/
    example_adapter.py
    setup.py

To install the adapter for use:

python -m pip install -e adapter_dev --user

It should now be available via Grizzly (listed in Installed Adapters):

$ python -m grizzly -h
usage: __main__.py ...

positional arguments:
  binary                Firefox binary to run
  adapter               Installed Adapters: example
...