Explain what changed between two firmware images — in plain English.
fw-diff takes two builds of almost anything that executes — Linux/Windows/Mac binaries (ELF/PE/Mach-O), iOS .ipa, Android .apk, Switch NRO/NSO, UEFI firmware volumes, .deb/.rpm/MSI/CAB/DMG packages, raw flash dumps, or extracted directory trees — lifts both through Ghidra headless, matches functions across builds, computes a deterministic structural diff, and then annotates every change with a security-aware, human-readable explanation — using a local LLM by default. No cloud required.
$ fw-diff explain fw-1.4.2.bin fw-1.4.3.bin --arch armv7 --base 0x40000000
lifting ██████████ fw-1.4.2 (1,910 functions, 3m12s)
lifting ██████████ fw-1.4.3 (1,912 functions, 3m05s)
matching ██████████ exact 1,681 · structural 118 · embedding 43 · unmatched 180
┌─ HIGH · parse_header 0x4001a3c0 → 0x4001a4f0 ─────────────────────────────────┐
│ New bound check inserted before memcpy (dst size 0x40 vs len). │
│ Classifier: bound_change (evidence: cmp #0x40 added; caller of memcpy) │
│ Hypothesis: CWE-190 fix — integer/bounds hardening. Confidence: medium. │
└───────────────────────────────────────────────────────────────────────────────┘
Changed 89 · Added 121 · Removed 28 · new crypto constants in 2 functions
Reports: out/report.html · out/report.md · out/facts.json
Every existing binary-diff workflow stops at “these functions changed.” Nobody tells you what changed, why it matters, or whether it is a security fix or a regression — and none of them work headless on symbol-less, multi-arch firmware inside CI.
| fw-diff | BinDiff | Diaphora | Ghidra Version Tracking | |
|---|---|---|---|---|
| Headless / CI-friendly | yes (policy gates) | no | partial | partial (GUI-bound) |
| Raw firmware, no symbols | first-class | weak | weak | manual effort |
| Multi-arch (ARM/MIPS/RISC-V/…) | yes | x86-centric | varies | yes |
| Natural-language explanations | yes, local LLM | no | no | no |
| Deterministic, reproducible output | yes | yes | partial | no |
| Offline / air-gapped | default | n/a | n/a | n/a |
| License | BSD-3-Clause | proprietary | GPL | Apache-2.0 |
facts.json— machine-readable diff facts. Deterministic: same inputs → byte-identical output. Safe to gate a release pipeline on.report.html— interactive side-by-side decompilation with per-change explanations, function graph, and evidence links.report.md— the changelog your patch notes were missing.sarif.json— SARIF 2.1 for GitHub code scanning / IDEs (high → warning, medium → note, low → omitted).- CLI policy engine — fail CI on classes of change (
--fail-on bound_change,new_crypto).
flowchart LR
A[old image] --> I[ingest<br/>unpack · arch detect]
B[new image] --> I
I --> W1[ghidra-worker<br/>old]
I --> W2[ghidra-worker<br/>new]
W1 --> M[matcher<br/>S1 exact → S2 structural → S3 embedding]
W2 --> M
M --> D[delta engine<br/>AST diff → ChangeFacts]
D --> E[explainer<br/>local LLM annotation]
D --> R[renderers]
E --> R
R --> O[facts.json · report.md · report.html]
The core design rule: facts before narrative. The deterministic diff engine is the source of truth. The LLM only annotates facts it is handed and must cite evidence for every claim — it can never invent a finding. See ADR-0003.
Zero-setup demo (bundled fixture IR — no Ghidra needed):
pipx install fw-diff
fw-diff demo --out out/ # full pipeline on a synthetic firmware pairReal firmware (Ghidra 11.3+ required — tested against 11.3.2, Java 21):
fw-diff explain old.bin new.bin --embed # adds the S3 embedding stage (fastembed)pipx install 'fw-diff[ghidra]'
export GHIDRA_INSTALL_DIR=/opt/ghidra # or the bundled wheel: pip install <ghidra>/Ghidra/Features/PyGhidra/pypkg/dist/pyghidra-*.whl
fw-diff doctor # verifies the environment
fw-diff explain fw-1.4.2.bin fw-1.4.3.bin --base 0x40000000
fw-diff ci fw-1.4.2.bin fw-1.4.3.bin --policy policy.yaml
fw-diff timeline fw-1.4.1.bin fw-1.4.2.bin fw-1.4.3.bin # release-train diff
fw-diff mcp # agent integrationRuns fully offline with any Ollama-served model; zero API keys needed.
Remote OpenAI-compatible endpoints are opt-in via --llm-url. All numeric gates: exit 0/1
(policy), 2 (config/ingest).
| Method | Command | Notes |
|---|---|---|
| pip / pipx | pipx install fw-diff |
recommended for analysts |
| Docker | docker run ghcr.io/dilates/fw-diff explain … |
worker sandboxing built in |
| from source | uv sync && uv run fw-diff --help |
see CONTRIBUTING |
fw-diff mcp runs a read-only MCP server on stdio — sessions, facts, and change lists
exposed to MCP-compatible agents (editors, pipelines). The analysis pipeline
stays CLI-side; the server only reads the local session store.
v0.2.0a1 — implemented. Deterministic core end-to-end (ingest → Ghidra lift → normalize → multi-stage match → delta → classifiers → facts/reports), local-LLM explain layer with evidence-validated claims, SARIF 2.1 output, docker worker sandboxing (ADR-0008), blob-level lift cache, S3 embedding stage (optional), and a nightly eval harness. CI gates: lint/types ×3 Python versions, corpus, byte-reproducibility, docs, real-Ghidra integration, docker-worker sandbox. See docs/ROADMAP.md and docs/PRODUCT_SPEC.md.
| Doc | What's in it |
|---|---|
| Product spec | users, scope, success metrics |
| Architecture | components, data model, process model |
| Pipeline spec | normalization, matching, classifiers — the deep dive |
| Report format | facts.json schema v1 |
| ADRs | 10 recorded design decisions |
| Threat model | untrusted-input posture, LLM guardrails |
| Testing strategy | synthetic firmware corpus, eval harness |
| Getting started | walkthrough with real session output |
We treat this like a product: specs first, ADRs for every decision, corpus-driven tests. Start with CONTRIBUTING.md. Found a vulnerability in fw-diff itself? See SECURITY.md.
BSD-3-Clause — see LICENSE.
