├── .github ├── dependabot.yml └── workflows │ ├── ci.yml │ ├── pip-audit.yml │ └── pythonpublish.yml ├── .gitignore ├── .gitmodules ├── LICENSE ├── MANIFEST.in ├── README.md ├── protofuzz ├── __init__.py ├── gen.py ├── log.py ├── pbimport.py ├── protofuzz.py ├── tests │ ├── __init__.py │ ├── test.proto │ ├── test_gen.py │ ├── test_log.py │ ├── test_pbimport.py │ ├── test_protofuzz.py │ └── test_values.py └── values.py └── setup.py /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | 3 | updates: 4 | - package-ecosystem: github-actions 5 | directory: / 6 | schedule: 7 | interval: daily 8 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | schedule: 9 | - cron: '0 12 * * *' 10 | 11 | jobs: 12 | lint: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v3.1.0 16 | - uses: actions/setup-python@v4 17 | with: 18 | python-version: ">= 3.9" 19 | - name: lint 20 | run: | 21 | pip3 install black 22 | black --check protofuzz 23 | 24 | test: 25 | strategy: 26 | matrix: 27 | python: 28 | - "3.9" 29 | - "3.10" 30 | runs-on: ubuntu-latest 31 | steps: 32 | - uses: actions/checkout@v3.1.0 33 | with: 34 | submodules: true 35 | - uses: actions/setup-python@v1 36 | with: 37 | python-version: ${{ matrix.python }} 38 | - name: deps 39 | run: sudo apt install -y protobuf-compiler 40 | - name: install 41 | run: python3 setup.py install 42 | - name: test 43 | run: python3 -m unittest discover protofuzz/tests 44 | -------------------------------------------------------------------------------- /.github/workflows/pip-audit.yml: -------------------------------------------------------------------------------- 1 | name: Scan dependencies for vulnerabilities with pip-audit 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | schedule: 9 | - cron: "0 12 * * *" 10 | 11 | jobs: 12 | pip-audit: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v3.1.0 18 | 19 | - name: Install Python 20 | uses: actions/setup-python@v4 21 | with: 22 | python-version: ">= 3.9" 23 | 24 | - name: Install project 25 | run: | 26 | python -m venv --upgrade-deps /tmp/pip-audit-env 27 | source /tmp/pip-audit-env/bin/activate 28 | 29 | python -m pip install . 30 | 31 | 32 | - name: Run pip-audit 33 | uses: pypa/gh-action-pip-audit@v1.0.5 34 | with: 35 | virtual-environment: /tmp/pip-audit-env 36 | 37 | -------------------------------------------------------------------------------- /.github/workflows/pythonpublish.yml: -------------------------------------------------------------------------------- 1 | # This workflows will upload a Python Package using Twine when a release is created 2 | # For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries 3 | 4 | name: Upload Python Package 5 | 6 | on: 7 | release: 8 | types: [published] 9 | 10 | jobs: 11 | deploy: 12 | 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v3.1.0 17 | - name: Set up Python 18 | uses: actions/setup-python@v4 19 | with: 20 | python-version: '>= 3.9' 21 | - name: Install dependencies 22 | run: | 23 | python -m pip install --upgrade pip 24 | pip install setuptools wheel twine 25 | - name: Build and publish 26 | env: 27 | TWINE_USERNAME: ${{ secrets.PYPI_USERNAME }} 28 | TWINE_PASSWORD: ${{ secrets.PYPI_PASSWORD }} 29 | run: | 30 | python setup.py sdist bdist_wheel 31 | twine upload dist/* 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | env/ 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *,cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # IPython Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # dotenv 79 | .env 80 | 81 | # virtualenv 82 | venv/ 83 | ENV/ 84 | 85 | # Spyder project settings 86 | .spyderproject 87 | 88 | # Rope project settings 89 | .ropeproject 90 | 91 | .DS_Store 92 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "protofuzz/fuzzdb"] 2 | path = protofuzz/fuzzdb 3 | url = https://github.com/fuzzdb-project/fuzzdb.git 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Trail of Bits 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft protofuzz/fuzzdb/attack 2 | graft protofuzz/fuzzdb/regex 3 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ProtoFuzz 2 | 3 | [![CI](https://github.com/trailofbits/protofuzz/workflows/CI/badge.svg)](https://github.com/trailofbits/protofuzz/actions/workflows/ci.yml) 4 | [![PyPI version](https://badge.fury.io/py/protofuzz.svg)](https://badge.fury.io/py/protofuzz) 5 | 6 | ProtoFuzz is a generic fuzzer for Google’s Protocol Buffers format. Instead of defining a new fuzzer generator for custom binary formats, protofuzz automatically creates a fuzzer based on the same format definition that programs use. ProtoFuzz is implemented as a stand-alone Python3 program. 7 | 8 | ## Installation 9 | 10 | Make sure you have protobuf package installed and `protoc` is accessible from $PATH, and that `protoc` can generate Python3-compatible code. 11 | 12 | ```console 13 | $ git clone --recursive git@github.com:trailofbits/protofuzz.git 14 | $ cd protofuzz 15 | $ python3 setup.py install 16 | ``` 17 | 18 | ## Usage 19 | 20 | ```python 21 | >>> from protofuzz import protofuzz 22 | >>> message_fuzzers = protofuzz.from_description_string(""" 23 | ... message Address { 24 | ... required int32 house = 1; 25 | ... required string street = 2; 26 | ... } 27 | ... """) 28 | >>> for obj in message_fuzzers['Address'].permute(): 29 | ... print("Generated object: {}".format(obj)) 30 | ... 31 | Generated object: house: -1 32 | street: "!" 33 | 34 | Generated object: house: 0 35 | street: "!" 36 | 37 | Generated object: house: 256 38 | street: "!" 39 | ... 40 | ``` 41 | 42 | You can also create dependencies between arbitrary fields that are resolved with 43 | any callable object: 44 | 45 | ```python 46 | >>> message_fuzzers = protofuzz.from_description_string(""" 47 | ... message Address { 48 | ... required int32 house = 1; 49 | ... required string street = 2; 50 | ... } 51 | ... message Other { 52 | ... required Address addr = 1; 53 | ... required uint32 foo = 2; 54 | ... } 55 | ... """) 56 | >>> fuzzer = message_fuzzers['Other'] 57 | >>> # The following creates a dependency that ensures Other.foo is always set 58 | >>> # to 1 greater than Other.addr.house 59 | >>> fuzzer.add_dependency('foo', 'addr.house', lambda x: x+1) 60 | >>> for obj in fuzzer.permute(): 61 | ... print("Generated object: {}".format(obj)) 62 | ``` 63 | 64 | Note however, the values your lambda creates must be conformant to the destination 65 | type. 66 | 67 | ## Caveats 68 | 69 | Currently, we use [fuzzdb](https://github.com/fuzzdb-project/fuzzdb) for values. This might not be complete or appropriate for your use. Consider swapping it for your own values. 70 | 71 | If you have your own separate instance of fuzzdb, you can export `FUZZDB_DIR` 72 | in your environment with the absolute path to your instance. 73 | 74 | ```console 75 | export FUZZDB_DIR=/path/to/your/fuzzdb 76 | ``` 77 | -------------------------------------------------------------------------------- /protofuzz/__init__.py: -------------------------------------------------------------------------------- 1 | """Protofuzz is a Google Protobuf data generator that uses fuzzdb. 2 | 3 | Usage: 4 | 5 | >>> from protofuzz import protofuzz, log 6 | 7 | >>> # Store the last 10 sent messages 8 | >>> logger = log.LastNMessagesLogger('logger', 10) 9 | >>> message_fuzzers = protofuzz.from_description_string(''' 10 | ... message Address { 11 | ... required int32 house = 1; 12 | ... required string street = 2; 13 | ... } 14 | ... ''') 15 | >>> fuzzer = message_fuzzers['Address'] 16 | ... for obj in fuzzer.permute(): 17 | ... print("Generated object: {}".format(obj)) 18 | ... logger.log(obj) 19 | ... 20 | 21 | """ 22 | 23 | __all__ = ["gen", "log", "protofuzz", "values"] 24 | -------------------------------------------------------------------------------- /protofuzz/gen.py: -------------------------------------------------------------------------------- 1 | """Define a set of value generators and permuters that create tuples of values.""" 2 | 3 | __all__ = ["IterValueGenerator", "DependentValueGenerator", "Zip", "Product"] 4 | 5 | 6 | class ValueGenerator(object): 7 | """Value generator..""" 8 | 9 | def __init__(self, name, limit=float("inf")): 10 | """Base class of a value generators.""" 11 | self._name = name 12 | self._cached_value = None 13 | self._limit = limit 14 | 15 | def name(self): 16 | """Return generator name.""" 17 | return self._name 18 | 19 | def set_name(self, name): 20 | """Set generator name.""" 21 | self._name = name 22 | 23 | def __iter__(self): 24 | return self 25 | 26 | def __next__(self): 27 | if self._limit == 0: 28 | raise StopIteration 29 | self._limit = self._limit - 1 # FIXME: refactor 30 | return self.get() 31 | 32 | def get(self): 33 | """Return the most recent value generated.""" 34 | raise NotImplementedError("Must override get()") 35 | 36 | def set_limit(self, limit): 37 | """Set a limit on how many values we should generate.""" 38 | self._limit = limit 39 | 40 | 41 | class IterValueGenerator(ValueGenerator): 42 | """Basic generator that successively returns values it was initialized with.""" 43 | 44 | def __init__(self, name, values): 45 | """Basic generator that successively returns values it was initialized with.""" 46 | super(IterValueGenerator, self).__init__(name) 47 | self._values = values 48 | self._iter = None 49 | 50 | def __iter__(self): 51 | self._iter = iter(self._values) 52 | return self 53 | 54 | def __next__(self): 55 | self._cached_value = next(self._iter) 56 | super().__next__() 57 | return self._cached_value 58 | 59 | def get(self): 60 | if self._cached_value is None: 61 | raise RuntimeError( 62 | "Can't get a value on a generator that isn't " + " being iterated" 63 | ) 64 | return self._cached_value 65 | 66 | 67 | class DependentValueGenerator(ValueGenerator): 68 | """A generator that represents a dependent value via a callable action.""" 69 | 70 | def __init__(self, name, target, action): 71 | """A generator that represents a dependent value via a callable action.""" 72 | super().__init__(name) 73 | self._target = target 74 | self._action = action 75 | 76 | def get(self): 77 | return self._action(self._target.get()) 78 | 79 | 80 | class Permuter(ValueGenerator): 81 | class MessageNotFound(RuntimeError): 82 | """Raised if attempted to reference an unknown child generator.""" 83 | 84 | pass 85 | 86 | def __init__(self, name, *generators, limit=float("inf")): 87 | """Base class for generators that permute multiple ValueGenerator objects.""" 88 | super().__init__(name, limit) 89 | self._generators = list(generators) 90 | self._update_independent_generators() 91 | 92 | @staticmethod 93 | def get_independent_generators(gens): 94 | """Return only those generators that produce their own values (as opposed to those that are related).""" 95 | return [_ for _ in gens if not isinstance(_, DependentValueGenerator)] 96 | 97 | def step_generator(self, generators): 98 | """The actual method responsible for the permutation strategy.""" 99 | raise NotImplementedError("Implement step_generator() in a subclass") 100 | 101 | def _update_independent_generators(self): 102 | independents = self.get_independent_generators(self._generators) 103 | self._independent_iterators = [iter(_) for _ in independents] 104 | self._step = self.step_generator(self._independent_iterators) 105 | 106 | def _resolve_child(self, path): 107 | """Return a member generator by a dot-delimited path.""" 108 | obj = self 109 | 110 | for component in path.split("."): 111 | ptr = obj 112 | if not isinstance(ptr, Permuter): 113 | raise self.MessageNotFound("Bad element path [wrong type]") 114 | 115 | # pylint: disable=protected-access 116 | found_gen = (_ for _ in ptr._generators if _.name() == component) 117 | 118 | obj = next(found_gen, None) 119 | 120 | if not obj: 121 | raise self.MessageNotFound( 122 | "Path '{}' unresolved to member.".format(path) 123 | ) 124 | return ptr, obj # FIXME: ptr might be referenced before assignment 125 | 126 | def make_dependent(self, source, target, action): 127 | """Create a dependency between path 'source' and path 'target' via the callable 'action'. 128 | 129 | >>> permuter._generators 130 | [IterValueGenerator(one), IterValueGenerator(two)] 131 | >>> permuter.make_dependent('one', 'two', lambda x: x + 1) 132 | 133 | Going forward, 'two' will only contain values that are (one+1). 134 | 135 | """ 136 | if not self._generators: 137 | return 138 | 139 | src_permuter, src = self._resolve_child(source) 140 | dest = self._resolve_child(target)[1] 141 | 142 | # pylint: disable=protected-access 143 | container = src_permuter._generators 144 | idx = container.index(src) 145 | container[idx] = DependentValueGenerator(src.name(), dest, action) 146 | 147 | self._update_independent_generators() 148 | 149 | def get(self): 150 | """Retrieve the most recent value generated.""" 151 | # If you attempt to use a generator comprehension below, it will 152 | # consume the StopIteration exception and just return an empty tuple, 153 | # instead of stopping iteration normally 154 | return tuple([(x.name(), x.get()) for x in self._generators]) 155 | 156 | def __iter__(self): 157 | self._update_independent_generators() 158 | return self 159 | 160 | def __next__(self): 161 | next(self._step) 162 | 163 | if self._limit == 0: 164 | self._step.close() 165 | raise StopIteration 166 | self._limit = self._limit - 1 167 | 168 | return self.get() 169 | 170 | 171 | class Zip(Permuter): 172 | """A permuter that is equivalent to the zip() builtin.""" 173 | 174 | def step_generator(self, generators): 175 | try: 176 | while True: 177 | # Step every generator in sync 178 | for generator in generators: 179 | next(generator) 180 | yield 181 | except (StopIteration, GeneratorExit): 182 | return 183 | 184 | 185 | class Product(Permuter): 186 | """A permuter that is equivalent to itertools.product.""" 187 | 188 | def step_generator(self, generators): 189 | if len(generators) < 1: 190 | yield () 191 | else: 192 | first, rest = generators[0], generators[1:] 193 | for item in first: 194 | for items in self.step_generator(rest): 195 | yield (item,) + items 196 | -------------------------------------------------------------------------------- /protofuzz/log.py: -------------------------------------------------------------------------------- 1 | """Utility logging class that is useful when running fuzzing campaigns.""" 2 | 3 | import os 4 | import pickle 5 | 6 | __all__ = ["Logger", "LastNMessagesLogger"] 7 | 8 | 9 | class Logger(object): 10 | """Base class for a fuzzing logger.""" 11 | 12 | def __init__(self, filename): 13 | """Class constructor.""" 14 | self._filename = filename 15 | 16 | def log(self, item): 17 | """Log the protobuf object |item|.""" 18 | raise NotImplementedError("Must implement log()") 19 | 20 | def get(self): 21 | """Return all the entries in the buffer.""" 22 | raise NotImplementedError("Must implement get()") 23 | 24 | 25 | class LastNMessagesLogger(Logger): 26 | """Maintain the last N messages in a file. 27 | 28 | Ensure messages are persisted to disk during every write. 29 | 30 | """ 31 | 32 | def __init__(self, filename, size=0): 33 | """Class constructor.""" 34 | super().__init__(filename) 35 | self._size = size 36 | 37 | def log(self, obj): 38 | """Commit an arbitrary (picklable) object to the log.""" 39 | entries = self.get() 40 | entries.append(obj) 41 | # Only log the last |n| entries if set 42 | if self._size > 0: 43 | entries = entries[-self._size :] 44 | self._write_entries(entries) 45 | 46 | def get(self): 47 | """Return contents of the file.""" 48 | entries = [] 49 | if not os.path.exists(self._filename): 50 | return entries 51 | 52 | log_file = open(self._filename, "rb") 53 | while ( 54 | log_file.peek() 55 | ): # FIXME unresolved attribute reference 'peek' for BinaryIO 56 | entries.append(pickle.load(log_file)) 57 | log_file.close() 58 | 59 | return entries 60 | 61 | def _write_entries(self, entries): 62 | log_file = open(self._filename, "wb") 63 | try: 64 | log_file.seek(0) 65 | for entry in entries: 66 | pickle.dump(entry, log_file, pickle.HIGHEST_PROTOCOL) 67 | log_file.flush() 68 | os.fsync(log_file.fileno()) 69 | finally: 70 | log_file.close() 71 | -------------------------------------------------------------------------------- /protofuzz/pbimport.py: -------------------------------------------------------------------------------- 1 | """Collection of functions dealing with locating and using the protobuf compiler.""" 2 | 3 | import sys 4 | import os 5 | import tempfile 6 | import subprocess 7 | import re 8 | import importlib 9 | import importlib.util 10 | 11 | __all__ = [ 12 | "BadProtobuf", 13 | "ProtocNotFound", 14 | "from_string", 15 | "from_file", 16 | "types_from_module", 17 | ] 18 | 19 | 20 | class BadProtobuf(Exception): 21 | """Raised when .proto file has errors.""" 22 | 23 | pass 24 | 25 | 26 | class ProtocNotFound(Exception): 27 | """Raised when failing to find the protoc binary.""" 28 | 29 | pass 30 | 31 | 32 | def find_protoc(path=os.environ["PATH"]): 33 | """Traverse a path ($PATH by default) to find the protoc compiler.""" 34 | protoc_filenames = ["protoc", "protoc.exe"] 35 | 36 | bin_search_paths = path.split(os.pathsep) or [] 37 | for search_path in bin_search_paths: 38 | for protoc_filename in protoc_filenames: 39 | bin_path = os.path.join(search_path, protoc_filename) 40 | if os.path.isfile(bin_path) and os.access(bin_path, os.X_OK): 41 | return bin_path 42 | 43 | raise ProtocNotFound("Protobuf compiler not found") 44 | 45 | 46 | def from_string(proto_str): 47 | """Produce a Protobuf module from a string description. 48 | 49 | Return the module if successfully compiled, otherwise raise a BadProtobuf exception. 50 | 51 | """ 52 | _, proto_file = tempfile.mkstemp(suffix=".proto") 53 | 54 | with open(proto_file, "w+") as proto_f: 55 | proto_f.write(proto_str) 56 | 57 | return from_file(proto_file) 58 | 59 | 60 | def _load_module(path): 61 | """Load python source file at path and return as a module.""" 62 | module_name = os.path.splitext(os.path.basename(path))[0] 63 | 64 | module = None # FIXME: better if/else switch statement 65 | if sys.version_info.minor < 5: 66 | loader = importlib.machinery.SourceFileLoader(module_name, path) 67 | module = loader.load_module() 68 | else: 69 | spec = importlib.util.spec_from_file_location(module_name, path) 70 | module = importlib.util.module_from_spec(spec) 71 | spec.loader.exec_module(module) 72 | 73 | return module 74 | 75 | 76 | def _compile_proto(full_path, dest): 77 | """Compile protobuf files.""" 78 | proto_path = os.path.dirname(full_path) 79 | protoc_args = [ 80 | find_protoc(), 81 | "--python_out={}".format(dest), 82 | "--proto_path={}".format(proto_path), 83 | full_path, 84 | ] 85 | proc = subprocess.Popen(protoc_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) 86 | try: 87 | outs, errs = proc.communicate(timeout=5) 88 | except subprocess.TimeoutExpired: 89 | proc.kill() 90 | outs, errs = proc.communicate() 91 | return False 92 | 93 | if proc.returncode != 0: 94 | msg = 'Failed compiling "{}": \n\nstderr: {}\nstdout: {}'.format( 95 | full_path, errs.decode("utf-8"), outs.decode("utf-8") 96 | ) 97 | raise BadProtobuf(msg) 98 | 99 | return True 100 | 101 | 102 | def from_file(proto_file): 103 | """Takes either a |protoc_file| or a generated |module_file| 104 | If given a `_pb2.py` file, this will try to just import the module. This should be the output of the Protobuf compiler; users should not attempt to import arbitrary Python files. 105 | If given a `.proto` file, this will compile it via the Protobuf compiler, and import the module. 106 | 107 | Return the module if successfully compiled, otherwise raise either a ProtocNotFound or BadProtobuf exception. 108 | 109 | """ 110 | if proto_file.endswith("_pb2.py"): 111 | return _load_module(proto_file) 112 | 113 | if not proto_file.endswith(".proto"): 114 | raise BadProtobuf() 115 | 116 | dest = tempfile.mkdtemp() 117 | full_path = os.path.abspath(proto_file) 118 | _compile_proto(full_path, dest) 119 | 120 | filename = os.path.split(full_path)[-1] 121 | name = re.search(r"^(.*)\.proto$", filename).group(1) 122 | target = os.path.join(dest, name + "_pb2.py") 123 | 124 | return _load_module(target) 125 | 126 | 127 | def types_from_module(pb_module): 128 | """Return protobuf class types from an imported generated module.""" 129 | types = pb_module.DESCRIPTOR.message_types_by_name 130 | return [getattr(pb_module, name) for name in types] 131 | -------------------------------------------------------------------------------- /protofuzz/protofuzz.py: -------------------------------------------------------------------------------- 1 | """The entry points to the protofuzz module. 2 | 3 | Usage: 4 | 5 | >>> message_fuzzers = protofuzz.from_description_string(''' 6 | ... message Address { 7 | ... required int32 house = 1; 8 | ... required string street = 2; 9 | ... } 10 | ... ''') 11 | >>> for fuzzer in message_fuzzers: 12 | ... for obj in fuzzer.permute(): 13 | ... print("Generated object: {}".format(obj)) 14 | ... 15 | Generated object: house: -1 16 | street: "!" 17 | 18 | Generated object: house: 0 19 | street: "!" 20 | 21 | Generated object: house: 256 22 | street: "!" 23 | 24 | (etc) 25 | 26 | """ 27 | 28 | # FIXME: Package containing module 'google' is not listed in project requirements 29 | from google.protobuf import descriptor as D 30 | from google.protobuf import message 31 | from google.protobuf.internal import containers 32 | 33 | from protofuzz import pbimport, gen, values 34 | 35 | __all__ = [ 36 | "ProtobufGenerator", 37 | "from_file", 38 | "from_description_string", 39 | "from_protobuf_class", 40 | ] 41 | 42 | 43 | def _int_generator(descriptor, bitwidth, unsigned): 44 | vals = list(values.get_integers(bitwidth, unsigned)) 45 | return gen.IterValueGenerator(descriptor.name, vals) 46 | 47 | 48 | def _string_generator(descriptor, max_length=0, limit=0): 49 | vals = list(values.get_strings(max_length, limit)) 50 | return gen.IterValueGenerator(descriptor.name, vals) 51 | 52 | 53 | def _bytes_generator(descriptor, max_length=0, limit=0): 54 | strs = values.get_strings(max_length, limit) 55 | vals = [bytes(_, "utf-8") for _ in strs] 56 | return gen.IterValueGenerator(descriptor.name, vals) 57 | 58 | 59 | def _float_generator(descriptor, bitwidth): 60 | return gen.IterValueGenerator(descriptor.name, values.get_floats(bitwidth)) 61 | 62 | 63 | def _enum_generator(descriptor): 64 | vals = descriptor.enum_type.values_by_number.keys() 65 | return gen.IterValueGenerator(descriptor.name, vals) 66 | 67 | 68 | def _prototype_to_generator(descriptor, cls): 69 | """Return map of descriptor to a protofuzz generator.""" 70 | _fd = D.FieldDescriptor 71 | generator = None 72 | 73 | ints32 = [ 74 | _fd.TYPE_INT32, 75 | _fd.TYPE_UINT32, 76 | _fd.TYPE_FIXED32, 77 | _fd.TYPE_SFIXED32, 78 | _fd.TYPE_SINT32, 79 | ] 80 | ints64 = [ 81 | _fd.TYPE_INT64, 82 | _fd.TYPE_UINT64, 83 | _fd.TYPE_FIXED64, 84 | _fd.TYPE_SFIXED64, 85 | _fd.TYPE_SINT64, 86 | ] 87 | ints_signed = [ 88 | _fd.TYPE_INT32, 89 | _fd.TYPE_SFIXED32, 90 | _fd.TYPE_SINT32, 91 | _fd.TYPE_INT64, 92 | _fd.TYPE_SFIXED64, 93 | _fd.TYPE_SINT64, 94 | ] 95 | 96 | if descriptor.type in ints32 + ints64: 97 | bitwidth = [32, 64][descriptor.type in ints64] 98 | unsigned = descriptor.type not in ints_signed 99 | generator = _int_generator(descriptor, bitwidth, unsigned) 100 | elif descriptor.type == _fd.TYPE_DOUBLE: 101 | generator = _float_generator(descriptor, 64) 102 | elif descriptor.type == _fd.TYPE_FLOAT: 103 | generator = _float_generator(descriptor, 32) 104 | elif descriptor.type == _fd.TYPE_STRING: 105 | generator = _string_generator(descriptor) 106 | elif descriptor.type == _fd.TYPE_BYTES: 107 | generator = _bytes_generator(descriptor) 108 | elif descriptor.type == _fd.TYPE_BOOL: 109 | generator = gen.IterValueGenerator(descriptor.name, [True, False]) 110 | elif descriptor.type == _fd.TYPE_ENUM: 111 | generator = _enum_generator(descriptor) 112 | elif descriptor.type == _fd.TYPE_MESSAGE: 113 | generator = descriptor_to_generator(descriptor.message_type, cls) 114 | generator.set_name(descriptor.name) 115 | else: 116 | raise RuntimeError("type {} unsupported".format(descriptor.type)) 117 | 118 | return generator 119 | 120 | 121 | def descriptor_to_generator(cls_descriptor, cls, limit=0): 122 | """Convert protobuf descriptor to a protofuzz generator for same type.""" 123 | generators = [] 124 | for descriptor in cls_descriptor.fields_by_name.values(): 125 | generator = _prototype_to_generator(descriptor, cls) 126 | 127 | if limit != 0: 128 | generator.set_limit(limit) 129 | 130 | generators.append(generator) 131 | 132 | obj = cls(cls_descriptor.name, *generators) 133 | return obj 134 | 135 | 136 | def _assign_to_field(obj, name, val): 137 | """Return map of arbitrary value to a protobuf field.""" 138 | target = getattr(obj, name) 139 | 140 | if isinstance(target, containers.RepeatedScalarFieldContainer): 141 | target.append(val) 142 | elif isinstance(target, containers.RepeatedCompositeFieldContainer): 143 | target = target.add() 144 | target.CopyFrom(val) 145 | elif isinstance(target, (int, float, bool, str, bytes)): 146 | setattr(obj, name, val) 147 | elif isinstance(target, message.Message): 148 | target.CopyFrom(val) 149 | else: 150 | raise RuntimeError("Unsupported type: {}".format(type(target))) 151 | 152 | 153 | def _fields_to_object(descriptor, fields): 154 | """Convert descriptor and a set of fields to a Protobuf instance.""" 155 | # pylint: disable=protected-access 156 | obj = descriptor._concrete_class() 157 | 158 | for name, value in fields: 159 | if isinstance(value, tuple): 160 | subtype = descriptor.fields_by_name[name].message_type 161 | value = _fields_to_object(subtype, value) 162 | _assign_to_field(obj, name, value) 163 | 164 | return obj 165 | 166 | 167 | class ProtobufGenerator(object): 168 | """A "fuzzing strategy" class that is associated with a Protobuf class. 169 | 170 | Currently, two strategies are supported: 171 | 172 | - permute() 173 | Generate permutations of fuzzed values for the fields. 174 | 175 | - linear() 176 | Generate fuzzed instances in lock-step (this is equivalent to running zip(*fields). 177 | 178 | """ 179 | 180 | def __init__(self, descriptor): 181 | """Protobufgenerator constructor.""" 182 | self._descriptor = descriptor 183 | self._dependencies = [] 184 | 185 | def _iteration_helper(self, iter_class, limit): 186 | generator = descriptor_to_generator(self._descriptor, iter_class) 187 | 188 | if limit: 189 | generator.set_limit(limit) 190 | 191 | # Create dependencies before beginning generation 192 | for args in self._dependencies: 193 | generator.make_dependent(*args) 194 | 195 | for fields in generator: 196 | yield _fields_to_object(self._descriptor, fields) 197 | 198 | def add_dependency(self, source, target, action): 199 | """Create a dependency between fields source and target via callable action. 200 | 201 | >>> permuter = protofuzz.from_description_string(''' 202 | ... message Address { 203 | ... required uint32 one = 1; 204 | ... required uint32 two = 2; 205 | ... }''')['Address'] 206 | >>> permuter.add_dependency('one', 'two', lambda val: max(0,val-1)) 207 | >>> for obj in permuter.linear(): 208 | ... print("obj = {}".format(obj)) 209 | ... 210 | obj = one: 0 211 | two: 1 212 | 213 | obj = one: 256 214 | two: 257 215 | 216 | obj = one: 4096 217 | two: 4097 218 | 219 | obj = one: 1073741823 220 | two: 1073741824 221 | 222 | """ 223 | self._dependencies.append((source, target, action)) 224 | 225 | def permute(self, limit=0): 226 | """Return a fuzzer that permutes all the fields with fuzzed values.""" 227 | return self._iteration_helper(gen.Product, limit) 228 | 229 | def linear(self, limit=0): 230 | """Return a fuzzer that emulates "zip" behavior.""" 231 | return self._iteration_helper(gen.Zip, limit) 232 | 233 | 234 | def _module_to_generators(pb_module): 235 | """Convert protobuf module to dict of generators. 236 | 237 | This is typically used with modules that contain multiple type definitions. 238 | 239 | """ 240 | if not pb_module: 241 | return None 242 | message_types = pb_module.DESCRIPTOR.message_types_by_name 243 | return {k: ProtobufGenerator(v) for k, v in message_types.items()} 244 | 245 | 246 | def from_file(protobuf_file): 247 | """Return dict of generators from a path to a .proto file or pre-generated _pb2.py file. 248 | _pb2.py file should be the output of the Protobuf compiler; users should not attempt to import arbitrary Python files. 249 | 250 | Args: 251 | protobuf_file(str) -- The path to the .proto file or pre-generated _pb2.py file. 252 | 253 | Returns: 254 | A dict indexed by message name of ProtobufGenerator objects. 255 | These can be used to create inter-field dependencies or to generate messages. 256 | 257 | Raises: 258 | FileNotFoundError: If the _pb2.py file is not found 259 | ModuleNotFoundError: If there is a nested protobuf import, see issue #11 260 | BadProtobuf: If the .proto file is incorrectly formatted or named. 261 | ProtocNotFound: If the protoc compiler was not found on $PATH. 262 | Any Import Python module errors: e.g. AttributeError, IndentationError, etc if the _pb2.py file is not a valid generated file 263 | 264 | """ 265 | module = pbimport.from_file(protobuf_file) 266 | return _module_to_generators(module) 267 | 268 | 269 | def from_description_string(protobuf_desc): 270 | """Return dict of generators from a string representation of the .proto file. 271 | 272 | Args: 273 | protobuf_desc(str) -- The description of protobuf messages; contents of 274 | what would usually go into a .proto file. 275 | 276 | Returns: 277 | A dict indexed by message name of ProtobufGenerator objects. These can 278 | be used to create inter-field dependencies or to generate messages. 279 | 280 | Raises: 281 | ProtocNotFound: If the protoc compiler was not found on $PATH. 282 | 283 | """ 284 | module = pbimport.from_string(protobuf_desc) 285 | return _module_to_generators(module) 286 | 287 | 288 | def from_protobuf_class(protobuf_class): 289 | """Return generator for an already-loaded Protobuf class. 290 | 291 | Args: 292 | protobuf_class(Message) -- A class object created from Protobuf- 293 | generated code. 294 | 295 | Returns: 296 | A ProtobufGenerator instance that can be used to create inter-field 297 | dependencies or to generate messages. 298 | 299 | """ 300 | return ProtobufGenerator(protobuf_class.DESCRIPTOR) 301 | -------------------------------------------------------------------------------- /protofuzz/tests/__init__.py: -------------------------------------------------------------------------------- 1 | __doc__ = """Tests""" 2 | -------------------------------------------------------------------------------- /protofuzz/tests/test.proto: -------------------------------------------------------------------------------- 1 | package tutorial; 2 | 3 | message Person { 4 | required string name = 1; 5 | required int32 id = 2; 6 | optional string email = 3; 7 | 8 | enum PhoneType { 9 | MOBILE = 0; 10 | HOME = 1; 11 | WORK = 2; 12 | } 13 | 14 | message PhoneNumber { 15 | required string number = 1; 16 | optional PhoneType type = 2 [default = HOME]; 17 | } 18 | 19 | repeated PhoneNumber phone = 4; 20 | } 21 | 22 | message Other { 23 | required string foo = 1; 24 | required int32 index = 2; 25 | } 26 | 27 | message AddressBook { 28 | optional Other other = 1; 29 | repeated Person person = 2; 30 | } 31 | 32 | -------------------------------------------------------------------------------- /protofuzz/tests/test_gen.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | from protofuzz import gen 4 | 5 | import unittest 6 | 7 | 8 | class TestGenerators(unittest.TestCase): 9 | def test_name(self): 10 | """Test setting a name""" 11 | name = "A Name" 12 | generator = gen.IterValueGenerator("name", []) 13 | generator.set_name(name) 14 | 15 | self.assertEqual(generator.name(), name) 16 | 17 | def test_basic_gen(self): 18 | """Test a basic generator""" 19 | source_vals = [1, 2, 3, 4] 20 | numbers = gen.IterValueGenerator("iter", source_vals) 21 | produced_vals = [] 22 | 23 | for x in numbers: 24 | produced_vals.append(x) 25 | 26 | self.assertEqual(produced_vals, source_vals) 27 | 28 | def test_gen_init(self): 29 | """Test that we can't get a value from a non-iterated generator""" 30 | values = gen.IterValueGenerator("iter", [1, 2, 3, 4]) 31 | 32 | with self.assertRaises(RuntimeError): 33 | values.get() 34 | 35 | def test_dependent_values(self): 36 | """Make sure dependent values are correctly resolved""" 37 | 38 | def is_even(x): 39 | return x % 2 == 0 40 | 41 | values = gen.IterValueGenerator("name", [1, 2, 3, 4]) 42 | dependent = gen.DependentValueGenerator( 43 | "depends", target=values, action=is_even 44 | ) 45 | 46 | for x in values: 47 | generated_val, generated_dependency = values.get(), dependent.get() 48 | self.assertEqual(generated_dependency, is_even(generated_val)) 49 | 50 | def test_repeated_gets(self): 51 | """Make sure that calling get() twice on a generator does not advance it""" 52 | 53 | def plus_one(x): 54 | return x + 1 55 | 56 | values = gen.IterValueGenerator("name", [1, 2, 3, 4]) 57 | dependent = gen.DependentValueGenerator( 58 | "dependent", target=values, action=plus_one 59 | ) 60 | 61 | # Request an actual item 62 | next(iter(values)) 63 | 64 | values.get() 65 | 66 | first = dependent.get() 67 | second = dependent.get() 68 | 69 | self.assertEqual(first, second) 70 | 71 | def test_permuted_generators(self): 72 | """Test basic Product() permuter""" 73 | values1 = gen.IterValueGenerator("a", [1, 2]) 74 | values2 = gen.IterValueGenerator("b", [1, 2]) 75 | produced_vals = [] 76 | 77 | for x in gen.Product("name", values1, values2): 78 | x = tuple(map(lambda e: e[1], x)) 79 | produced_vals.append(x) 80 | 81 | self.assertEqual(produced_vals, [(1, 1), (1, 2), (2, 1), (2, 2)]) 82 | 83 | def test_permuted_generators_with_dependent_values(self): 84 | """Test that Product permuter works with dependent values""" 85 | 86 | def is_even(x): 87 | return x % 2 == 0 88 | 89 | values1 = gen.IterValueGenerator("a", [1, 2, 3]) 90 | values2 = gen.IterValueGenerator("b", [1, 2, 3]) 91 | values3 = gen.IterValueGenerator("c", [1, 2, 3]) 92 | dependent = gen.DependentValueGenerator("v1", target=values1, action=is_even) 93 | 94 | for x in gen.Product("name", values1, values2, values3, dependent): 95 | v1, v2, v3, dep = x 96 | self.assertEqual(is_even(values1.get()), dependent.get()) 97 | 98 | def test_permuted_generators_with_via_make_dep(self): 99 | """Test creation of dependencies via Permuter.make_dependent()""" 100 | names = gen.IterValueGenerator("name", ["alice", "bob"]) 101 | lengths = gen.IterValueGenerator("len", ["one", "two"]) 102 | permuter = gen.Zip("Permute", names, lengths) 103 | 104 | permuter.make_dependent("len", "name", len) 105 | 106 | for tuples in permuter: 107 | values = dict(tuples) 108 | self.assertEqual(len(values["name"]), values["len"]) 109 | 110 | def test_zip(self): 111 | """Test a basic Zip permuter""" 112 | source_vals = [1, 2, 3, 4] 113 | vals1 = gen.IterValueGenerator("key", source_vals) 114 | vals2 = gen.IterValueGenerator("val", source_vals) 115 | 116 | produced_via_zips = [] 117 | for x, y in gen.Zip("name", vals1, vals2): 118 | produced_via_zips.append((x[1], y[1])) 119 | 120 | expected = list(zip(source_vals, source_vals)) 121 | self.assertEqual(produced_via_zips, expected) 122 | 123 | def test_limited_gen(self): 124 | source_vals = list(range(4)) 125 | limit = 3 126 | values = gen.IterValueGenerator("name", source_vals) 127 | values.set_limit(limit) 128 | 129 | produced_vals = [val for val in values] 130 | self.assertEqual(source_vals[:limit], produced_vals) 131 | 132 | def test_limited_zip(self): 133 | """Test limits on a basic Zip iterator""" 134 | source_vals = [1, 2, 3, 4] 135 | values = gen.IterValueGenerator("name", source_vals) 136 | produced_vals = [] 137 | 138 | for x in gen.Zip("name", values, limit=len(source_vals) - 1): 139 | produced_vals.append(x[0][1]) 140 | 141 | self.assertEqual(source_vals[:-1], produced_vals) 142 | 143 | def test_limited_product(self): 144 | """Test limits on a Product iterator""" 145 | source_vals = [1, 2, 3, 4] 146 | vals1 = gen.IterValueGenerator("key", source_vals) 147 | vals2 = gen.IterValueGenerator("values", source_vals) 148 | produced_vals = [] 149 | 150 | for v1, v2 in gen.Product("name", vals1, vals2, limit=4): 151 | produced_vals.append((v1[1], v2[1])) 152 | 153 | self.assertEqual(produced_vals, [(1, 1), (1, 2), (1, 3), (1, 4)]) 154 | 155 | def test_dual_permuters(self): 156 | """Test nested permuters""" 157 | source_vals = [1, 2] 158 | vals1 = gen.IterValueGenerator("key", source_vals) 159 | vals2 = gen.IterValueGenerator("val", source_vals) 160 | 161 | produced_via_zips = [] 162 | produced_via_product = [] 163 | 164 | for x in gen.Zip("name", vals1): 165 | for y in gen.Zip("name", vals2): 166 | produced_via_zips.append(x + y) 167 | 168 | for x in gen.Product("name", vals1, vals2): 169 | produced_via_product.append(x) 170 | 171 | self.assertEqual(produced_via_zips, produced_via_product) 172 | 173 | def test_make_dependent(self): 174 | source_vals = [1, 2, 3, 4] 175 | vals1 = gen.IterValueGenerator("key", source_vals) 176 | vals2 = gen.IterValueGenerator("values", source_vals) 177 | 178 | def increment_by_one(val): 179 | return val + 1 180 | 181 | permuter = gen.Zip("test", vals1, vals2) 182 | permuter.make_dependent("key", "values", increment_by_one) 183 | 184 | for values in permuter: 185 | res = dict(values) 186 | self.assertEqual(res["key"], increment_by_one(res["values"])) 187 | -------------------------------------------------------------------------------- /protofuzz/tests/test_log.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import shutil 5 | import tempfile 6 | import unittest 7 | 8 | from protofuzz import log 9 | 10 | 11 | class TestLog(unittest.TestCase): 12 | def setUp(self): 13 | self.fd, self.tempfile = tempfile.mkstemp() 14 | 15 | def _get_logger(self, n): 16 | return log.LastNMessagesLogger(self.tempfile, n) 17 | 18 | def tearDown(self): 19 | os.unlink(self.tempfile) 20 | 21 | def test_few_msgs(self): 22 | """Test logging a message""" 23 | logger = self._get_logger(4) 24 | 25 | original = ["hello"] 26 | 27 | for obj in original: 28 | logger.log(obj) 29 | 30 | retrieved = logger.get() 31 | 32 | self.assertEqual(original, retrieved) 33 | 34 | def test_many_msgs(self): 35 | """Test logging a few messages""" 36 | logger = self._get_logger(3) 37 | 38 | original = ["one", "two", "three", "four"] 39 | 40 | for obj in original: 41 | logger.log(obj) 42 | 43 | retrieved = logger.get() 44 | 45 | self.assertEqual(original[-3:], retrieved) 46 | 47 | def test_nones(self): 48 | """Test with None""" 49 | logger = self._get_logger(1) 50 | logger.log(None) 51 | retrieved = logger.get() 52 | self.assertIsNone(retrieved[0]) 53 | -------------------------------------------------------------------------------- /protofuzz/tests/test_pbimport.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import re 5 | import secrets 6 | import stat 7 | import shutil 8 | import tempfile 9 | import unittest 10 | 11 | from protofuzz import pbimport 12 | 13 | 14 | class TestPbimport(unittest.TestCase): 15 | def setUp(self): 16 | self.tempdir = tempfile.mkdtemp() 17 | self.valid_filename = os.path.join(self.tempdir, "test.proto") 18 | 19 | def tearDown(self): 20 | shutil.rmtree(self.tempdir) 21 | 22 | def test_find_protoc(self): 23 | """ 24 | Can we can find protoc? 25 | """ 26 | binary = os.path.join(self.tempdir, "protoc") 27 | with open(binary, "wb+") as f: 28 | pass 29 | mode = os.stat(binary) 30 | os.chmod(binary, mode.st_mode | stat.S_IEXEC) 31 | 32 | found_binary = pbimport.find_protoc(self.tempdir) 33 | self.assertEqual(found_binary, binary) 34 | 35 | def test_from_string(self): 36 | """ 37 | Get a protobuf module from string 38 | """ 39 | name = f"Msg{secrets.token_hex(16)}" 40 | contents = "message {} {{ required int32 var = 1; }}\n".format(name) 41 | 42 | module = pbimport.from_string(contents) 43 | self.assertTrue(hasattr(module, name)) 44 | 45 | def test_from_generated(self): 46 | """ 47 | Get a protobuf module from generated file 48 | """ 49 | name = f"Msg{secrets.token_hex(16)}" 50 | contents = "message {} {{ required int32 var = 1; }}\n".format(name) 51 | 52 | with open(self.valid_filename, "w") as f: 53 | f.write(contents) 54 | 55 | dest = self.tempdir 56 | full_path = os.path.abspath(self.valid_filename) 57 | pbimport._compile_proto(full_path, dest) 58 | target = os.path.join(dest, "test_pb2.py") 59 | 60 | module = pbimport.from_file(target) 61 | self.assertTrue(hasattr(module, name)) 62 | 63 | def test_failing_import(self): 64 | """ 65 | Test the failing of malformed generated protobuf file 66 | """ 67 | contents = 'print("malformed generated code")' 68 | 69 | filename = os.path.join(self.tempdir, "test_pb2.py") 70 | with open(filename, "w") as f: 71 | f.write(contents) 72 | 73 | module = pbimport.from_file(filename) 74 | with self.assertRaises(AttributeError): 75 | pbimport.types_from_module(module) 76 | 77 | def test_failing_import_not_found(self): 78 | """ 79 | Get a protobuf module from generated file 80 | """ 81 | filename = os.path.join(self.tempdir, "test_pb2.py") 82 | 83 | with self.assertRaises(FileNotFoundError): 84 | module = pbimport.from_file(filename) 85 | 86 | def test_generate_and_import(self): 87 | """ 88 | Test generation and loading of protobuf artifacts 89 | """ 90 | name = f"Msg{secrets.token_hex(16)}" 91 | contents = "message {} {{ required int32 var = 1; }}\n".format(name) 92 | 93 | with open(self.valid_filename, "w") as f: 94 | f.write(contents) 95 | 96 | module = pbimport.from_file(self.valid_filename) 97 | self.assertTrue(hasattr(module, name)) 98 | 99 | def test_failing_generate_and_import(self): 100 | """ 101 | Test the failing of malformed protoc file 102 | """ 103 | contents = "malformed protoc" 104 | 105 | with open(self.valid_filename, "w") as f: 106 | f.write(contents) 107 | 108 | with self.assertRaises(pbimport.BadProtobuf): 109 | module = pbimport.from_file(self.valid_filename) 110 | -------------------------------------------------------------------------------- /protofuzz/tests/test_protofuzz.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import os 4 | import re 5 | import secrets 6 | import unittest 7 | import tempfile 8 | 9 | from protofuzz import protofuzz, pbimport, values 10 | 11 | 12 | class TestProtofuzz(unittest.TestCase): 13 | def new_description(self): 14 | message = f"Message{secrets.token_hex(16)}" 15 | other = f"Other{secrets.token_hex(16)}" 16 | 17 | description = f""" 18 | message {message} {{ 19 | required int32 one = 1; 20 | required int32 two = 2; 21 | }} 22 | 23 | message {other} {{ 24 | required int32 one = 1; 25 | required int32 two = 2; 26 | }} 27 | """ 28 | 29 | return (message, other, description) 30 | 31 | def test_from_string(self): 32 | """Make sure we can create protofuzz generators from string""" 33 | message, other, description = self.new_description() 34 | messages = protofuzz.from_description_string(description) 35 | 36 | self.assertIn(message, messages) 37 | self.assertIn(other, messages) 38 | 39 | def test_from_file(self): 40 | """Make sure we can create protofuzz generators from file""" 41 | message, other, description = self.new_description() 42 | fd, filename = tempfile.mkstemp(suffix=".proto") 43 | try: 44 | f = open(filename, "w") 45 | f.write(description) 46 | f.close() 47 | 48 | messages = protofuzz.from_file(filename) 49 | finally: 50 | os.unlink(filename) 51 | 52 | self.assertIn(message, messages) 53 | self.assertIn(other, messages) 54 | 55 | def test_from_file_generated(self): 56 | """Make sure we can create protofuzz generators from generated protobuf code""" 57 | message, other, description = self.new_description() 58 | fd, filename = tempfile.mkstemp(suffix=".proto") 59 | dest = tempfile.tempdir 60 | try: 61 | f = open(filename, "w") 62 | f.write(description) 63 | f.close() 64 | 65 | full_path = os.path.abspath(filename) 66 | pbimport._compile_proto(full_path, dest) 67 | temp_filename = os.path.split(full_path)[-1] 68 | name = re.search(r"^(.*)\.proto$", temp_filename).group(1) 69 | target = os.path.join(dest, name + "_pb2.py") 70 | 71 | messages = protofuzz.from_file(target) 72 | os.unlink(target) 73 | finally: 74 | os.unlink(filename) 75 | 76 | self.assertIn(message, messages) 77 | self.assertIn(other, messages) 78 | 79 | def test_failure_from_invalid_import_file(self): 80 | """Asserts invalid generated protobuf code throws exception""" 81 | message, other, description = self.new_description() 82 | fd, filename = tempfile.mkstemp(suffix="_pb2.py") 83 | try: 84 | f = open(filename, "w") 85 | f.write(description) 86 | f.close() 87 | 88 | with self.assertRaises(IndentationError): 89 | messages = protofuzz.from_file(filename) 90 | finally: 91 | os.unlink(filename) 92 | 93 | def test_failure_from_invalid_import_file_empty(self): 94 | """Asserts invalid generated protobuf code throws exception""" 95 | fd, filename = tempfile.mkstemp(suffix="_pb2.py") 96 | try: 97 | with self.assertRaises(AttributeError): 98 | messages = protofuzz.from_file(filename) 99 | finally: 100 | os.unlink(filename) 101 | 102 | def test_enum(self): 103 | """Make sure all enum values are enumerated in linear permutation""" 104 | enum_values = [0, 1, 2] 105 | definition = """ 106 | message Message {{ 107 | enum Colors {{ RED = {}; GREEN = {}; BLUE = {}; }} 108 | required Colors color = 1; 109 | }} 110 | """.format( 111 | *enum_values 112 | ) 113 | 114 | messages = protofuzz.from_description_string(definition) 115 | 116 | all_values = [obj.color for obj in messages["Message"].linear()] 117 | 118 | # TODO(ww): Why do all_values come out in reversed order here? 119 | self.assertEqual(all_values, list(reversed(enum_values))) 120 | 121 | def test_floating_point(self): 122 | """Test basic doubles""" 123 | name = f"Msg{secrets.token_hex(16)}" 124 | definition = """ 125 | message {} {{ 126 | required double dbl = 1; 127 | required float fl = 2; 128 | }}""".format( 129 | name 130 | ) 131 | messages = protofuzz.from_description_string(definition) 132 | for msg in messages[name].linear(): 133 | self.assertIsInstance(msg.dbl, float) 134 | self.assertIsInstance(msg.fl, float) 135 | 136 | def _single_field_helper(self, field_type, field_name): 137 | name = f"Msg{secrets.token_hex(16)}" 138 | definition = """ 139 | message {} {{ 140 | required {} {} = 1; 141 | }}""".format( 142 | name, field_type, field_name 143 | ) 144 | permuter = protofuzz.from_description_string(definition)[name] 145 | return permuter.linear(limit=10) 146 | 147 | def test_basic_types(self): 148 | """Test generation of strings, bools, and bytes values""" 149 | typemap = [("string", str), ("bool", bool), ("bytes", bytes)] 150 | for pbname, pyname in typemap: 151 | for msg in self._single_field_helper(pbname, "val"): 152 | self.assertIsInstance(msg.val, pyname) 153 | 154 | def test_repeated(self): 155 | name = f"Msg{secrets.token_hex(16)}" 156 | definition = "message {} {{ repeated string val = 1; }}".format(name) 157 | messages = protofuzz.from_description_string(definition) 158 | for msg in messages[name].linear(limit=10): 159 | self.assertIsInstance(msg.val[0], str) 160 | 161 | def test_repeated_msg(self): 162 | name = f"Msg{secrets.token_hex(16)}" 163 | definition = """ 164 | message Inner {{ required int32 val = 1; }} 165 | message {} {{ repeated Inner val = 1; }} 166 | """.format( 167 | name 168 | ) 169 | 170 | messages = protofuzz.from_description_string(definition) 171 | 172 | for msg in messages[name].linear(limit=10): 173 | self.assertIsInstance(msg.val[0].val, int) 174 | 175 | def test_optional(self): 176 | name = f"Msg{secrets.token_hex(16)}" 177 | definition = "message {} {{ optional string val = 1; }}".format(name) 178 | messages = protofuzz.from_description_string(definition) 179 | for msg in messages[name].linear(limit=10): 180 | self.assertIsInstance(msg.val, str) 181 | 182 | def permuter_helper(self, method): 183 | message, _, description = self.new_description() 184 | messages = protofuzz.from_description_string(description) 185 | permuter = messages[message] 186 | 187 | self.assertTrue(len(list(method(permuter))) > 0) 188 | 189 | def test_linear_fuzzing(self): 190 | """Linear fuzzing generates some results""" 191 | self.permuter_helper(lambda x: x.linear()) 192 | 193 | def test_permuted_fuzzing(self): 194 | """Permuted fuzzing generates some results""" 195 | self.permuter_helper(lambda x: x.permute()) 196 | 197 | def test_custom_ints(self): 198 | """Test a custom int generator""" 199 | old_intvalues = values._fuzzdb_integers 200 | try: 201 | custom_vals = [1, 2, 3, 4] 202 | 203 | def custom_ints(limit=0): 204 | return iter(custom_vals) 205 | 206 | values._fuzzdb_integers = custom_ints 207 | 208 | name = f"Msg{secrets.token_hex(16)}" 209 | definition = "message {} {{required int32 val = 1;}}".format(name) 210 | messages = protofuzz.from_description_string(definition) 211 | results = [x.val for x in messages[name].linear()] 212 | 213 | self.assertEqual(results, custom_vals) 214 | finally: 215 | values._fuzzdb_integers = old_intvalues 216 | -------------------------------------------------------------------------------- /protofuzz/tests/test_values.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | import unittest 4 | 5 | from protofuzz import values 6 | 7 | 8 | class TestValues(unittest.TestCase): 9 | def test_get_all_strings(self): 10 | """Get all strings from fuzzdb""" 11 | vals = list(values.get_strings()) 12 | self.assertTrue(len(vals) > 0) 13 | 14 | def test_some_strings(self): 15 | """Get a few strings from fuzzdb""" 16 | vals = list(values.get_strings(limit=10)) 17 | self.assertEqual(len(vals), 10) 18 | 19 | def test_floats(self): 20 | vals = values.get_floats(32) 21 | for val in vals: 22 | self.assertIsInstance(val, float) 23 | -------------------------------------------------------------------------------- /protofuzz/values.py: -------------------------------------------------------------------------------- 1 | """A collection of values for other modules to use. 2 | 3 | If you wish to use a different source of data, this is the place to modify. 4 | 5 | """ 6 | 7 | import os 8 | import importlib.util 9 | import importlib.resources 10 | from pathlib import Path 11 | 12 | from typing import List, Optional, Generator, BinaryIO, Union 13 | 14 | BASE_PATH_ENVIRONMENT_VAR: str = "FUZZDB_DIR" 15 | BASE_PATH: Optional[Path] = None 16 | 17 | __all__ = ["get_strings", "get_integers", "get_floats"] 18 | 19 | 20 | def _get_fuzzdb_path() -> Path: 21 | """Configure the base path for fuzzdb file imports. 22 | 23 | fuzzdb is not a python module, so we cannot maximize the functionality 24 | of importlib to scan and import all of the files as resources. We instead 25 | find the first, most likely working path of fuzzdb based on the package 26 | structure provided by importlib, then provide the absolute path to 27 | that location. 28 | 29 | If FUZZDB_DIR is set in the environment, this method prioritizes searching 30 | for it first. 31 | 32 | If BASE_PATH has been set (is not None), this immediately 33 | returns as it has been already set by other code in this module. 34 | 35 | Arguments: None 36 | Returns: absolute path to fuzzdb/attack resource directory 37 | """ 38 | global BASE_PATH 39 | # Once BASE_PATH is set we do not want to change it so this is a no-op. 40 | if BASE_PATH: 41 | return BASE_PATH 42 | package_name = "protofuzz" 43 | module_name = "fuzzdb" 44 | search_paths: List[Path] = [] 45 | fuzzdb_path: Optional[Path] = None 46 | # We prioritize checking the env variable over the project recursive 47 | # copy of fuzzdb as the env being set implies the user wants that 48 | # location. 49 | if BASE_PATH_ENVIRONMENT_VAR in os.environ: 50 | search_paths.append(Path(os.environ[BASE_PATH_ENVIRONMENT_VAR])) 51 | # We convert this to a Path as it will be easier to traverse in other 52 | # methods, Path only accepts strings/bytes 53 | module_path = Path( 54 | str(importlib.resources.files(package_name).joinpath(module_name)) 55 | ) 56 | search_paths.append(module_path) 57 | for module_path in search_paths: 58 | attack_path = module_path / Path("attack") 59 | # Use the 1st directory we find that exists and seems like a fuzzdb dir 60 | if os.path.exists(attack_path): 61 | fuzzdb_path = attack_path 62 | break 63 | if not fuzzdb_path: 64 | raise RuntimeError("Could not import fuzzdb dependency files.") 65 | BASE_PATH = fuzzdb_path 66 | return fuzzdb_path 67 | 68 | 69 | def _limit_helper(stream: Union[BinaryIO, Generator, List], limit: int) -> Generator: 70 | """Limit a stream depending on the "limit" parameter.""" 71 | for value in stream: 72 | yield value 73 | if limit == 1: 74 | return 75 | else: 76 | limit = limit - 1 # FIXME 77 | 78 | 79 | def _fuzzdb_integers(limit: int = 0) -> Generator: 80 | """Return integers from fuzzdb.""" 81 | path = _get_fuzzdb_path() / Path("integer-overflow/integer-overflows.txt") 82 | with open(path, "rb") as stream: 83 | for line in _limit_helper(stream, limit): 84 | yield int(line.decode("utf-8"), 0) 85 | 86 | 87 | def _fuzzdb_get_strings(max_len: int = 0) -> Generator: 88 | """Return strings from fuzzdb.""" 89 | ignored = ["integer-overflow"] 90 | for subdir in os.listdir(_get_fuzzdb_path()): 91 | if subdir in ignored: 92 | continue 93 | subdir_abs_path = _get_fuzzdb_path() / Path(subdir) 94 | try: 95 | listing = os.listdir(subdir_abs_path) 96 | except NotADirectoryError: 97 | continue 98 | for filename in listing: 99 | if not filename.endswith(".txt"): 100 | continue 101 | subdir_abs_path_filename = subdir_abs_path / Path(filename) 102 | with open(subdir_abs_path_filename, "rb") as source: 103 | for line in source: 104 | string = line.decode("utf-8").strip() 105 | if not string or string.startswith("#"): 106 | continue 107 | if max_len != 0 and len(line) > max_len: 108 | continue 109 | 110 | yield string 111 | 112 | 113 | def get_strings(max_len: int = 0, limit: int = 0) -> Generator: 114 | """Return strings from fuzzdb. 115 | 116 | limit - Limit results to |limit| results, or 0 for unlimited. 117 | max_len - Maximum length of string required. 118 | 119 | """ 120 | return _limit_helper(_fuzzdb_get_strings(max_len), limit) 121 | 122 | 123 | def get_integers(bitwidth: int, unsigned: bool, limit: int = 0) -> Generator: 124 | """Return integers from fuzzdb database. 125 | 126 | bitwidth - The bitwidth that has to contain the integer 127 | unsigned - Whether the type is unsigned 128 | limit - Limit to |limit| results. 129 | 130 | """ 131 | if unsigned: 132 | start, stop = 0, ((1 << bitwidth) - 1) 133 | else: 134 | start, stop = (-(1 << bitwidth - 1)), (1 << (bitwidth - 1) - 1) 135 | 136 | for num in _fuzzdb_integers(limit): 137 | if num >= start and num <= stop: 138 | yield num 139 | 140 | 141 | def get_floats(bitwidth: int, limit: int = 0) -> Generator: 142 | """Return a number of interesting floating point values.""" 143 | assert bitwidth in (32, 64, 80) 144 | values = [0.0, -1.0, 1.0, -1231231231231.0123, 123123123123123.123] 145 | for val in _limit_helper(values, limit): 146 | yield val 147 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import os 2 | from setuptools import setup 3 | 4 | setup(name='protofuzz', 5 | version='0.2', 6 | description='Google protobuf message generator', 7 | long_description=""""ProtoFuzz is a generic fuzzer for Google’s Protocol Buffers format. 8 | Instead of defining a new fuzzer generator for custom binary formats, protofuzz automatically creates a fuzzer based on 9 | the same format definition that programs use. ProtoFuzz is implemented as a stand-alone Python3 program.""", 10 | url='https://github.com/trailofbits/protofuzz', 11 | author='Trail of Bits', 12 | license='MIT', 13 | packages=['protofuzz'], 14 | install_requires=['protobuf>=2.6.0'], 15 | test_suite='nose.collector', 16 | tests_require=['nose'], 17 | zip_safe=False, 18 | package_data={'protofuzz': [os.path.join('fuzzdb', '**', '*')]}, 19 | include_package_data=True, 20 | python_requires=">=3.9", 21 | ) 22 | --------------------------------------------------------------------------------