iotsploit-fuzzer 0.0.9__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- iotsploit_fuzzer-0.0.9/PKG-INFO +320 -0
- iotsploit_fuzzer-0.0.9/README.md +296 -0
- iotsploit_fuzzer-0.0.9/pyproject.toml +32 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/__init__.py +77 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/__init__.py +1 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/corpus.py +480 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/logger.py +54 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/analysis/outcome.py +273 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/__init__.py +67 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/bit_manipulator.py +405 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/config.py +41 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/fuzzing_engine.py +469 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/orchestrator.py +452 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/parser_campaign.py +380 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/strategies/__init__.py +43 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/strategies/bit_strategies.py +509 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/core/strategies/field_strategies.py +697 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/fuzz.py +276 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/__init__.py +1 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/base.py +14 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/corpus_generator.py +219 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/generators/radamsa_generator.py +96 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/__init__.py +1 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/base.py +27 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/can_harness.py +23 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/parser_harness.py +323 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/parser_targets.py +142 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/parser_worker.py +180 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/spi_harness.py +17 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/harnesses/uart_harness.py +23 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/__init__.py +1 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/base.py +17 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/can_interface.py +48 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/spi_interface.py +27 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/interfaces/uart_interface.py +26 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/monitoring/__init__.py +1 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/monitoring/boundary_monitor.py +187 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/monitoring/monitor.py +203 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/targets/__init__.py +1 -0
- iotsploit_fuzzer-0.0.9/src/iotsploit_fuzzer/targets/iotsploit.py +1004 -0
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: iotsploit-fuzzer
|
|
3
|
+
Version: 0.0.9
|
|
4
|
+
Summary: IoTSploit fuzzer library (ports + core + adapters), extracted from zeekr_sat_main
|
|
5
|
+
Author: IoTSploit Team
|
|
6
|
+
Author-email: devnull@example.com
|
|
7
|
+
Requires-Python: >=3.10,<4.0
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.15
|
|
15
|
+
Provides-Extra: all
|
|
16
|
+
Provides-Extra: can
|
|
17
|
+
Provides-Extra: spi
|
|
18
|
+
Provides-Extra: uart
|
|
19
|
+
Requires-Dist: pyserial ; extra == "uart" or extra == "all"
|
|
20
|
+
Requires-Dist: python-can ; extra == "can" or extra == "all"
|
|
21
|
+
Requires-Dist: spidev ; extra == "spi" or extra == "all"
|
|
22
|
+
Description-Content-Type: text/markdown
|
|
23
|
+
|
|
24
|
+
# iotsploit-fuzzer
|
|
25
|
+
|
|
26
|
+
Standalone fuzzer library extracted from `zeekr_sat_main`.
|
|
27
|
+
|
|
28
|
+
- **Package name (PyPI)**: `iotsploit-fuzzer`
|
|
29
|
+
- **Import name (Python)**: `iotsploit_fuzzer`
|
|
30
|
+
|
|
31
|
+
## Install (editable)
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
pip install -e iotsploit-fuzzer
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
## Notes
|
|
38
|
+
|
|
39
|
+
This package is being introduced to replace (and later deprecate) the in-repo `iot_protocol_fuzzer` module.
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
## Two things this package fuzzes
|
|
44
|
+
|
|
45
|
+
They are not the same job, and conflating them is how a fuzzing effort ends up
|
|
46
|
+
measuring the wrong thing.
|
|
47
|
+
|
|
48
|
+
**Outbound — the device under test.** CAN/UART/SPI payloads sent through a
|
|
49
|
+
wire harness at whatever is on the other end. Needs the rig. This is what
|
|
50
|
+
`CANHarness`, `UARTHarness` and `SPIHarness` do, driven from the Django UI.
|
|
51
|
+
|
|
52
|
+
**Inbound — our own parsers.** The same `Orchestrator`, pointed at a function
|
|
53
|
+
instead of an interface. Needs nothing but CPU, so it runs in CI and in the
|
|
54
|
+
commit gate. That is what follows.
|
|
55
|
+
|
|
56
|
+
## The parser loop
|
|
57
|
+
|
|
58
|
+
A campaign that is run once, reported and forgotten finds its last bug in week
|
|
59
|
+
two. What keeps producing information is the **error boundary** -- the line
|
|
60
|
+
between the inputs a parser accepts and the ones it rejects -- because every
|
|
61
|
+
refactor moves it and almost every move is unintended. Nothing in the test
|
|
62
|
+
suite asserts which ARXML files import or which ASC lines parse. The ledger is
|
|
63
|
+
that assertion, and it writes itself.
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
# What is in the registry
|
|
67
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --list
|
|
68
|
+
|
|
69
|
+
# One target, one campaign
|
|
70
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign \
|
|
71
|
+
--target canbus.scan_log --iterations 2000
|
|
72
|
+
|
|
73
|
+
# Everything, as a nightly run. Exits non-zero on a violation.
|
|
74
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --iterations 2000
|
|
75
|
+
|
|
76
|
+
# Replay the retained corpus and generate nothing. This is what the commit
|
|
77
|
+
# gate runs, via tests/test_parser_corpus_replay.py.
|
|
78
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --replay
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
A campaign reports exactly three things:
|
|
82
|
+
|
|
83
|
+
| Event | Means | Nightly |
|
|
84
|
+
|-------|-------|---------|
|
|
85
|
+
| `VIOLATION` | A contract broke: an undeclared exception, a hang, a resource limit, a broken round trip | Fails |
|
|
86
|
+
| `BOUNDARY_MOVED` | A payload the ledger knows now does something else. **Not necessarily a bug** -- a behaviour change somebody should confirm was intended | Reports |
|
|
87
|
+
| `NEW_REGION` | A signature this target has never produced. The corpus grew | Informational |
|
|
88
|
+
|
|
89
|
+
### Each parse runs in a process we are willing to lose
|
|
90
|
+
|
|
91
|
+
`ParserHarness` is a controller; the parse happens in a subprocess under
|
|
92
|
+
wall-clock, memory, output-size and payload-size limits. This is not
|
|
93
|
+
defensiveness. A parser that allocates until the host swaps cannot be
|
|
94
|
+
recovered from in-process -- `except MemoryError` runs only after the
|
|
95
|
+
allocation happened -- and a signal handler only runs at a bytecode boundary
|
|
96
|
+
the interpreter may never reach again. Worker death *is* the result.
|
|
97
|
+
|
|
98
|
+
Workers are batched and recycled rather than forked per payload, because a
|
|
99
|
+
spawn costs more than the parsing does. Anything that is not a plain accept or
|
|
100
|
+
reject is re-run alone in a fresh worker, three times, before it is believed:
|
|
101
|
+
an outcome that does not reproduce is reported as flaky and never enters the
|
|
102
|
+
corpus.
|
|
103
|
+
|
|
104
|
+
### The ledger is a contract, and it is versioned
|
|
105
|
+
|
|
106
|
+
`corpus/<target>/` holds two files: `payloads.zip` and `ledger.json`. One
|
|
107
|
+
archive per target rather than one file per payload -- a thousand inputs of a
|
|
108
|
+
hundred bytes each, stored loose, was 74% of the repository's tracked file
|
|
109
|
+
count for 1% of its bytes, and a 4 KB block apiece turned 1.1 MB into 9.4 MB
|
|
110
|
+
on disk. An existing loose corpus migrates itself on the next save.
|
|
111
|
+
|
|
112
|
+
The ledger is tracked in git on purpose -- it is the loop's
|
|
113
|
+
memory, the gate's regression corpus, and a boundary movement arrives as a
|
|
114
|
+
JSON diff in the pull request that caused it. Each entry carries the payload's
|
|
115
|
+
content hash, its normalised outcome signature, and the campaign that first
|
|
116
|
+
saw it.
|
|
117
|
+
|
|
118
|
+
Signatures never contain a raw exception message: messages carry offsets, hex,
|
|
119
|
+
paths and quoted input, and comparing them would report thousands of movements
|
|
120
|
+
nobody caused. They also never contain a line number, for the same reason.
|
|
121
|
+
|
|
122
|
+
Changing a target's declared exception set, its adapter, or the signature
|
|
123
|
+
format changes its **fingerprint**, and a ledger recorded under a different
|
|
124
|
+
fingerprint is refused rather than diffed:
|
|
125
|
+
|
|
126
|
+
```bash
|
|
127
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign \
|
|
128
|
+
--target canbus.scan_log --rebaseline
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Re-baselining keeps the payloads -- they are the expensive part -- and drops
|
|
132
|
+
only the claim about what they do, which the next campaign re-derives.
|
|
133
|
+
|
|
134
|
+
### Two mutators
|
|
135
|
+
|
|
136
|
+
The default is eight byte-level operations on a seeded PRNG. It needs nothing
|
|
137
|
+
installed, which is the point: a campaign has to be able to run on a machine
|
|
138
|
+
nobody has prepared -- CI, a Pi, the Windows target.
|
|
139
|
+
|
|
140
|
+
`--radamsa` selects [radamsa](https://gitlab.com/akihe/radamsa) instead, which
|
|
141
|
+
reads the *shape* of its input, so a mutated JSON document is usually still
|
|
142
|
+
JSON and far more mutants survive the adapter:
|
|
143
|
+
|
|
144
|
+
```bash
|
|
145
|
+
git clone --depth 1 https://gitlab.com/akihe/radamsa
|
|
146
|
+
cd radamsa && make && make install PREFIX=$HOME/.local
|
|
147
|
+
|
|
148
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --radamsa --iterations 2000
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
Measured on this registry, fresh corpus, same seed, all targets:
|
|
152
|
+
|
|
153
|
+
| | signatures | time | per second |
|
|
154
|
+
|---|---:|---:|---:|
|
|
155
|
+
| built-in, 2,000 inputs | 304 | 41 s | **7.4** |
|
|
156
|
+
| radamsa, 2,000 inputs | **331** | 848 s | 0.4 |
|
|
157
|
+
| built-in, 32,000 inputs | **443** | 614 s | 0.7 |
|
|
158
|
+
|
|
159
|
+
Per input radamsa wins by 9%; per second the built-in wins by 19x. Given equal
|
|
160
|
+
wall clock the built-in found 34% more signatures and two defects radamsa did
|
|
161
|
+
not -- and five of the six product defects the loop has found came from it.
|
|
162
|
+
|
|
163
|
+
Keep radamsa for what it reaches rather than for volume: it builds the deep
|
|
164
|
+
nesting and long repetitions a byte mutator hits only by accident, which is
|
|
165
|
+
how the `RecursionError` in the frame composer was found.
|
|
166
|
+
|
|
167
|
+
Both modes are seeded and reproducible, both record which payload a mutant
|
|
168
|
+
came from, and the manifest says which one ran (`builtin/<seed>` or
|
|
169
|
+
`radamsa/<seed>`). Neither the commit gate nor `--replay` uses a mutator at
|
|
170
|
+
all.
|
|
171
|
+
|
|
172
|
+
### The nightly run
|
|
173
|
+
|
|
174
|
+
`tools/testing/nightly-parser-fuzz.sh` runs one campaign against every target
|
|
175
|
+
and exits non-zero on a violation. Driven by cron rather than by the platform,
|
|
176
|
+
so that a night when Django or Redis is down is still a night the loop runs:
|
|
177
|
+
|
|
178
|
+
```cron
|
|
179
|
+
17 3 * * * /path/to/repo/tools/testing/nightly-parser-fuzz.sh
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
The seed is the day of the year, so each night explores a different corner and
|
|
183
|
+
any night can be reproduced exactly. Logs land in
|
|
184
|
+
`artifacts/parser-fuzz-logs/`, which is git-ignored; the corpus it grows is
|
|
185
|
+
not, and committing that change is what carries the night's learning to
|
|
186
|
+
everyone else and puts it in the commit gate.
|
|
187
|
+
|
|
188
|
+
`--iterations` is the mutation budget. The retained corpus is replayed on top
|
|
189
|
+
of it rather than out of it -- a boundary movement is defined on a payload the
|
|
190
|
+
ledger already holds, so a corpus larger than the budget would otherwise stop
|
|
191
|
+
the campaign mutating at all.
|
|
192
|
+
|
|
193
|
+
Triage, when it fails: the log names the target, the payload hash and the
|
|
194
|
+
source line. `corpus/<target>/payloads/<hash>.bin` is the input. Fix the owner,
|
|
195
|
+
then `--replay` that target to confirm; the payload stays in the corpus, so
|
|
196
|
+
every commit from then on checks it.
|
|
197
|
+
|
|
198
|
+
### Fuzzing one function
|
|
199
|
+
|
|
200
|
+
```bash
|
|
201
|
+
tools/testing/fuzz mypkg.parser:parse_config --raises ValueError --seed '{"port": 80}'
|
|
202
|
+
tools/testing/fuzz ./newfile.py:parse_range --raises ValueError --seed 'bytes=0-1023'
|
|
203
|
+
tools/testing/fuzz mypkg.log:scan --raises LogError --seed @capture.asc
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
That is the whole thing. It reads the function's signature to work out whether
|
|
207
|
+
to hand it bytes, text, JSON or a path; it finds `ValueError` in builtins and
|
|
208
|
+
`LogError` beside the function itself; and the corpus goes to a temporary
|
|
209
|
+
directory unless you pass `--keep DIR`.
|
|
210
|
+
|
|
211
|
+
**`--raises` is the experiment.** It is the contract you are holding the
|
|
212
|
+
function to -- the exceptions it says it can raise. Anything else escaping is
|
|
213
|
+
the finding. Empty means "this never raises", which is right for a decoder
|
|
214
|
+
that returns a failure object and wrong for a validator.
|
|
215
|
+
|
|
216
|
+
Get it wrong and the run stops before it starts:
|
|
217
|
+
|
|
218
|
+
```
|
|
219
|
+
Stopping: the seeds already break the contract you gave.
|
|
220
|
+
Every one of them raised something --raises does not cover:
|
|
221
|
+
|
|
222
|
+
AttributeError
|
|
223
|
+
|
|
224
|
+
... Re-run with:
|
|
225
|
+
|
|
226
|
+
--raises AttributeError
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
Without that check a wrong contract does not fail, it just never finishes:
|
|
230
|
+
every input becomes a violation and every violation is replayed three times in
|
|
231
|
+
a fresh process to confirm it.
|
|
232
|
+
|
|
233
|
+
**Seeds decide how deep it gets.** One real input is worth more than any
|
|
234
|
+
number of iterations. On the same function, same budget:
|
|
235
|
+
|
|
236
|
+
| Seed | Result |
|
|
237
|
+
|------|--------|
|
|
238
|
+
| none | 1 signature, corpus 3 -- never got past the first check |
|
|
239
|
+
| `bytes=0-1023` | 16 signatures, corpus 36 |
|
|
240
|
+
|
|
241
|
+
**Reading the result.** `violations` is a broken contract, and the payload is
|
|
242
|
+
in the corpus directory. `moved` is a payload that used to do something else
|
|
243
|
+
-- not necessarily a bug. `new` is behaviour never seen before, and should
|
|
244
|
+
fall towards zero as the corpus fills. Zero violations is the normal outcome;
|
|
245
|
+
the signature count is the map of what your function does.
|
|
246
|
+
|
|
247
|
+
### Fuzzing another application
|
|
248
|
+
|
|
249
|
+
The engine knows nothing about IoTSploit. Targets live in a **pack** -- an
|
|
250
|
+
ordinary module that calls `register()` -- and IoTSploit's is just the one
|
|
251
|
+
that ships here:
|
|
252
|
+
|
|
253
|
+
```python
|
|
254
|
+
# myapp_fuzz.py, anywhere on PYTHONPATH
|
|
255
|
+
from iotsploit_fuzzer.harnesses.parser_targets import ParseTarget, json_object, register
|
|
256
|
+
|
|
257
|
+
def parse_config(payload: bytes):
|
|
258
|
+
raw = json_object(payload, "a config object")
|
|
259
|
+
...
|
|
260
|
+
|
|
261
|
+
register(ParseTarget(
|
|
262
|
+
name="myapp.config",
|
|
263
|
+
adapter="myapp_fuzz:parse_config",
|
|
264
|
+
declared=("builtins:ValueError",),
|
|
265
|
+
seeds=(b'{"port": 8080}',),
|
|
266
|
+
))
|
|
267
|
+
```
|
|
268
|
+
|
|
269
|
+
```bash
|
|
270
|
+
python -m iotsploit_fuzzer.core.parser_campaign \
|
|
271
|
+
--targets myapp_fuzz --root ~/myapp-corpus --iterations 3000
|
|
272
|
+
```
|
|
273
|
+
|
|
274
|
+
`--targets` replaces the default pack entirely, so none of IoTSploit's load.
|
|
275
|
+
`--root` keeps the corpus with your own source, where the gate that replays it
|
|
276
|
+
lives. A pack imports `ParseTarget`, `register`, and whichever adapter helpers
|
|
277
|
+
it wants -- `temp_file`, `json_input`, `json_object`, `text_input` -- each of
|
|
278
|
+
which raises `Skip` rather than letting a malformed payload look like a defect
|
|
279
|
+
in your parser.
|
|
280
|
+
|
|
281
|
+
Everything else -- worker isolation, outcome signatures, the corpus, the
|
|
282
|
+
ledger and its fingerprint, the boundary diff, the gate replay -- works the
|
|
283
|
+
same whatever the pack contains.
|
|
284
|
+
|
|
285
|
+
### Making it permanent
|
|
286
|
+
|
|
287
|
+
`tools/testing/fuzz` throws its corpus away, which is right for a question you
|
|
288
|
+
are asking once. When a target earns a permanent slot, move it into a pack --
|
|
289
|
+
`targets/iotsploit.py` for this codebase -- as an adapter next to the others
|
|
290
|
+
and an entry in the registry list:
|
|
291
|
+
|
|
292
|
+
```python
|
|
293
|
+
ParseTarget(
|
|
294
|
+
name="drivers.logic_capture",
|
|
295
|
+
adapter=f"{_HERE}:logic_capture",
|
|
296
|
+
declared=("builtins:ValueError", "builtins:TypeError"),
|
|
297
|
+
seeds=(_CAPTURE_SEED,),
|
|
298
|
+
budget_seconds=5.0,
|
|
299
|
+
),
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
`tests/test_parser_targets.py` then checks it on every commit: that the adapter
|
|
303
|
+
and every declared name resolve, and that at least one seed actually reaches
|
|
304
|
+
the target instead of being skipped. Once it has a corpus, the gate replays it.
|
|
305
|
+
|
|
306
|
+
### Known limits
|
|
307
|
+
|
|
308
|
+
- **`--radamsa` mutants inflate.** Left alone they grow until they hit
|
|
309
|
+
`payload_max_bytes`, and radamsa's cost scales with input size, so a long
|
|
310
|
+
radamsa campaign gets slower as it goes.
|
|
311
|
+
- **Mutation is byte-level by default.** For the JSON-shaped targets (`canbus.from_target`,
|
|
312
|
+
`canbus.decode_frame`) roughly 90% of mutants are not valid JSON and are
|
|
313
|
+
skipped. Structure-aware mutation would fix it and has not been written.
|
|
314
|
+
- **`someip.sd_parse` has almost no observable boundary** from random bytes: it
|
|
315
|
+
catches everything and returns a list, so nearly every input looks the same.
|
|
316
|
+
It needs seeds that are valid SD datagrams to say anything.
|
|
317
|
+
- **Novelty by outcome is a weak fitness signal** next to coverage guidance. It
|
|
318
|
+
plateaus. Real coverage feedback needs `sys.monitoring` (3.12+); on 3.10 it
|
|
319
|
+
would cost a 10-30x slowdown.
|
|
320
|
+
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
# iotsploit-fuzzer
|
|
2
|
+
|
|
3
|
+
Standalone fuzzer library extracted from `zeekr_sat_main`.
|
|
4
|
+
|
|
5
|
+
- **Package name (PyPI)**: `iotsploit-fuzzer`
|
|
6
|
+
- **Import name (Python)**: `iotsploit_fuzzer`
|
|
7
|
+
|
|
8
|
+
## Install (editable)
|
|
9
|
+
|
|
10
|
+
```bash
|
|
11
|
+
pip install -e iotsploit-fuzzer
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## Notes
|
|
15
|
+
|
|
16
|
+
This package is being introduced to replace (and later deprecate) the in-repo `iot_protocol_fuzzer` module.
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
## Two things this package fuzzes
|
|
21
|
+
|
|
22
|
+
They are not the same job, and conflating them is how a fuzzing effort ends up
|
|
23
|
+
measuring the wrong thing.
|
|
24
|
+
|
|
25
|
+
**Outbound — the device under test.** CAN/UART/SPI payloads sent through a
|
|
26
|
+
wire harness at whatever is on the other end. Needs the rig. This is what
|
|
27
|
+
`CANHarness`, `UARTHarness` and `SPIHarness` do, driven from the Django UI.
|
|
28
|
+
|
|
29
|
+
**Inbound — our own parsers.** The same `Orchestrator`, pointed at a function
|
|
30
|
+
instead of an interface. Needs nothing but CPU, so it runs in CI and in the
|
|
31
|
+
commit gate. That is what follows.
|
|
32
|
+
|
|
33
|
+
## The parser loop
|
|
34
|
+
|
|
35
|
+
A campaign that is run once, reported and forgotten finds its last bug in week
|
|
36
|
+
two. What keeps producing information is the **error boundary** -- the line
|
|
37
|
+
between the inputs a parser accepts and the ones it rejects -- because every
|
|
38
|
+
refactor moves it and almost every move is unintended. Nothing in the test
|
|
39
|
+
suite asserts which ARXML files import or which ASC lines parse. The ledger is
|
|
40
|
+
that assertion, and it writes itself.
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
# What is in the registry
|
|
44
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --list
|
|
45
|
+
|
|
46
|
+
# One target, one campaign
|
|
47
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign \
|
|
48
|
+
--target canbus.scan_log --iterations 2000
|
|
49
|
+
|
|
50
|
+
# Everything, as a nightly run. Exits non-zero on a violation.
|
|
51
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --iterations 2000
|
|
52
|
+
|
|
53
|
+
# Replay the retained corpus and generate nothing. This is what the commit
|
|
54
|
+
# gate runs, via tests/test_parser_corpus_replay.py.
|
|
55
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --replay
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
A campaign reports exactly three things:
|
|
59
|
+
|
|
60
|
+
| Event | Means | Nightly |
|
|
61
|
+
|-------|-------|---------|
|
|
62
|
+
| `VIOLATION` | A contract broke: an undeclared exception, a hang, a resource limit, a broken round trip | Fails |
|
|
63
|
+
| `BOUNDARY_MOVED` | A payload the ledger knows now does something else. **Not necessarily a bug** -- a behaviour change somebody should confirm was intended | Reports |
|
|
64
|
+
| `NEW_REGION` | A signature this target has never produced. The corpus grew | Informational |
|
|
65
|
+
|
|
66
|
+
### Each parse runs in a process we are willing to lose
|
|
67
|
+
|
|
68
|
+
`ParserHarness` is a controller; the parse happens in a subprocess under
|
|
69
|
+
wall-clock, memory, output-size and payload-size limits. This is not
|
|
70
|
+
defensiveness. A parser that allocates until the host swaps cannot be
|
|
71
|
+
recovered from in-process -- `except MemoryError` runs only after the
|
|
72
|
+
allocation happened -- and a signal handler only runs at a bytecode boundary
|
|
73
|
+
the interpreter may never reach again. Worker death *is* the result.
|
|
74
|
+
|
|
75
|
+
Workers are batched and recycled rather than forked per payload, because a
|
|
76
|
+
spawn costs more than the parsing does. Anything that is not a plain accept or
|
|
77
|
+
reject is re-run alone in a fresh worker, three times, before it is believed:
|
|
78
|
+
an outcome that does not reproduce is reported as flaky and never enters the
|
|
79
|
+
corpus.
|
|
80
|
+
|
|
81
|
+
### The ledger is a contract, and it is versioned
|
|
82
|
+
|
|
83
|
+
`corpus/<target>/` holds two files: `payloads.zip` and `ledger.json`. One
|
|
84
|
+
archive per target rather than one file per payload -- a thousand inputs of a
|
|
85
|
+
hundred bytes each, stored loose, was 74% of the repository's tracked file
|
|
86
|
+
count for 1% of its bytes, and a 4 KB block apiece turned 1.1 MB into 9.4 MB
|
|
87
|
+
on disk. An existing loose corpus migrates itself on the next save.
|
|
88
|
+
|
|
89
|
+
The ledger is tracked in git on purpose -- it is the loop's
|
|
90
|
+
memory, the gate's regression corpus, and a boundary movement arrives as a
|
|
91
|
+
JSON diff in the pull request that caused it. Each entry carries the payload's
|
|
92
|
+
content hash, its normalised outcome signature, and the campaign that first
|
|
93
|
+
saw it.
|
|
94
|
+
|
|
95
|
+
Signatures never contain a raw exception message: messages carry offsets, hex,
|
|
96
|
+
paths and quoted input, and comparing them would report thousands of movements
|
|
97
|
+
nobody caused. They also never contain a line number, for the same reason.
|
|
98
|
+
|
|
99
|
+
Changing a target's declared exception set, its adapter, or the signature
|
|
100
|
+
format changes its **fingerprint**, and a ledger recorded under a different
|
|
101
|
+
fingerprint is refused rather than diffed:
|
|
102
|
+
|
|
103
|
+
```bash
|
|
104
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign \
|
|
105
|
+
--target canbus.scan_log --rebaseline
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Re-baselining keeps the payloads -- they are the expensive part -- and drops
|
|
109
|
+
only the claim about what they do, which the next campaign re-derives.
|
|
110
|
+
|
|
111
|
+
### Two mutators
|
|
112
|
+
|
|
113
|
+
The default is eight byte-level operations on a seeded PRNG. It needs nothing
|
|
114
|
+
installed, which is the point: a campaign has to be able to run on a machine
|
|
115
|
+
nobody has prepared -- CI, a Pi, the Windows target.
|
|
116
|
+
|
|
117
|
+
`--radamsa` selects [radamsa](https://gitlab.com/akihe/radamsa) instead, which
|
|
118
|
+
reads the *shape* of its input, so a mutated JSON document is usually still
|
|
119
|
+
JSON and far more mutants survive the adapter:
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
git clone --depth 1 https://gitlab.com/akihe/radamsa
|
|
123
|
+
cd radamsa && make && make install PREFIX=$HOME/.local
|
|
124
|
+
|
|
125
|
+
poetry run python -m iotsploit_fuzzer.core.parser_campaign --radamsa --iterations 2000
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Measured on this registry, fresh corpus, same seed, all targets:
|
|
129
|
+
|
|
130
|
+
| | signatures | time | per second |
|
|
131
|
+
|---|---:|---:|---:|
|
|
132
|
+
| built-in, 2,000 inputs | 304 | 41 s | **7.4** |
|
|
133
|
+
| radamsa, 2,000 inputs | **331** | 848 s | 0.4 |
|
|
134
|
+
| built-in, 32,000 inputs | **443** | 614 s | 0.7 |
|
|
135
|
+
|
|
136
|
+
Per input radamsa wins by 9%; per second the built-in wins by 19x. Given equal
|
|
137
|
+
wall clock the built-in found 34% more signatures and two defects radamsa did
|
|
138
|
+
not -- and five of the six product defects the loop has found came from it.
|
|
139
|
+
|
|
140
|
+
Keep radamsa for what it reaches rather than for volume: it builds the deep
|
|
141
|
+
nesting and long repetitions a byte mutator hits only by accident, which is
|
|
142
|
+
how the `RecursionError` in the frame composer was found.
|
|
143
|
+
|
|
144
|
+
Both modes are seeded and reproducible, both record which payload a mutant
|
|
145
|
+
came from, and the manifest says which one ran (`builtin/<seed>` or
|
|
146
|
+
`radamsa/<seed>`). Neither the commit gate nor `--replay` uses a mutator at
|
|
147
|
+
all.
|
|
148
|
+
|
|
149
|
+
### The nightly run
|
|
150
|
+
|
|
151
|
+
`tools/testing/nightly-parser-fuzz.sh` runs one campaign against every target
|
|
152
|
+
and exits non-zero on a violation. Driven by cron rather than by the platform,
|
|
153
|
+
so that a night when Django or Redis is down is still a night the loop runs:
|
|
154
|
+
|
|
155
|
+
```cron
|
|
156
|
+
17 3 * * * /path/to/repo/tools/testing/nightly-parser-fuzz.sh
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The seed is the day of the year, so each night explores a different corner and
|
|
160
|
+
any night can be reproduced exactly. Logs land in
|
|
161
|
+
`artifacts/parser-fuzz-logs/`, which is git-ignored; the corpus it grows is
|
|
162
|
+
not, and committing that change is what carries the night's learning to
|
|
163
|
+
everyone else and puts it in the commit gate.
|
|
164
|
+
|
|
165
|
+
`--iterations` is the mutation budget. The retained corpus is replayed on top
|
|
166
|
+
of it rather than out of it -- a boundary movement is defined on a payload the
|
|
167
|
+
ledger already holds, so a corpus larger than the budget would otherwise stop
|
|
168
|
+
the campaign mutating at all.
|
|
169
|
+
|
|
170
|
+
Triage, when it fails: the log names the target, the payload hash and the
|
|
171
|
+
source line. `corpus/<target>/payloads/<hash>.bin` is the input. Fix the owner,
|
|
172
|
+
then `--replay` that target to confirm; the payload stays in the corpus, so
|
|
173
|
+
every commit from then on checks it.
|
|
174
|
+
|
|
175
|
+
### Fuzzing one function
|
|
176
|
+
|
|
177
|
+
```bash
|
|
178
|
+
tools/testing/fuzz mypkg.parser:parse_config --raises ValueError --seed '{"port": 80}'
|
|
179
|
+
tools/testing/fuzz ./newfile.py:parse_range --raises ValueError --seed 'bytes=0-1023'
|
|
180
|
+
tools/testing/fuzz mypkg.log:scan --raises LogError --seed @capture.asc
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
That is the whole thing. It reads the function's signature to work out whether
|
|
184
|
+
to hand it bytes, text, JSON or a path; it finds `ValueError` in builtins and
|
|
185
|
+
`LogError` beside the function itself; and the corpus goes to a temporary
|
|
186
|
+
directory unless you pass `--keep DIR`.
|
|
187
|
+
|
|
188
|
+
**`--raises` is the experiment.** It is the contract you are holding the
|
|
189
|
+
function to -- the exceptions it says it can raise. Anything else escaping is
|
|
190
|
+
the finding. Empty means "this never raises", which is right for a decoder
|
|
191
|
+
that returns a failure object and wrong for a validator.
|
|
192
|
+
|
|
193
|
+
Get it wrong and the run stops before it starts:
|
|
194
|
+
|
|
195
|
+
```
|
|
196
|
+
Stopping: the seeds already break the contract you gave.
|
|
197
|
+
Every one of them raised something --raises does not cover:
|
|
198
|
+
|
|
199
|
+
AttributeError
|
|
200
|
+
|
|
201
|
+
... Re-run with:
|
|
202
|
+
|
|
203
|
+
--raises AttributeError
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
Without that check a wrong contract does not fail, it just never finishes:
|
|
207
|
+
every input becomes a violation and every violation is replayed three times in
|
|
208
|
+
a fresh process to confirm it.
|
|
209
|
+
|
|
210
|
+
**Seeds decide how deep it gets.** One real input is worth more than any
|
|
211
|
+
number of iterations. On the same function, same budget:
|
|
212
|
+
|
|
213
|
+
| Seed | Result |
|
|
214
|
+
|------|--------|
|
|
215
|
+
| none | 1 signature, corpus 3 -- never got past the first check |
|
|
216
|
+
| `bytes=0-1023` | 16 signatures, corpus 36 |
|
|
217
|
+
|
|
218
|
+
**Reading the result.** `violations` is a broken contract, and the payload is
|
|
219
|
+
in the corpus directory. `moved` is a payload that used to do something else
|
|
220
|
+
-- not necessarily a bug. `new` is behaviour never seen before, and should
|
|
221
|
+
fall towards zero as the corpus fills. Zero violations is the normal outcome;
|
|
222
|
+
the signature count is the map of what your function does.
|
|
223
|
+
|
|
224
|
+
### Fuzzing another application
|
|
225
|
+
|
|
226
|
+
The engine knows nothing about IoTSploit. Targets live in a **pack** -- an
|
|
227
|
+
ordinary module that calls `register()` -- and IoTSploit's is just the one
|
|
228
|
+
that ships here:
|
|
229
|
+
|
|
230
|
+
```python
|
|
231
|
+
# myapp_fuzz.py, anywhere on PYTHONPATH
|
|
232
|
+
from iotsploit_fuzzer.harnesses.parser_targets import ParseTarget, json_object, register
|
|
233
|
+
|
|
234
|
+
def parse_config(payload: bytes):
|
|
235
|
+
raw = json_object(payload, "a config object")
|
|
236
|
+
...
|
|
237
|
+
|
|
238
|
+
register(ParseTarget(
|
|
239
|
+
name="myapp.config",
|
|
240
|
+
adapter="myapp_fuzz:parse_config",
|
|
241
|
+
declared=("builtins:ValueError",),
|
|
242
|
+
seeds=(b'{"port": 8080}',),
|
|
243
|
+
))
|
|
244
|
+
```
|
|
245
|
+
|
|
246
|
+
```bash
|
|
247
|
+
python -m iotsploit_fuzzer.core.parser_campaign \
|
|
248
|
+
--targets myapp_fuzz --root ~/myapp-corpus --iterations 3000
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
`--targets` replaces the default pack entirely, so none of IoTSploit's load.
|
|
252
|
+
`--root` keeps the corpus with your own source, where the gate that replays it
|
|
253
|
+
lives. A pack imports `ParseTarget`, `register`, and whichever adapter helpers
|
|
254
|
+
it wants -- `temp_file`, `json_input`, `json_object`, `text_input` -- each of
|
|
255
|
+
which raises `Skip` rather than letting a malformed payload look like a defect
|
|
256
|
+
in your parser.
|
|
257
|
+
|
|
258
|
+
Everything else -- worker isolation, outcome signatures, the corpus, the
|
|
259
|
+
ledger and its fingerprint, the boundary diff, the gate replay -- works the
|
|
260
|
+
same whatever the pack contains.
|
|
261
|
+
|
|
262
|
+
### Making it permanent
|
|
263
|
+
|
|
264
|
+
`tools/testing/fuzz` throws its corpus away, which is right for a question you
|
|
265
|
+
are asking once. When a target earns a permanent slot, move it into a pack --
|
|
266
|
+
`targets/iotsploit.py` for this codebase -- as an adapter next to the others
|
|
267
|
+
and an entry in the registry list:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
ParseTarget(
|
|
271
|
+
name="drivers.logic_capture",
|
|
272
|
+
adapter=f"{_HERE}:logic_capture",
|
|
273
|
+
declared=("builtins:ValueError", "builtins:TypeError"),
|
|
274
|
+
seeds=(_CAPTURE_SEED,),
|
|
275
|
+
budget_seconds=5.0,
|
|
276
|
+
),
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
`tests/test_parser_targets.py` then checks it on every commit: that the adapter
|
|
280
|
+
and every declared name resolve, and that at least one seed actually reaches
|
|
281
|
+
the target instead of being skipped. Once it has a corpus, the gate replays it.
|
|
282
|
+
|
|
283
|
+
### Known limits
|
|
284
|
+
|
|
285
|
+
- **`--radamsa` mutants inflate.** Left alone they grow until they hit
|
|
286
|
+
`payload_max_bytes`, and radamsa's cost scales with input size, so a long
|
|
287
|
+
radamsa campaign gets slower as it goes.
|
|
288
|
+
- **Mutation is byte-level by default.** For the JSON-shaped targets (`canbus.from_target`,
|
|
289
|
+
`canbus.decode_frame`) roughly 90% of mutants are not valid JSON and are
|
|
290
|
+
skipped. Structure-aware mutation would fix it and has not been written.
|
|
291
|
+
- **`someip.sd_parse` has almost no observable boundary** from random bytes: it
|
|
292
|
+
catches everything and returns a list, so nearly every input looks the same.
|
|
293
|
+
It needs seeds that are valid SD datagrams to say anything.
|
|
294
|
+
- **Novelty by outcome is a weak fitness signal** next to coverage guidance. It
|
|
295
|
+
plateaus. Real coverage feedback needs `sys.monitoring` (3.12+); on 3.10 it
|
|
296
|
+
would cost a 10-30x slowdown.
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
[tool.poetry]
|
|
2
|
+
name = "iotsploit-fuzzer"
|
|
3
|
+
version = "0.0.9"
|
|
4
|
+
description = "IoTSploit fuzzer library (ports + core + adapters), extracted from zeekr_sat_main"
|
|
5
|
+
authors = ["IoTSploit Team <devnull@example.com>"]
|
|
6
|
+
readme = "README.md"
|
|
7
|
+
packages = [{ include = "iotsploit_fuzzer", from = "src" }]
|
|
8
|
+
|
|
9
|
+
[tool.poetry.dependencies]
|
|
10
|
+
python = ">=3.10,<4.0"
|
|
11
|
+
|
|
12
|
+
# Keep core deps minimal; optional hardware/protocol deps are in extras.
|
|
13
|
+
python-can = { version = "*", optional = true }
|
|
14
|
+
pyserial = { version = "*", optional = true }
|
|
15
|
+
spidev = { version = "*", optional = true }
|
|
16
|
+
|
|
17
|
+
[tool.poetry.group.dev.dependencies]
|
|
18
|
+
pytest = "^7.4.0"
|
|
19
|
+
|
|
20
|
+
[tool.poetry.extras]
|
|
21
|
+
can = ["python-can"]
|
|
22
|
+
uart = ["pyserial"]
|
|
23
|
+
spi = ["spidev"]
|
|
24
|
+
all = ["python-can", "pyserial", "spidev"]
|
|
25
|
+
|
|
26
|
+
[build-system]
|
|
27
|
+
requires = ["poetry-core>=1.8.0"]
|
|
28
|
+
build-backend = "poetry.core.masonry.api"
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
32
|
+
addopts = ["-q"]
|