Files
lofi-12xt-firmware/RE-PROCESS.md
T
2026-09-30 22:48:06 +02:00

7.2 KiB
Raw Blame History

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, 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+).