# 14 — Firmware adversarial interoperability research guide (risc-v-gsp-rm-debugger + Ghidra) > Status: implemented, documented and tested (audit: docs/27-TOOL-AUDIT.md; value demos: docs/25-VALUE-VALIDATION.md). ## 1. Purpose and scope Open-IOV's hard problem is not writing the GSP emulation once; it is keeping `vgsp` accurate while the firmware changes underneath it. New GSP images, device pairings and architectures arrive stripped, signed and hardware-coupled, and some actively resist analysis. This guide is the practical companion to `RISC-V-GSP-RM-DEBUGGER-ROADMAP.md` H4: how to move from "the new image does something different" to a reviewed product change with evidence. Ghidra is the **static map**; risc-v-gsp-rm-debugger is the **dynamic ground truth**; recorded sessions are the **shared evidence**. Recipes use only the v0.1 surface, plus items explicitly labelled roadmap; see also docs/06-ACCURACY.md. | Capability used here | State | |---|---| | `probe`/`record`/`analyze`/`compare`/`symbols`/`inject`/`ghidra`, hooks, watchpoints, snapshots, sessions, `export_hits` | working (v0.1) | | Ghidra control bridge (`run_until`, breakpoints, hits) | working (v0.1) | | Full TCG access trace (`qemu/greeneye_trace.c`) | source + optional build (verified live); native equivalent folds into sessions (`--trace`, R4); direct TCG JSONL import is still roadmap | | Mutation harness, control discovery/reach, portability/version diff | working (docs/18, 19, 21, 22) | | First-touch device map + lenient native MMIO (H2.1) | working (docs/24) | | MCP server (`plugins/mcp_server.py`, H7/R3) | working (docs/20) | | Full device model (H2.2) | roadmap (docs/16, 24) | ## 2. The adversary model (a moving target, not a malicious party) This guide assumes no threat actor. The "adversary" is **unplanned firmware change plus deliberate opacity**: new images on a hidden schedule; stripped symbols, signed images and non-recoverable boot states; anti-analysis behaviour (timing checks, self-consistency checks, hardware- dependent branches) and **decoys** (code or data not on the live path); and hardware coupling a simulator cannot yet exercise. 1. Any single run is an anecdote. A conclusion needs at least an A/B pair. 2. A static reading is a hypothesis. It becomes evidence only when execution reaches it in a recorded session. 3. Absence of a hit is a fact about *that* run at *that* fidelity (docs/06), not proof of dead code. 4. Every claim carries its fidelity level (L0–L3, docs/06) and provenance. ## 3. The joint workflow: Ghidra, risc-v-gsp-rm-debugger, sessions ``` question > Ghidra (static): decompile, dispatcher tables, symbols > risc-v-gsp-rm-debugger (dynamic): same stimulus, hooks, watchpoints, trace > sessions + hits TSV: risc-v-gsp-rm-debugger compare -> triage -> evidence pack > vgsp change (with evidence) ``` | Question | Ghidra answers | risc-v-gsp-rm-debugger answers | Shared artifact | |---|---|---|---| | What is this code? | decompilation, call graph, xrefs | — | symbol DB (docs/05) | | Did it execute here? | hypothesis from xrefs | `bp_hit`/`watch_hit` events | `greeneye_hits.tsv` | | What changed between versions? | corpus/symbol diff | `compare` hit/watch/write deltas | symbol DB per version + report | | What can this service touch? | object references | watchpoint/TCG access map | capability table | References: docs/05-GHIDRA-BRIDGE.md, docs/13-GHIDRA-CONTROL.md, docs/07-PORTABILITY-PLAYBOOKS.md. ## 4. Method catalogue Run all commands from `open-iov/debug/risc-v-gsp-rm-vm/`; angle-bracket placeholders are task-supplied (never hardcode private paths in docs). ### 4.1 Semantic diff across firmware versions (H4.1) Goal: separate "the code changed" from "the behaviour moved" before the emulation is touched. 1. Build a symbol DB per version. `ghidra/ExportForGreenEye.java` emits `addrnamesize`, which `symbols export --index` accepts: ```bash ./risc-v-gsp-rm-debugger.sh symbols export --index --bias 0 -o symbols-new.json ./risc-v-gsp-rm-debugger.sh symbols export --index --bias 0 -o symbols-old.json ``` 2. Probe each image for first-touch structure changes (docs/07 P1.2): ```bash ./risc-v-gsp-rm-debugger.sh probe --symbols symbols-old.json --record ./risc-v-gsp-rm-debugger.sh probe --symbols symbols-new.json --record ``` 3. Run the **same stimulus** against the re-hosted build of each version. Put it in a script so it is identical across runs: ```python # run_stimulus.py -- ./risc-v-gsp-rm-debugger.sh run run_stimulus.py --kernel import greeneye as ge s = ge.launch(kernel="", record=True, name="stim") ctrl = s.symbols.lookup("") # demo: gsp_ctrl s.watch(ctrl + 0x10, size=8, kind="w") s.run(duration=0.5) # settle/self-test s.memory.write_u64(ctrl + 0x20, 1) # mailbox PING s.memory.write_u64(ctrl + 0x28, 0x1234) s.run(duration=0.5) s.note("stimulus: PING 0x1234") s.close() ``` Demo control-block offsets are demo-specific; use the schema (`firmware/uproc_demo/gsp_ctrl.schema.json`) or SDK constants rather than hardcoding them (docs/12 §4). 4. Compare the sessions symbolically — breakpoint profile delta, watchpoint delta and written-page delta: ```bash ./risc-v-gsp-rm-debugger.sh compare dumps/ dumps/ --symbols ``` 5. Triage with a decision table rather than intuition: | Static code changed? | Hit/write profile changed? | Reading | |---|---|---| | no | no | unchanged on this stimulus | | no | yes | behaviour moved: inputs, ordering, environment or state | | yes | no | changed code off this stimulus path (or dead/decoy: §4.4) | | yes | yes | new logic on a live path — prioritise | | n/a | symbol never hit in either run | coverage gap: widen the stimulus first | 6. Annotate the result back into Ghidra (`greeneye_hits.tsv` + `MarkGreenEyeHits.java`). Behaviour that moved without a code change is usually a state/ordering fix for the emulation, not new logic. ### 4.2 Capability inference from watchpoints and TCG traces (H4.2) Goal: derive what a service/engine actually reads and writes, then compare it with the capability model the emulation assumes. 1. Instrument the object ranges the service owns (kinds `w`, `r`, `a`): ```python # capability_probe.py -- ./risc-v-gsp-rm-debugger.sh run capability_probe.py --kernel import greeneye as ge s = ge.launch(kernel="", record=True, name="caps") base = s.symbols.lookup("") for off in (0x0, 0x10, 0x20, 0x28, 0x38, 0x40): for kind in ("r", "w"): s.watch(base + off, size=8, kind=kind, callback=lambda h, k=kind: s.note("cap-" + k, pc=h.pc)) s.run(duration=1.0) s.close() ``` 2. For access coverage **between stops**, enable the TCG plugin with a PC filter for the service's load range: ```bash make -C qemu qemu-system-riscv64 -machine virt -m 256M -display none \ -serial file:/tmp/uart.log -bios none -kernel -S \ -gdb tcp:127.0.0.1:4444 \ -plugin qemu/greeneye-trace.so,path=/tmp/ge-trace.jsonl,start=0x80000000,end=0x81000000,exec=on ./risc-v-gsp-rm-debugger.sh attach --port 4444 --kernel --plugin ``` `start=`/`end=` bound the trace, `exec=on` adds per-instruction records and `path=` selects the JSONL output (`qemu/greeneye_trace.c`; qemu/README.md). Folding that JSONL into sessions is roadmap R4 — today it is a separate evidence blob. 3. Reduce to a capability table and compare with the emulation's model: | Observed access | Address/range | Kind | PC/symbol | Emulated capability | Finding | |---|---|---|---|---|---| | write | `0x…` | w | `func+0x…` | not modelled | least-privilege candidate | | read | `0x…` | r | `func+0x…` | denied in emulation | potential fail-closed gap | Least-privilege readings matter for `vgsp`, but a simulator observation is evidence about the firmware (docs/06 §6); confirm before it changes the product. ### 4.3 Mutation of construction/control inputs (H4.3) Goal: drive error paths and asserts deliberately, capture them with symbols, and build a corpus for the emulation's fail-closed rules. The full harness is roadmap R5; until then, bounded mutation over existing primitives is honest. 1. Mutate single fields deterministically and record each attempt: ```python # mutate.py -- ./risc-v-gsp-rm-debugger.sh run mutate.py --kernel import greeneye as ge s = ge.launch(kernel="", record=True, name="mutate") ctrl = s.symbols.lookup("") for value in (0, 1, 0xFFFFFFFFFFFFFFFF, 0x8000000000000000): s.memory.write_u64(ctrl + 0x20, value) s.run(duration=0.2, max_stops=200) s.note("mutation", field="mailbox", value=value) s.close() ``` 2. Mutate injected-service calls on both paths (direct and mailbox): ```python from greeneye.inject import load_service, invoke_remote, invoke_via_firmware svc = load_service(s, "sdk/build/injected_demo.elf", slot=6) invoke_remote(s, svc, cmd=0xFFFF, a0=0, a1=0) # malformed cmd s.run(duration=0.3) invoke_via_firmware(s, svc, cmd=0xFFFF, a0=0, a1=0) # malformed mailbox ``` 3. Capture error paths with hooks/watchpoints on the dispatcher, fault handlers and assert sites (symbol DB); `./risc-v-gsp-rm-debugger.sh demo` records a trap. 4. Compare clean vs mutated sessions, keeping the mutation value in a `note` so the pack is self-describing: ```bash ./risc-v-gsp-rm-debugger.sh compare dumps/ dumps/ --symbols ``` Every corpus entry needs the firmware hash; never run mutation against hardware (docs/12 §9). ### 4.4 Decoy and anti-analysis handling (H4.4) Goal: distinguish signal from noise when the image branches on environment, timing or integrity state, or contains bogus code paths. 1. **Deterministic replay first.** Run the identical stimulus twice and require the comparison to be empty: ```bash ./risc-v-gsp-rm-debugger.sh compare dumps/ dumps/ --symbols ``` Any delta is a non-determinism candidate (scheduler, uninitialised read, time); record it. 2. **Two-run differential.** Flip one input bit; attribute every delta to it; unrelated movement suggests a time/integrity check: ```python s.memory.write_u64(ctrl + 0x28, original ^ (1 << 0)) # one bit ``` 3. **Coverage-based dead-code detection.** Use the TCG trace with `exec=on` (or a bounded `s.step(n)` pass) to build a PC set, then annotate it into Ghidra: ```python from greeneye.ghidra import export_hits from greeneye.symbols import SymbolDB db = SymbolDB(); db.load_json("") export_hits("dumps/", "greeneye_hits.tsv", symbols=db) ``` ```bash $GHIDRA/support/analyzeHeadless -process -noanalysis \ -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ -postScript MarkGreenEyeHits.java /abs/path/greeneye_hits.tsv ``` Zero hits across every stimulus makes a function a **decoy candidate**, not proof of dead code (docs/06). 4. **Trap-on-register-read experiments.** Arm a read watchpoint on a register the firmware might be probing, and record which PC reads it: ```python s.watch("", size=8, kind="r", callback=lambda hit: s.note("anti-analysis-read", pc=hit.pc)) ``` On today's `qemu virt` an unmodelled GPU register faults rather than returning a value (`./risc-v-gsp-rm-debugger.sh probe` reports that first read, docs/06 §3); the value-return experiment waits on the H2 device model — log, don't claim. 5. Maintain the catalogue of observed techniques in docs/09-GSP-FIRMWARE-NOTES.md (roadmap H4.4), one session pair per technique. ### 4.5 Differential execution: reference vs simulator vs injected service (H4.5) Goal: measure, not narrate. The contract is the M1–M6 table from docs/06: | # | Metric | How it is measured here | |---|---|---| | M1 | Inventory coverage | observed controls/RPCs vs emulated handled set | | M2 | Reply agreement | field-by-field reply diff (canonical size, consumed fields) | | M3 | Ordering agreement | sequence alignment of the control/event stream (LCS/edit distance) | | M4 | State agreement | firmware state transitions vs emulation state model | | M5 | Error-path agreement | injected faults take the same status/notifier path | | M6 | Memory side-effects | read/write map vs emulation shadow state; snapshot hashes | Procedure: (1) capture the reference on hardware/VFIO (docs/07 P4); (2) capture the simulator side with `record`/scripted `run`, hooks, watchpoints and snapshots; (3) `./risc-v-gsp-rm-debugger.sh compare dumps/ dumps/ --symbols`, with a script or plugin for per-control M2/M3 payload events; (4) repeat with the injected-service path to test the service boundary; (5) escalate — divergence that would change emulation behaviour must be reproduced physically; simulator-only divergence becomes a fidelity gap. ### 4.6 From observation to reimplementation The canonical accuracy loop (roadmap H4.5): (1) observe on hardware/VFIO; (2) observe in risc-v-gsp-rm-debugger (replayable session); (3) `compare` + M1–M6; (4) implement in `vgsp` with the session directories attached as evidence; (5) re-run the same stimulus and require the metric table to improve or stay equal. Practical rules: one session pair per behaviour change; the PR links the `compare` report and session hashes plus the symbol DB diff if the static map changed; decoy or low-fidelity observations never justify a product change. ## 5. Ghidra integration specifics Start the live bridge and drive it from the decompiler: ```bash ./risc-v-gsp-rm-debugger.sh ghidra --kernel --bridge-port 7000 $GHIDRA/support/analyzeHeadless -process -noanalysis \ -scriptPath debug/risc-v-gsp-rm-vm/ghidra \ -postScript GreenEyeControl.java 7000 0x ``` `GreenEyeControl.java` operations (docs/13): `setBreakpointAt/Here`, `runUntil/Here`, `readMemoryHere`, `readRegister`/`writeRegister`, `callFunction`, `snapshotHere`, `listHits`, `exportHits`. Watchpoints exist on the bridge (`watch_set`) without a Java wrapper: `send("watch_set", {...})`. Address conventions and bias pitfalls (docs/05 §1): * Ghidra shows **program addresses**; risc-v-gsp-rm-debugger resolves **runtime addresses**. For firmware linked at its load address they coincide; otherwise supply `--bias` when building the symbol DB (`ExportForGreenEye.java` emits program addresses). * The classic mistake is passing a program address to `hook()` when the image base is non-zero; prefer `s.hook("")` and pass raw numbers through `resolve`. `decompiled_index.tsv` carries both spaces; risc-v-gsp-rm-debugger keeps the program address in `symbol.extras["program_addr"]` for `bucket:line` mapping. * Wrapper-generated functions are re-laid-out between versions: transfer names, never raw wrapper addresses (docs/05 §6). * Bridge `run_until` timeouts mean "not executed under this stimulus at this fidelity"; they do not prove unreachability. ## 6. Metrics, evidence pack and report template A finding is only a finding when the pack is complete. The evidence pack for one adversarial-navigation task contains: * [ ] firmware identity (version, hash, pairing) and symbol DB(s) with provenance and hash; * [ ] baseline/candidate session dirs (or sha256 inventory) and the exact stimulus; * [ ] `compare` report and relevant `analyze` reports; * [ ] M1–M6 table with the reference-capture method and explicit L2/L3 limits; * [ ] `greeneye_hits.tsv` plus any Ghidra annotations produced; * [ ] unexplained-delta list (empty is a claim, not a default). Short report template: ```markdown # Firmware delta: -> () * firmware: -> ; symbol DB: -> (provenance: , bias ) * sessions: , (greeneye-session/1); stimulus: