7.2 KiB
7.2 KiB
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 6sets of SD image vs flash dump —onlySD == 0against v1.5.205 proved the board's version (seerev-diff.mdfor the v1.1→v1.2→v1.5 marker families). - Opcode histogram for
xx 59 53 58(AIS0x585359xxfamily):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.pyparses/verifies the SD container (filesize + sect-chain fit);tools/ais_unpack.pysplits 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, sha25693a5d11a…), extensionghidra_12.1.3_PUBLIC_20260925_C6000.zip(sha256ad44169d…, geepot/ghidra-c6000, targets Ghidra 12.1.x, languageC6000: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 zipfileextraction drops exec bits →chmod +xall*.sh,analyzeHeadless,ghidraRun*,*decompile*,launch*or nothing runs..java/.pyheadless scripts do NOT run in this setup (Failed to get OSGi bundle containing script— affects even the extension's ownC6000SetEntry.java, any directory). Don't fight it.- Working path: PyGhidra (
pip install pyghidrain the project venv;GHIDRA_INSTALL_DIR+JAVA_HOMEset). 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 theTypeErrormessage — 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, thencreateFunction().getReferencesTo()is empty for plain-bcalls; 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) andout/dec_*.txt. Project persists at/tmp/opencode/ghidra-lab/pyproj(nestedpyproj/LofiPy/LofiPy.gpr) for follow-up passes.
4. C6000 static-analysis notes (C674x, LE)
- Disassemble in 8-byte fetch packets;
CPKT/SPLOOP(W)/SPMASK/SPKERNELmarkers matter. CapstoneCS_ARCH_TMS320C64Xis 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,A4after aCALLPfeeds 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),ADDKPCsets the return. Returns:BNOP B3(+ work hidden in its delay slots, e.g. finalNOT). - String refs are usually not absolute pointers:
MVK low + MVKH 0xC70Apairs, orADDKPC target,reg. To find who uses a string, search for itsMVK low16(e.g.ERRORatC70A8E82←MVK -0x717E+MVKH -0x38F6). - ABI (TI C6000 EABI): args
A4,B4,A6,B6,…, returnA4, linkB3, stackB15/A15(--= push/prologue,++= pop/epilogue).A10–A15/B10–B15callee-saved (survive calls — trace them acrossCALLP);A0–A9/B0–B9scratch.*++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, counterSUB, back-edgeB;NOTat entry (init) and in return delay slot (xorout) ⇒ zlib chaining semanticsF(buf,len,init), so chunked ≡ whole.DINT/RINTaround 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)
- 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. - 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). - 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).
- 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 gave0xC27C6282, which also appears literally in the checker (MVK 0x6282/MVKH 0xC27C, DDR scratch*0xC27C6282) — done. - Forge = compute over content with unknowns fixed, store result; verify by
re-check +
unpack→packbyte-identity on stock images.
6. Mod workflow (SD-only, OLED as oracle)
- Level-0:
lofi_patch_string.py stock.bin mod.bin Old New(equal length!) — checksum auto-computed (lofi_image.compute_checksum). - Copy to SD root as
Lofi-12 XT.bin, hold PAD while powering on, read OLED (Checking…/Writing…/Verifying…/100%vsERROR : This file is invalid.). - Confirm the UI change on-device; keep stock SD for instant revert.
- Escalate only after the loop is proven: xref-guided constant patches (Level-1), then Ghidra-anchored branch/code-cave patches (Level-2+).