Files
2026-09-30 22:48:06 +02:00

117 lines
7.2 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Lofi-12 XT — RE Process & Tips
How the findings in `FINDINGS.md` were obtained, so nothing starts from scratch.
Golden rules: read-only first; two dumps + `cmp` before trusting anything; keep a
known-good stock SD for revert; never touch SPI flash or the AT32 unless recovery
is drilled and dumped.
## 1. SPI dumping (checklist — full guide: `docs/flash-dumping.md`)
Back up IC300 + IC301 before anything else; dumps stay local, never shipped.
Read-only, twice per chip, `sha256sum` + `cmp`, 16,777,216 B each; never
`-w`/`-E` until dumps verify. Watch for: stickers hiding markings (peel/photo),
black-CH341a 5 V signals (meter + 3.3 V mod), `5523`/UART vs `5512`/SPI jumper,
`errno=13` (udev/host execution — `distrobox-host-exec` from containers),
exact `flashrom -c` name. Sanity: `TIPA` at IC300+0, `AILS`/`RIFF` on IC301.
## 2. Firmware triage (first hour, no disassembly)
- `ls -l`, `sha256sum`, `xxd | head` (magic), `strings -n 6 | head`.
- Set-subtraction oracle: `strings -n 6` sets of SD image vs flash dump —
`onlySD == 0` against v1.5.205 proved the board's version (see `rev-diff.md`
for the v1.1→v1.2→v1.5 marker families).
- Opcode histogram for `xx 59 53 58` (AIS `0x585359xx` family): `01` = Section
Load, `06` = JumpClose — instant AIS map. Entrypoint disassembles under
C64x-LE to the reset prelude (`ZERO b0; mvc b0,ier; …`); ARM decode of the same
bytes is garbage → DSP-confirmed in seconds.
- `tools/lofi_image.py` parses/verifies the SD container (filesize + sect-chain
fit); `tools/ais_unpack.py` splits the AIS dump into DDR-addressed segments.
## 3. Headless Ghidra + ghidra-c6000 lab (what actually worked)
- Needs: Temurin JDK 21, Ghidra 12.1.3
(`ghidra_12.1.3_PUBLIC_20260817.zip`, sha256 `93a5d11a…`), extension
`ghidra_12.1.3_PUBLIC_20260925_C6000.zip` (sha256 `ad44169d…`,
[geepot/ghidra-c6000](https://github.com/geepot/ghidra-c6000), targets Ghidra
12.1.x, language `C6000:LE:32:default`). Versions must match — ext is tied to
the Ghidra build. Keep all of it in `/tmp/opencode/ghidra-lab` (outside repo).
- `unzip`/`python -m zipfile` extraction drops exec bits → `chmod +x` all
`*.sh`, `analyzeHeadless`, `ghidraRun*`, `*decompile*`, `launch*` or nothing runs.
- **`.java`/`.py` headless scripts do NOT run** in this setup
(`Failed to get OSGi bundle containing script` — affects even the extension's
own `C6000SetEntry.java`, any directory). Don't fight it.
- **Working path: PyGhidra** (`pip install pyghidra` in the project venv;
`GHIDRA_INSTALL_DIR` + `JAVA_HOME` set). Full API, no script providers:
`open_program(binary, language="C6000:LE:32:default", analyze=False)` →
`memory.createInitializedBlock(name, addr, InputStream, len, TaskMonitor.DUMMY,
False)` per AIS segment (exact overloads surface via the `TypeError` message —
read it, it lists signatures) → `flat.disassemble(entry)` + `createFunction`
→ `flat.analyzeAll(program)` → `FlatDecompilerAPI(flat).decompile(fn)`
(returns the C string directly). Drivers: `/tmp/opencode/ghidra-lab/pyais*.py`.
- Auto-analysis creates almost no C6000 functions (VLIW flow) — force
`disassemble()` at targets, then `createFunction()`. `getReferencesTo()` is
empty for plain-`b` calls; find callers by scanning raw bytes for branch
targets instead. Dense-seed disassembly (every 8 B over a range) before dumping.
- Read `out/dis_*.txt` (predicated, packet-aware — far better than capstone) and
`out/dec_*.txt`. Project persists at `/tmp/opencode/ghidra-lab/pyproj`
(nested `pyproj/LofiPy/LofiPy.gpr`) for follow-up passes.
## 4. C6000 static-analysis notes (C674x, LE)
- Disassemble in 8-byte fetch packets; `CPKT`/`SPLOOP(W)`/`SPMASK`/`SPKERNEL`
markers matter. Capstone `CS_ARCH_TMS320C64X` is fine for triage but misdecodes
compact packets and hides predicates — confirm odd bytes in Ghidra.
- **Delay slots always execute**: `B`/5 slots, `CALLP`/6 slots. Args/returns are
set in delay slots *before* the callee runs (e.g. `MV A10,A4` after a `CALLP`
feeds the callee, not the code after return). Never read dataflow linearly.
- Calls: `B addr` (near), `CALLP addr,B3`, `BNOP reg` (virtual — target from a
vtable word, e.g. `LDW *+A3[2],B5`), `ADDKPC` sets the return. Returns:
`BNOP B3` (+ work hidden in its delay slots, e.g. final `NOT`).
- String refs are usually **not** absolute pointers: `MVK low + MVKH 0xC70A`
pairs, or `ADDKPC target,reg`. To find who uses a string, search for its
`MVK low16` (e.g. `ERROR` at `C70A8E82` ← `MVK -0x717E` + `MVKH -0x38F6`).
- ABI (TI C6000 EABI): args `A4,B4,A6,B6,…`, return `A4`, link `B3`, stack
`B15`/`A15` (`--` = push/prologue, `++` = pop/epilogue). `A10–A15/B10–B15`
callee-saved (survive calls — trace them across `CALLP`); `A0–A9/B0–B9` scratch.
`*++SP[n]` loads in epilogues are noise — filter non-SP bases when hunting
header parsers (`LDW *+Rn[1]/[2]` on the same non-SP reg ≈ struct+4/+8).
- CRC32-IEEE bitwise shape: `MVK 0x8320 + MVKH 0xEDB8` (poly), `LDBU *ptr++`,
`XOR`, 8× `AND 1 / SHRU 1 / [pred]XOR poly`, counter `SUB`, back-edge `B`;
`NOT` at entry (init) and in return delay slot (xorout) ⇒ zlib chaining
semantics `F(buf,len,init)`, so chunked ≡ whole. `DINT/RINT` around loops =
flash/SD critical section (update path smell).
- Decompiler limits (per ext docs): delay slots unmodelled, const-prop bounded
to 512 B, stack naming unreliable after `SUBAW`/push mixes — always verify
hot addresses against raw disassembly + file offsets.
## 5. Checksum-cracking playbook (what cracked it)
1. Rule out standard families first over spans
(`full/from_0x04/0x0C/0x30/0x54/payload`, DDR-sorted, addr+len+payload) ×
inits × field-modes (checksum/filesize/flags zeroed/ff/asis) —
`tools/lofi_checksum*.py`. All failed here.
2. Kill whole classes mathematically instead of brute-forcing: the affine test
`(C1^C2)==(S1^S2)` over span pairs disproves *all* (seed, xor-mask) pairs for
CRC-32 at once. Check for a 256-word CRC table in-flash (linearity scan).
3. Isolate the routine from code (string builders → check fn → loop), confirm
semantics (poly/init/final/chaining), then enumerate the *remaining* unknowns
(here: 16-byte header + first init).
4. **GF(2) solve, don't guess:** CRC is affine — one image's 32-bit equality is
32 linear constraints. Build columns via single-bit messages
(`CRC(single_bit) ^ CRC(zeros)`), Gaussian-eliminate, solve per image, and
demand the *same* constant from every image. Here all three gave
`0xC27C6282`, which also appears literally in the checker
(`MVK 0x6282/MVKH 0xC27C`, DDR scratch `*0xC27C6282`) — done.
5. Forge = compute over content with unknowns fixed, store result; verify by
re-check + `unpack→pack` byte-identity on stock images.
## 6. Mod workflow (SD-only, OLED as oracle)
1. Level-0: `lofi_patch_string.py stock.bin mod.bin Old New` (equal length!) —
checksum auto-computed (`lofi_image.compute_checksum`).
2. Copy to SD root as `Lofi-12 XT.bin`, hold PAD while powering on, read OLED
(`Checking…/Writing…/Verifying…/100%` vs `ERROR : This file is invalid.`).
3. Confirm the UI change on-device; keep stock SD for instant revert.
4. Escalate only after the loop is proven: xref-guided constant patches (Level-1),
then Ghidra-anchored branch/code-cave patches (Level-2+).