Design Spec: Response-Feedback Guided Fuzzing (fuzz guided)¶
Document Control¶
| Field | Value |
|---|---|
| Status | Implemented |
| Command surface | canarchy fuzz guided |
| Primary area | Fuzzing, CLI, safety |
| Related specs | docs/design/active-transmit-safety.md, docs/design/uds-transaction-workflows.md |
Goal¶
Drive a coverage-guided fuzzing loop against an external CAN / J1939 / UDS target. AFL-style instrumentation coverage is unavailable when the target is an ECU on the far side of a bus, so the feedback signal is derived from observed responses: UDS negative response codes (NRCs), response-timing variance, DM1 fault emergence, and frame-rate / silence transitions. Inputs that elicit new observed behaviour are kept and their lineage is prioritised for further mutation, so the campaign spends its budget where the target reacts.
User-Facing Motivation¶
Blind fuzzers waste most of their budget re-sending inputs the ECU ignores. Using the target's own responses as a novelty signal focuses mutation on inputs that change its behaviour — surfacing new NRCs, latent fault codes, and timing/silence anomalies far faster, while staying inside the active-transmit safety model.
Observation Model¶
Each fuzzed input produces a ResponseObservation (the response frames captured
within a window, the elapsed time, and whether the target went silent). It is
reduced to a Fingerprint — a set of category-prefixed behaviour markers:
| Marker | Source | Example |
|---|---|---|
nrc:<svc>:<code> |
UDS negative response (0x7F) after ISO-TP reassembly |
nrc:27:35 |
pos:<svc> |
UDS positive response service id | pos:50 |
dm1:<spn>:<fmi> |
Active DTC in a DM1 broadcast (canarchy.j1939.dm1_messages) |
dm1:100:3 |
timing:<bucket> |
Response latency bucketed on a fixed ms ladder | timing:3 |
silence |
No response within the window | silence |
--signals selects which marker categories are active (default all).
Scoring Function¶
A FeedbackTracker holds the set of markers seen so far. For a new fingerprint,
its gain is the weighted count of markers not yet seen:
gain(fp) = sum( weight[category(m)] for m in fp.markers if m not in seen )
Default weights prioritise semantic findings over timing noise: nrc and dm1
= 5, pos = 2, silence = 3, timing = 1. An input with gain > 0 is a new
behaviour: it is retained as a corpus seed and recorded as a finding. The tracker
then folds the fingerprint's markers into seen.
Seed Corpus And Lineage¶
- A seed is
(data, seed_id, parent_id, generation, score). The initial seeds seed generation 0 with no parent. - Each iteration selects a seed (randomly with weight
score + 1), mutates it with acanarchy.fuzzingmutator (havoc / splice), and observes the response. A mutation withgain > 0becomes a child seed whosescoreis its gain and whoseparent_id/generationrecord its lineage. - The corpus is capped (
--max-corpus); when it overflows, the lowest-scoring seeds are pruned, so productive lineages survive and barren ones are dropped. --corpus <dir>persists the corpus: one raw seed file per seed plus alineage.jsonmanifest, and reloads seed bytes on the next run. Each invocation starts a new feedback tracker and campaign namespace; this is seed reuse, not exact process resumption.
Durable Findings Requirements (#503)¶
| ID | Type | Requirement |
|---|---|---|
| REQ-GFA-01 | Ubiquitous | The active CLI/MCP campaign shall create a unique campaign directory and persist each finding independently of the bounded working corpus before another transmission. |
| REQ-GFA-02 | Ubiquitous | The system shall retain exact clamped payload bytes, response frames/timing/silence, new markers, iteration, parent ID/payload, generation, and campaign-qualified finding identity. |
| REQ-GFA-03 | Ubiquitous | The campaign manifest shall record schema/tool versions, timestamp, initial seeds, effective RNG/mutation/feedback settings, target ID/type/channel, and available transport window settings without copying arbitrary environment variables. |
| REQ-GFA-04 | Unwanted behaviour | If a storage operation fails, the system shall stop further transmissions, report a structured error and archive location, and preserve previously completed records. |
| REQ-GFA-05 | Event-driven | When transport failure or keyboard interruption ends a campaign, the CLI shall report the archive location and completed finding count. |
| REQ-GFA-06 | Optional feature | Where dry-run is selected, the system shall create no archive and transmit no frames. |
| REQ-GFA-08 | Unwanted behaviour | If a campaign rate or duration is non-finite, the CLI shall reject it as a usage error before archive creation, in active and dry-run modes. |
| REQ-GFA-09 | Unwanted behaviour | If a transport transaction raises an OSError, the CLI shall report a transport error; only explicit archive or corpus operations shall produce persistence errors. |
| REQ-GFA-07 | Ubiquitous | The archive shall expose JSON records inspectable offline without executing commands or requiring surviving corpus seeds. |
Archive Format And Durability¶
Every active run creates <root>/<uuid>/campaign.json and one
finding-<iteration padded to 8 digits>.json per interesting input. The root is
--findings-dir, otherwise <corpus>/findings when --corpus is given, otherwise
~/.canarchy/findings. Existing corpus manifests and .bin files keep their
format; findings never depend on them. Repeated runs use fresh UUID directories
so s1 in one run cannot overwrite s1 in another.
The manifest has schema_version: 1, campaign_id, created_at,
canarchy_version, explicit config, and clamped initial_seeds with local
seed IDs. Configuration includes seed, max_payload, pace_seconds, budget,
mutator name, feedback weights/categories, numeric arbitration ID, extended
flag, interface, backend, python-can interface type, capture limit and timeout.
target_firmware: null explicitly denotes unavailable ECU identity; physical
ECU state is not reproducible from the manifest alone.
Finding records add schema_version, recorded_at, campaign_id, and
finding_id (<campaign_id>:<seed_id>) to iteration, seed/parent IDs, generation,
gain and new markers. data and parent_data are hex strings. observation
contains serialized response frames, elapsed seconds, and a silence flag.
Parent snapshots remain available after pruning; initial parents are also in
the manifest and every interesting descendant has its own record.
Records are serialized into a temporary file, flushed and fsynced, then
atomically replaced into their final filename. POSIX additionally fsyncs the
directory; Windows supports the complete-file replacement but not that directory
fsync step. A process killed during a write may leave a hidden temporary file;
offline readers use only campaign.json and finding-*.json. A write failure
stops the loop rather than proceeding with unrecorded findings. Only previously
completed records are guaranteed: a kill between receiving a response and
publishing its record can lose that in-flight finding. Final corpus-save failure
cannot remove already archived evidence. No automatic retention/pruning of the
findings archive occurs.
The pure loop supports an injected on_finding callback and optional campaign
ID. Direct Python callers select their own persistence sink; the CLI/MCP always
wires the archive for active runs. See the command specification for offline
inspection and manual replay planning.
Command Surface¶
canarchy fuzz guided <interface> --id <arb-id> [--signals nrc,timing,dm1,silence]
[--corpus <dir>] [--findings-dir <root>] [--seed-data <hex>] [--max-iterations <n>] [--max-seconds <s>]
[--max-corpus <n>] [--rate <hz>] [--seed <rng>] [--ack-active] [--dry-run]
[--json|--jsonl|--text]
Integration Points¶
- Mutators:
canarchy.fuzzing.havoc_payload/splice_payload(the #310 and AFL mutators) provide the mutation operators;fuzz_guidedis a thin loop on top, not a new mutation engine. - UDS:
canarchy.uds.reassemble_uds_pdusreassembles responses for NRC / positive fingerprinting. - J1939:
canarchy.j1939.dm1_messagesextracts DM1 active DTCs for fault emergence. - Transport: each iteration uses a single
LocalTransport.transaction(send + receive on one bus) rather thansend()then a separatecapture(), so the receive path is buffering before the probe is transmitted and a fast response is not missed and recorded as falsesilence.uds scanandxcp scanuse the same primitive. - Safety:
fuzz guidedis an active-transmit command — it honours--ack-active,[safety].require_active_ack, theYESconfirmation,--ratepacing, and--dry-run(which plans the campaign without opening the transport).--ratepacing is applied inside the campaign budget with the deadline re-checked after each pacing delay, so a delay that crosses--max-secondsends the run rather than permitting one more transmission. An--idabove the 11-bit standard range is transmitted as an extended frame (inferred, likesend/xcp scan), so a 29-bit id never builds an invalid standard frame. The MCP tool mandatesack_active=truewithdry_rundefaulting to true.
Failure-Mode Handling¶
| Condition | Handling |
|---|---|
| Hung / silent ECU | The observation window times out, recorded as a silence marker; the loop continues unless the kill-switch fires. |
| Bus / transport error | Surfaced as FUZZ_GUIDED_TRANSPORT_FAILED (exit 2); completed finding records remain in the archive, whose location/count is returned in the error data; a partial final corpus is not promised. |
| Saturating DM1 output | DM1 markers are de-duplicated by (spn, fmi); a single noisy fault cannot inflate the score unboundedly. |
| Kill-switch | --max-seconds and --max-iterations bound the campaign; either reaching its limit ends the run with a recorded stop_reason. |
Output Contracts¶
--json returns the campaign envelope: iterations, new_behaviour_count,
corpus_size, unique_markers, stop_reason, a findings list (each with exact payload, observation, lineage and stable identity), and the resolved campaign config.
campaign_id, archive_path, and archived_finding_count locate durable evidence.
--jsonl emits per-finding events after the campaign; persistence itself is incremental. --text renders a campaign summary.
--dry-run returns the plan (mode: dry_run) with the seed count and the first
planned mutations, opening no transport.
Error Contracts¶
| Code | Trigger | Exit code |
|---|---|---|
FUZZ_GUIDED_INVALID_SIGNALS |
--signals names an unknown marker category |
1 |
FUZZ_GUIDED_INVALID_ID |
--id is not a valid CAN id, or is outside the 29-bit range |
1 |
ACTIVE_ACK_REQUIRED |
active run without --ack-active while required |
1 |
FUZZ_GUIDED_TRANSPORT_FAILED |
the transport raised mid-campaign | 2 |
FUZZ_GUIDED_PERSISTENCE_FAILED |
archive initialization, record publication, or final corpus save failed | 2 |
FUZZ_GUIDED_INVALID_TIMING |
non-finite rate or max-seconds | 1 |
FUZZ_GUIDED_INTERRUPTED |
keyboard interruption | 1 |
Responsibilities And Boundaries¶
In scope: the response-fingerprint engine, the guided loop with lineage and persistence, the CLI/MCP surface, and active-transmit safety. Out of scope (v1): multi-target campaigns, automatic seed minimisation, automatic live replay and feedback signals beyond the five categories above. These are natural follow-ups once the loop is in use.
Deferred Decisions¶
- Seed minimisation / corpus distillation passes.
- A
fuzz guided replay <corpus>command to re-drive a saved corpus. - Additional feedback signals (e.g. UDS pending-response
0x78cadence).