Init
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
# Lofi-12 XT custom-firmware tools
|
||||
|
||||
Stdlib-only Python (`tools/`). Verified against v1.1.156 / v1.2.179 / v1.5.205.
|
||||
|
||||
| Script | Purpose |
|
||||
|---|---|
|
||||
| `lofi_image.py` | Shared parser/packer library (import, not CLI) |
|
||||
| `lofi_unpack.py` | `image.bin outdir/` — verify chain, dump `headers.json` + `sectN_addr*.bin` |
|
||||
| `lofi_pack.py` | `indir/ out.bin [--checksum HEX]` — rebuild exact-fit image |
|
||||
| `lofi_patch_string.py` | Same-length UI string swap (Level-0 mod, addresses stable) |
|
||||
| `lofi_xref.py` | Count code/self pointers, list slack zero-gaps, locate string + refs |
|
||||
| `lofi_checksum.py` | Quick check: CRC/adler/fletcher/sum/xor over obvious spans |
|
||||
| `lofi_checksum_crack.py` | Long-running cracker: Phase A fast hashes + xor-mask detect, Phase B generic CRC param search (1,008 combos, multiprocessed), Phase C seeded CRC32-IEEE brute force 0..2²⁴ (hours) |
|
||||
|
||||
## Safe first loop (do not flash until checksum is cracked)
|
||||
|
||||
```bash
|
||||
python3 tools/lofi_unpack.py "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" /tmp/opencode/fw/unpack-1.5.205
|
||||
python3 tools/lofi_pack.py /tmp/opencode/fw/unpack-1.5.205 /tmp/opencode/fw/rebuilt.bin
|
||||
cmp "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" /tmp/opencode/fw/rebuilt.bin # must be identical
|
||||
python3 tools/lofi_patch_string.py "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" /tmp/opencode/fw/patched.bin Threshold ThresholX
|
||||
python3 tools/lofi_xref.py "Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin" --find "AUDIO EXPORT"
|
||||
python3 tools/lofi_checksum.py
|
||||
```
|
||||
|
||||
## Cracking the checksum (the blocker)
|
||||
|
||||
```bash
|
||||
# smoke tests (seconds):
|
||||
python3 tools/lofi_checksum_crack.py --quick-only
|
||||
python3 tools/lofi_checksum_crack.py --jobs 2 --limit 40 --out /tmp/opencode/ck-smoke.txt
|
||||
|
||||
# full long run (Phase B ~minutes, Phase C ~hours, all cores):
|
||||
python3 tools/lofi_checksum_crack.py --jobs $(nproc) --out /tmp/opencode/ck-results.txt
|
||||
|
||||
# Phase D batch — CRC-16 family, ones-complement, chained per-sect, FNV-1/DJB2/Murmur (~1 min):
|
||||
python3 tools/lofi_checksum_phaseD.py --jobs $(nproc) --out /tmp/opencode/ck-phaseD.txt
|
||||
```
|
||||
|
||||
Results (exact or constant-xor-mask hits) append to the `--out` file;
|
||||
progress checkpoints go to `--out.progress`. Any hit must match **all 3**
|
||||
builds to be reported. Re-run with `--seed-max 4294967296` for the full
|
||||
2³² seed space only if 2²⁴ finds nothing.
|
||||
|
||||
## Blockers / rules
|
||||
|
||||
- `cmtd+0x08` checksum: **solved** — `lofi_image.compute_checksum`
|
||||
(CRC32-IEEE, init 0, over the image with bytes `[8:12]` replaced by
|
||||
`0xC27C6282`; proven 3/3 against stock images). `lofi_pack` and
|
||||
`lofi_patch_string` apply it automatically.
|
||||
- Keep a known-good stock `.bin` on SD for revert; never touch SPI flash / USB MCU.
|
||||
- Distribute mods as patches against user-supplied stock `.bin`, not full images.
|
||||
- See `tweakability-report.md` (levels 0–4) and `rev-diff.md` for what is feasible.
|
||||
Executable
+112
@@ -0,0 +1,112 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Brute-force checksum hypotheses for cmtd+0x08 against all known images.
|
||||
|
||||
Tests CRC32/zlib/adler/fletcher/sum/xor variants over multiple spans.
|
||||
A hypothesis must match ALL builds to be reported as candidate.
|
||||
Stdlib only.
|
||||
"""
|
||||
import binascii, os, struct, sys, zlib
|
||||
|
||||
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
IMAGES = [
|
||||
'Lofi-12XT_v1.1.156/Lofi-12XT_v1.1.156/Lofi-12 XT.bin',
|
||||
'Lofi-12XT_v1.2.179/Lofi-12XT_v1.2.179/Lofi-12 XT.bin',
|
||||
'Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin',
|
||||
]
|
||||
|
||||
def fletcher16(data):
|
||||
s1 = s2 = 0
|
||||
for b in data:
|
||||
s1 = (s1 + b) % 255
|
||||
s2 = (s2 + s1) % 255
|
||||
return (s2 << 8) | s1
|
||||
|
||||
def fletcher32(data):
|
||||
s1 = s2 = 0
|
||||
for i in range(0, len(data), 2):
|
||||
w = data[i] | (data[i+1] << 8 if i+1 < len(data) else 0)
|
||||
s1 = (s1 + w) % 0xFFFF
|
||||
s2 = (s2 + s1) % 0xFFFF
|
||||
return (s2 << 16) | s1
|
||||
|
||||
def sum32(data):
|
||||
return sum(data) & 0xFFFFFFFF
|
||||
|
||||
def xor32(data):
|
||||
r = 0
|
||||
for i in range(0, len(data) - 3, 4):
|
||||
(w,) = struct.unpack('<I', data[i:i+4])
|
||||
r ^= w
|
||||
return r & 0xFFFFFFFF
|
||||
|
||||
def load():
|
||||
out = []
|
||||
for rel in IMAGES:
|
||||
p = os.path.join(BASE, rel)
|
||||
d = open(p, 'rb').read()
|
||||
ck = struct.unpack('<I', d[8:12])[0]
|
||||
out.append((rel.split('/')[0], d, ck))
|
||||
return out
|
||||
|
||||
def spans(d):
|
||||
nsect = struct.unpack('<I', d[0x50:0x54])[0]
|
||||
off = 0x54
|
||||
payloads = b''
|
||||
for _ in range(nsect):
|
||||
ln = struct.unpack('<I', d[off+8:off+12])[0]
|
||||
payloads += d[off+12:off+12+ln]
|
||||
off += 12 + ln
|
||||
z8 = bytearray(d); z8[8:12] = b'\0\0\0\0'
|
||||
return {
|
||||
'full': d,
|
||||
'from_0x04': d[0x04:],
|
||||
'from_0x0C': d[0x0C:],
|
||||
'from_0x30': d[0x30:],
|
||||
'from_0x54': d[0x54:],
|
||||
'payload_concat': payloads,
|
||||
'full_ck_zeroed': bytes(z8),
|
||||
'from0x0C_ck_zeroed': bytes(z8)[0x0C:],
|
||||
}
|
||||
|
||||
def main():
|
||||
blobs = load()
|
||||
for name, d, ck in blobs:
|
||||
print(f"{name}: size={len(d)} stored_ck={ck:08X}")
|
||||
algs = {
|
||||
'crc32_le': lambda b: binascii.crc32(b) & 0xFFFFFFFF,
|
||||
'crc32_be_byteswap': lambda b: struct.unpack('>I', struct.pack('<I', binascii.crc32(b) & 0xFFFFFFFF))[0],
|
||||
'crc32_complement': lambda b: (~binascii.crc32(b)) & 0xFFFFFFFF,
|
||||
'adler32': lambda b: zlib.adler32(b) & 0xFFFFFFFF,
|
||||
'fletcher16': fletcher16,
|
||||
'fletcher32': fletcher32,
|
||||
'sum32': sum32,
|
||||
'xor32': xor32,
|
||||
}
|
||||
cands = []
|
||||
total = 0
|
||||
for aname, fn in algs.items():
|
||||
for sname in spans(blobs[0][1]):
|
||||
total += 1
|
||||
ok = True
|
||||
for _, d, ck in blobs:
|
||||
try:
|
||||
v = fn(spans(d)[sname]) & 0xFFFFFFFF
|
||||
except Exception:
|
||||
ok = False
|
||||
break
|
||||
if v != ck:
|
||||
ok = False
|
||||
break
|
||||
status = 'MATCH-ALL' if ok else 'no'
|
||||
if ok:
|
||||
cands.append((aname, sname))
|
||||
# show near-misses? only print matches to stay concise
|
||||
if ok:
|
||||
print(f" {aname} x {sname}: {status}")
|
||||
print(f"tested {total} hypotheses, {len(cands)} full-match candidates")
|
||||
if not cands:
|
||||
print("No match: checksum is not plain CRC32/adler/fletcher/sum/xor over obvious spans.")
|
||||
print("Next: try seeded CRC, TI-AIS style, per-sect accumulate, or mmtd.flags correlation.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+338
@@ -0,0 +1,338 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Long-running checksum cracker for Lofi-12 XT cmtd+0x08.
|
||||
|
||||
Strategy: 3 known (image, checksum) pairs let us test each hypothesis fast
|
||||
with early exit, plus detect CONSTANT-XOR-masked CRCs (stored = crc ^ mask).
|
||||
|
||||
Phases:
|
||||
A (seconds): zlib-speed hashes (crc32/adler/word-sums/fnv) x spans x field-modes.
|
||||
B (long): generic table-driven CRC32 parameter search
|
||||
(poly x init x xorout x refin x span x field-mode),
|
||||
multiprocessed, checkpointed, resumable.
|
||||
|
||||
Usage (long run):
|
||||
python3 tools/lofi_checksum_crack.py --jobs $(nproc) --out /tmp/opencode/ck-results.txt
|
||||
|
||||
Quick smoke test:
|
||||
python3 tools/lofi_checksum_crack.py --quick-only
|
||||
python3 tools/lofi_checksum_crack.py --jobs 2 --limit 40 --out /tmp/opencode/ck-smoke.txt
|
||||
|
||||
Stdlib only. Safe: read-only on stock .bin files.
|
||||
"""
|
||||
import argparse, binascii, itertools, json, multiprocessing as mp
|
||||
import os, struct, sys, time, zlib
|
||||
|
||||
BASE = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||
IMAGES = [
|
||||
('1.1.156', 'Lofi-12XT_v1.1.156/Lofi-12XT_v1.1.156/Lofi-12 XT.bin'),
|
||||
('1.2.179', 'Lofi-12XT_v1.2.179/Lofi-12XT_v1.2.179/Lofi-12 XT.bin'),
|
||||
('1.5.205', 'Lofi-12XT_v1.5.205/Lofi-12XT_v1.5.205/Lofi-12 XT.bin'),
|
||||
]
|
||||
|
||||
POLYS = [ # normal (non-reflected) form
|
||||
0x04C11DB7, # IEEE / PKZIP
|
||||
0x1EDC6F41, # CRC-32C (Castagnoli)
|
||||
0x741B8CD7, # CRC-32K (Koopman)
|
||||
0x1A2B3E55, # spare / nonstandard probes
|
||||
0x814141AB, # CRC-32Q
|
||||
0x000000AF, # tiny-poly probe (catches nibble-CRC schemes fast-fail)
|
||||
]
|
||||
INITS = [0x00000000, 0xFFFFFFFF]
|
||||
XOROUTS = [0x00000000, 0xFFFFFFFF]
|
||||
REFINS = [True, False]
|
||||
FIELDMODES = ['asis', 'zeroed', 'ff'] # how cmtd+0x08 bytes are treated during hashing
|
||||
SPANS = ['full', 'from_0x0C', 'from_0x30', 'from_0x54', 'payload_concat',
|
||||
'headers_only', 'sect_headers_mixed']
|
||||
|
||||
def load_images():
|
||||
blobs = []
|
||||
for ver, rel in IMAGES:
|
||||
p = os.path.join(BASE, rel)
|
||||
d = open(p, 'rb').read()
|
||||
assert d[:4] == b'cmtd' and d[0x30:0x34] == b'mmtd', p
|
||||
ck = struct.unpack('<I', d[8:12])[0]
|
||||
blobs.append((ver, d, ck))
|
||||
return blobs
|
||||
|
||||
def span_bufs(d: bytes):
|
||||
nsect = struct.unpack('<I', d[0x50:0x54])[0]
|
||||
off = 0x54
|
||||
pay = bytearray()
|
||||
hdrs = bytearray()
|
||||
for _ in range(nsect):
|
||||
assert d[off:off+4] == b'sect'
|
||||
ln = struct.unpack('<I', d[off+8:off+12])[0]
|
||||
hdrs += d[off:off+12]
|
||||
pay += d[off+12:off+12+ln]
|
||||
off += 12 + ln
|
||||
z = bytearray(d); z[8:12] = b'\0\0\0\0'
|
||||
f = bytearray(d); f[8:12] = b'\xff\xff\xff\xff'
|
||||
return {
|
||||
'full': [d, bytes(z), bytes(f)],
|
||||
'from_0x0C': [d[0x0C:], bytes(z)[0x0C:], bytes(f)[0x0C:]],
|
||||
'from_0x30': [d[0x30:], d[0x30:], d[0x30:]],
|
||||
'from_0x54': [d[0x54:], d[0x54:], d[0x54:]],
|
||||
'payload_concat': [bytes(pay), bytes(pay), bytes(pay)],
|
||||
'headers_only': [d[:0x54], bytes(z)[:0x54], bytes(f)[:0x54]],
|
||||
'sect_headers_mixed': [bytes(hdrs), bytes(hdrs), bytes(hdrs)],
|
||||
}
|
||||
_FIELDMODE_IDX = {'asis': 0, 'zeroed': 1, 'ff': 2}
|
||||
|
||||
# ---------- generic CRC32 (table-driven, pure python) ----------
|
||||
_crc_tables = {}
|
||||
def crc_table(poly, refin):
|
||||
key = (poly, refin)
|
||||
t = _crc_tables.get(key)
|
||||
if t is not None:
|
||||
return t
|
||||
t = []
|
||||
if refin:
|
||||
rpoly = int(f'{poly:032b}'[::-1], 2)
|
||||
for i in range(256):
|
||||
c = i
|
||||
for _ in range(8):
|
||||
c = (c >> 1) ^ rpoly if c & 1 else c >> 1
|
||||
t.append(c & 0xFFFFFFFF)
|
||||
else:
|
||||
for i in range(256):
|
||||
c = i << 24
|
||||
for _ in range(8):
|
||||
c = ((c << 1) ^ poly) & 0xFFFFFFFF if c & 0x80000000 else (c << 1) & 0xFFFFFFFF
|
||||
t.append(c)
|
||||
_crc_tables[key] = t
|
||||
return t
|
||||
|
||||
def crc_generic(buf: bytes, poly, init, refin, xorout):
|
||||
tab = crc_table(poly, refin)
|
||||
crc = init
|
||||
if refin:
|
||||
for b in buf:
|
||||
crc = tab[(crc ^ b) & 0xFF] ^ (crc >> 8)
|
||||
else:
|
||||
for b in buf:
|
||||
crc = tab[((crc >> 24) ^ b) & 0xFF] ^ ((crc << 8) & 0xFFFFFFFF)
|
||||
return (crc ^ xorout) & 0xFFFFFFFF
|
||||
|
||||
def fnv1a32(buf: bytes):
|
||||
h = 0x811C9DC5
|
||||
for b in buf:
|
||||
h = ((h ^ b) * 0x01000193) & 0xFFFFFFFF
|
||||
return h
|
||||
|
||||
def wordsum_le(buf: bytes):
|
||||
s = 0
|
||||
for i in range(0, len(buf) - 3, 4):
|
||||
(w,) = struct.unpack('<I', buf[i:i+4])
|
||||
s = (s + w) & 0xFFFFFFFF
|
||||
return s
|
||||
|
||||
# ---------- phase A: fast hashes ----------
|
||||
def phase_a(blobs):
|
||||
print('=== Phase A: fast hashes (crc32/adler/fnv/wordsum) ===', flush=True)
|
||||
cands = []
|
||||
for span in SPANS:
|
||||
bufs = [(ver, span_bufs(d)[span], ck) for ver, d, ck in blobs]
|
||||
tests = {
|
||||
'crc32': lambda b: binascii.crc32(b) & 0xFFFFFFFF,
|
||||
'crc32_bswap': lambda b: struct.unpack('>I', struct.pack('<I', binascii.crc32(b) & 0xFFFFFFFF))[0],
|
||||
'adler32': lambda b: zlib.adler32(b) & 0xFFFFFFFF,
|
||||
'fnv1a32': fnv1a32,
|
||||
'wordsum_le': wordsum_le,
|
||||
'sum_bytes': lambda b: sum(b) & 0xFFFFFFFF,
|
||||
}
|
||||
for aname, fn in tests.items():
|
||||
for fmode in _FIELDMODE_IDX:
|
||||
idx = _FIELDMODE_IDX[fmode]
|
||||
try:
|
||||
vals = [(ver, ck, fn(b[idx])) for ver, b, ck in bufs]
|
||||
except Exception as e:
|
||||
print(f' {aname} x {span} x {fmode}: error {e}')
|
||||
continue
|
||||
if all(v == ck for _, ck, v in vals):
|
||||
print(f' *** EXACT MATCH: {aname} x {span} x {fmode}')
|
||||
cands.append((aname, span, fmode, 0))
|
||||
masks = [ck ^ v for _, ck, v in vals]
|
||||
if masks[0] == masks[1] == masks[2]:
|
||||
print(f' --- xor-mask candidate: {aname} x {span} x {fmode} '
|
||||
f'mask={masks[0]:08X} (masked CRC, needs 1 confirmation)')
|
||||
cands.append((aname, span, fmode, masks[0]))
|
||||
if not cands:
|
||||
print('Phase A: no exact or xor-mask candidates.', flush=True)
|
||||
return cands
|
||||
|
||||
# ---------- phase B: generic CRC search ----------
|
||||
def all_params():
|
||||
for poly, init, xorout, refin, span, fmode in itertools.product(
|
||||
POLYS, INITS, XOROUTS, REFINS, SPANS, FIELDMODES):
|
||||
yield (poly, init, xorout, refin, span, fmode)
|
||||
|
||||
_G_BLOBS = None
|
||||
def _init_worker(blobs_packed):
|
||||
global _G_BLOBS
|
||||
_G_BLOBS = blobs_packed # list of (ver, span->bufs, ck); pickled once per worker
|
||||
|
||||
def _worker(param):
|
||||
poly, init, xorout, refin, span, fmode = param
|
||||
idx = _FIELDMODE_IDX[fmode]
|
||||
try:
|
||||
r = []
|
||||
for ver, sbufs, ck in _G_BLOBS:
|
||||
v = crc_generic(sbufs[span][idx], poly, init, refin, xorout)
|
||||
r.append((ver, ck, v))
|
||||
if r[0][2] == r[0][1] and r[1][2] == r[1][1] and r[2][2] == r[2][1]:
|
||||
return ('EXACT', param, 0)
|
||||
m0, m1, m2 = r[0][1] ^ r[0][2], r[1][1] ^ r[1][2], r[2][1] ^ r[2][2]
|
||||
if m0 == m1 == m2:
|
||||
return ('MASK', param, m0)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
def phase_b(blobs, jobs, out, limit=None, resume_from=0):
|
||||
params = list(all_params())
|
||||
if limit:
|
||||
params = params[:limit]
|
||||
total = len(params)
|
||||
print(f'=== Phase B: generic CRC search: {total} combos, jobs={jobs} ===', flush=True)
|
||||
# pack span buffers once (pickle to workers a single time)
|
||||
packed = [(ver, span_bufs(d), ck) for ver, d, ck in blobs]
|
||||
done = resume_from
|
||||
t0 = time.time()
|
||||
found = []
|
||||
with mp.Pool(jobs, initializer=_init_worker, initargs=(packed,)) as pool:
|
||||
CH = 8
|
||||
for i, res in enumerate(pool.imap_unordered(_worker, params, chunksize=CH), start=1):
|
||||
if i <= done:
|
||||
continue
|
||||
if res is not None:
|
||||
kind, param, mask = res
|
||||
poly, init, xorout, refin, span, fmode = param
|
||||
line = (f'{kind} poly={poly:08X} init={init:08X} xorout={xorout:08X} '
|
||||
f'refin={int(refin)} span={span} field={fmode} mask={mask:08X}')
|
||||
print(f' *** {line}', flush=True)
|
||||
found.append(line)
|
||||
with open(out, 'a') as fh:
|
||||
fh.write(line + '\n')
|
||||
if i % 50 == 0 or i == total:
|
||||
el = time.time() - t0
|
||||
rate = i / max(el, 1e-6)
|
||||
print(f' [{i}/{total}] {rate:.1f} combos/s elapsed={el:.0f}s', flush=True)
|
||||
with open(out + '.progress', 'w') as fh:
|
||||
fh.write(json.dumps({'done': i, 'total': total,
|
||||
'elapsed': el, 'found': found}) + '\n')
|
||||
el = time.time() - t0
|
||||
print(f'Phase B done: {total} combos in {el:.0f}s, {len(found)} candidates.', flush=True)
|
||||
return found
|
||||
|
||||
_G_SEED_BUFS = None
|
||||
_G_SEED_CKS = None
|
||||
|
||||
def _init_seed_worker(bufs, cks):
|
||||
global _G_SEED_BUFS, _G_SEED_CKS
|
||||
_G_SEED_BUFS = bufs
|
||||
_G_SEED_CKS = cks
|
||||
|
||||
def _seed_scan(task):
|
||||
lo, hi = task
|
||||
b1, b2, b3 = _G_SEED_BUFS
|
||||
s1, s2, s3 = _G_SEED_CKS
|
||||
hits = []
|
||||
for seed in range(lo, hi):
|
||||
if (binascii.crc32(b1, seed) & 0xFFFFFFFF) == s1:
|
||||
if ((binascii.crc32(b2, seed) & 0xFFFFFFFF) == s2 and
|
||||
(binascii.crc32(b3, seed) & 0xFFFFFFFF) == s3):
|
||||
hits.append(seed)
|
||||
return (lo, hi, hits)
|
||||
|
||||
def _seed_worker(args):
|
||||
lo, hi, span, fmode = args
|
||||
idx = _FIELDMODE_IDX[fmode]
|
||||
b1, b2, b3 = _G_SEED[0][span][idx], _G_SEED[1][span][idx], _G_SEED[2][span][idx]
|
||||
s1, s2, s3 = _G_SEED[3]
|
||||
hits = []
|
||||
for seed in range(lo, hi):
|
||||
c1 = binascii.crc32(b1, seed) & 0xFFFFFFFF
|
||||
if c1 != s1:
|
||||
# xor-mask path: derive mask from image 1, confirm on 2+3
|
||||
# (costs 2 more CRCs only on the rare near-hit; here c1!=s1 always
|
||||
# so check mask constancy cheaply only every step? skip: exact-only
|
||||
# in seed phase for speed; mask search lives in Phase B)
|
||||
continue
|
||||
c2 = binascii.crc32(b2, seed) & 0xFFFFFFFF
|
||||
c3 = binascii.crc32(b3, seed) & 0xFFFFFFFF
|
||||
if c2 == s2 and c3 == s3:
|
||||
hits.append(f'EXACT seed={seed:08X} span={span} field={fmode}')
|
||||
return hits
|
||||
|
||||
def phase_c(blobs, jobs, out, seed_max=1 << 24, seed_span='full', seed_chunk=4096):
|
||||
print(f'=== Phase C: seeded CRC32-IEEE brute force: seeds 0..{seed_max} '
|
||||
f'span={seed_span} jobs={jobs} ===', flush=True)
|
||||
print(' (C-speed crc32; exact-match only; this is the hours-long phase)', flush=True)
|
||||
# NOTE: 'zeroed' = cmtd+0x08 treated as 00s during hashing (standard scheme);
|
||||
# 'asis' re-check is cheap relative to seed space, so do zeroed first.
|
||||
t0 = time.time()
|
||||
found = []
|
||||
for fmode in ('zeroed', 'asis'):
|
||||
idx = _FIELDMODE_IDX[fmode]
|
||||
bufs = [span_bufs(d)[seed_span][idx] for _, d, _ in blobs]
|
||||
cks = [ck for _, _, ck in blobs]
|
||||
found += _phase_c_loop(bufs, cks, seed_span, fmode, seed_max, seed_chunk, jobs, out, t0)
|
||||
return found
|
||||
|
||||
def _phase_c_loop(bufs, cks, span, fmode, seed_max, chunk, jobs, out, t0):
|
||||
b1, b2, b3 = bufs
|
||||
s1, s2, s3 = cks
|
||||
found = []
|
||||
# shard seed space across workers: each task scans [lo,hi)
|
||||
tasks = [(lo, min(lo + chunk, seed_max)) for lo in range(0, seed_max, chunk)]
|
||||
total = len(tasks)
|
||||
done = 0
|
||||
with mp.Pool(jobs, initializer=_init_seed_worker, initargs=(bufs, cks)) as pool:
|
||||
for lo, hi, hits in pool.imap_unordered(_seed_scan, tasks, chunksize=4):
|
||||
done += 1
|
||||
for seed in hits:
|
||||
line = f'EXACT seed={seed:08X} span={span} field={fmode}'
|
||||
print(f' *** {line}', flush=True)
|
||||
found.append(line)
|
||||
with open(out, 'a') as fh:
|
||||
fh.write(line + '\n')
|
||||
if done % max(1, total // 20) == 0 or done == total:
|
||||
el = time.time() - t0
|
||||
print(f' Phase C [{done}/{total} chunks] seeds~{done*chunk}/{seed_max} '
|
||||
f'elapsed={el:.0f}s', flush=True)
|
||||
el = time.time() - t0
|
||||
print(f'Phase C done: {seed_max} seeds in {el:.0f}s, {len(found)} hits.', flush=True)
|
||||
return found
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--jobs', type=int, default=max(1, (os.cpu_count() or 4) - 1))
|
||||
ap.add_argument('--out', default='/tmp/opencode/ck-results.txt')
|
||||
ap.add_argument('--quick-only', action='store_true')
|
||||
ap.add_argument('--skip-phase-a', action='store_true')
|
||||
ap.add_argument('--skip-phase-b', action='store_true')
|
||||
ap.add_argument('--skip-phase-c', action='store_true')
|
||||
ap.add_argument('--limit', type=int, default=None, help='test only first N combos (smoke test)')
|
||||
ap.add_argument('--resume-from', type=int, default=0)
|
||||
ap.add_argument('--seed-max', type=int, default=1 << 24,
|
||||
help='seed brute-force range 0..SEED_MAX (default 2^24 ≈ hours)')
|
||||
ap.add_argument('--seed-span', default='full', choices=SPANS)
|
||||
ap.add_argument('--seed-chunk', type=int, default=4096)
|
||||
a = ap.parse_args()
|
||||
blobs = load_images()
|
||||
for ver, d, ck in blobs:
|
||||
print(f'{ver}: size={len(d)} stored_ck={ck:08X}', flush=True)
|
||||
if not a.skip_phase_a:
|
||||
phase_a(blobs)
|
||||
if a.quick_only:
|
||||
print('quick-only: stopping before Phase B/C.', flush=True)
|
||||
return
|
||||
open(a.out, 'a').write(f'# run {time.ctime()} jobs={a.jobs} limit={a.limit} seed_max={a.seed_max}\n')
|
||||
if not a.skip_phase_b:
|
||||
phase_b(blobs, a.jobs, a.out, limit=a.limit, resume_from=a.resume_from)
|
||||
if not a.skip_phase_c and not a.limit:
|
||||
phase_c(blobs, a.jobs, a.out, seed_max=a.seed_max,
|
||||
seed_span=a.seed_span, seed_chunk=a.seed_chunk)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+314
@@ -0,0 +1,314 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Phase D checksum cracker: 16-bit/ones-complement/chained/DJB2/Murmur batch.
|
||||
|
||||
Covers what Phases A-C did not: every candidate must match ALL 3 builds.
|
||||
Stdlib only, read-only on stock .bin files.
|
||||
|
||||
Quick smoke: python3 tools/lofi_checksum_phaseD.py --limit 30
|
||||
Full run: python3 tools/lofi_checksum_phaseD.py --jobs $(nproc) --out /tmp/opencode/ck-phaseD.txt
|
||||
|
||||
Families:
|
||||
CRC16: ARC/Modbus/CCITT-FALSE/XMODEM/Kermit/DNP/UMTS/BINHEX + halved-file pairs
|
||||
Ones-complement: 16-bit word sums (LE/BE, folded, complemented)
|
||||
Word sums: 16-bit LE/BE, BSD rotate, SysV folded
|
||||
Chained per-sect: crc32-of-crcs, sum-of-crcs, xor-of-crcs
|
||||
Odd hashes: FNV-1 (not 1a), DJB2, Murmur3-x86-32
|
||||
"""
|
||||
import argparse, binascii, multiprocessing as mp
|
||||
import os, struct, sys, time
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
from lofi_checksum_crack import load_images, span_bufs, SPANS, _FIELDMODE_IDX
|
||||
|
||||
CRC16_PARAMS = [ # (name, poly_trunc, init, refin, xorout, check_of_"123456789")
|
||||
# NOTE: refin polys are given in bit-reversed (truncated) form, used as-is.
|
||||
('ARC', 0xA001, 0x0000, True, 0x0000, 0xBB3D),
|
||||
('MODBUS', 0xA001, 0xFFFF, True, 0x0000, 0x4B37),
|
||||
('USB', 0xA001, 0xFFFF, True, 0xFFFF, 0xB4C8),
|
||||
('CCITT-F', 0x1021, 0xFFFF, False, 0x0000, 0x29B1),
|
||||
('XMODEM', 0x1021, 0x0000, False, 0x0000, 0x31C3),
|
||||
('KERMIT', 0x8408, 0x0000, True, 0x0000, 0x2189),
|
||||
('X25', 0x8408, 0xFFFF, True, 0xFFFF, 0x906E),
|
||||
('DNP', 0xA6BC, 0x0000, True, 0xFFFF, 0xEA82),
|
||||
('GENIBUS', 0x1021, 0xFFFF, False, 0xFFFF, 0xD64E),
|
||||
('GSM', 0x1021, 0x0000, False, 0xFFFF, 0xCE3C),
|
||||
]
|
||||
|
||||
_tables16 = {}
|
||||
def _tab16(poly_trunc, refin):
|
||||
key = (poly_trunc, refin)
|
||||
t = _tables16.get(key)
|
||||
if t is not None:
|
||||
return t
|
||||
if refin:
|
||||
# poly_trunc is already bit-reversed (e.g. 0xA001); use as-is.
|
||||
p = poly_trunc
|
||||
t = []
|
||||
for i in range(256):
|
||||
c = i
|
||||
for _ in range(8):
|
||||
c = (c >> 1) ^ p if c & 1 else c >> 1
|
||||
t.append(c & 0xFFFF)
|
||||
else:
|
||||
p = poly_trunc
|
||||
t = []
|
||||
for i in range(256):
|
||||
c = i << 8
|
||||
for _ in range(8):
|
||||
c = ((c << 1) ^ p) & 0xFFFF if c & 0x8000 else (c << 1) & 0xFFFF
|
||||
t.append(c)
|
||||
_tables16[key] = t
|
||||
return t
|
||||
|
||||
def crc16(buf, poly, init, refin, xorout):
|
||||
tab = _tab16(poly, refin)
|
||||
crc = init
|
||||
if refin:
|
||||
for b in buf:
|
||||
crc = tab[(crc ^ b) & 0xFF] ^ (crc >> 8)
|
||||
else:
|
||||
for b in buf:
|
||||
crc = tab[((crc >> 8) ^ b) & 0xFF] ^ ((crc << 8) & 0xFFFF)
|
||||
return (crc ^ xorout) & 0xFFFF
|
||||
|
||||
import array
|
||||
|
||||
def _even(buf):
|
||||
return buf if len(buf) % 2 == 0 else buf + b'\x00'
|
||||
|
||||
def _words16(buf, endian):
|
||||
a = array.array('H', _even(buf))
|
||||
if endian == 'big':
|
||||
a.byteswap()
|
||||
return a
|
||||
|
||||
def ones_complement16(buf, endian):
|
||||
mv = _words16(buf, endian)
|
||||
s = sum(mv) & 0xFFFFFFFFFFFFFFFF
|
||||
while s >> 16:
|
||||
s = (s & 0xFFFF) + (s >> 16)
|
||||
return (~s) & 0xFFFF
|
||||
|
||||
def wordsum16(buf, endian):
|
||||
return sum(_words16(buf, endian)) & 0xFFFF
|
||||
|
||||
def bsd_sum(buf):
|
||||
s = 0
|
||||
for b in buf:
|
||||
s = ((s >> 1) | ((s & 1) << 15)) & 0xFFFF
|
||||
s = (s + b) & 0xFFFF
|
||||
return s
|
||||
|
||||
def sysv_sum(buf):
|
||||
s = sum(buf)
|
||||
s = (s & 0xFFFF) + ((s >> 16) & 0xFFFF)
|
||||
s = (s & 0xFFFF) + ((s >> 16) & 0xFFFF)
|
||||
return s & 0xFFFF
|
||||
|
||||
def fnv1_32(buf):
|
||||
h = 0x811C9DC5
|
||||
for b in buf:
|
||||
h = (h * 0x01000193) & 0xFFFFFFFF
|
||||
h ^= b
|
||||
return h
|
||||
|
||||
def djb2(buf):
|
||||
h = 5381
|
||||
for b in buf:
|
||||
h = ((h * 33) + b) & 0xFFFFFFFF
|
||||
return h
|
||||
|
||||
def murmur3_x86_32(buf, seed=0):
|
||||
h = seed
|
||||
n = len(buf) & ~3
|
||||
for i in range(0, n, 4):
|
||||
k = struct.unpack('<I', buf[i:i+4])[0]
|
||||
k = (k * 0xCC9E2D51) & 0xFFFFFFFF
|
||||
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
|
||||
k = (k * 0x1B873593) & 0xFFFFFFFF
|
||||
h ^= k
|
||||
h = ((h << 13) | (h >> 19)) & 0xFFFFFFFF
|
||||
h = (h * 5 + 0xE6546B64) & 0xFFFFFFFF
|
||||
tail = buf[n:]
|
||||
k = 0
|
||||
for i, b in enumerate(tail):
|
||||
k |= b << (8 * i)
|
||||
if tail:
|
||||
k = (k * 0xCC9E2D51) & 0xFFFFFFFF
|
||||
k = ((k << 15) | (k >> 17)) & 0xFFFFFFFF
|
||||
k = (k * 0x1B873593) & 0xFFFFFFFF
|
||||
h ^= k
|
||||
h ^= len(buf)
|
||||
h ^= h >> 16
|
||||
h = (h * 0x85EBCA6B) & 0xFFFFFFFF
|
||||
h ^= h >> 13
|
||||
h = (h * 0xC2B2AE35) & 0xFFFFFFFF
|
||||
h ^= h >> 16
|
||||
return h
|
||||
|
||||
def match16(stored, v):
|
||||
"""How a 16-bit value could sit in the 32-bit field."""
|
||||
lo, hi = stored & 0xFFFF, (stored >> 16) & 0xFFFF
|
||||
if lo == v == hi:
|
||||
return 'both16'
|
||||
if lo == v:
|
||||
return 'lo16'
|
||||
if hi == v:
|
||||
return 'hi16'
|
||||
if stored == v:
|
||||
return 'full32eq16'
|
||||
if stored == ((v << 16) | v):
|
||||
return 'duplicated16'
|
||||
return None
|
||||
|
||||
def build_jobs():
|
||||
jobs = []
|
||||
for name, poly, init, refin, xorout, _check in CRC16_PARAMS:
|
||||
for span in SPANS:
|
||||
for fmode in ('asis', 'zeroed', 'ff'):
|
||||
jobs.append(('crc16', name, (poly, init, refin, xorout), span, fmode))
|
||||
for span in SPANS:
|
||||
for fmode in ('asis', 'zeroed', 'ff'):
|
||||
for nm in ('ones_le', 'ones_be', 'wsum16_le', 'wsum16_be',
|
||||
'bsd', 'sysv', 'fnv1', 'djb2', 'murmur0', 'murmurF'):
|
||||
jobs.append(('fast32', nm, (), span, fmode))
|
||||
for span in SPANS:
|
||||
for fmode in ('asis', 'zeroed'):
|
||||
for nm in ('chain_crc_of_crcs', 'chain_sum_of_crcs', 'chain_xor_of_crcs',
|
||||
'halved_crc16_pair'):
|
||||
jobs.append(('chain', nm, (), span, fmode))
|
||||
return jobs
|
||||
|
||||
_G = None
|
||||
def _init(blobs):
|
||||
global _G
|
||||
_G = blobs # [(ver, span->bufs[3], ck)]
|
||||
|
||||
FAST32 = {
|
||||
'fnv1': fnv1_32, 'djb2': djb2,
|
||||
'murmur0': lambda b: murmur3_x86_32(b, 0),
|
||||
'murmurF': lambda b: murmur3_x86_32(b, 0xFFFFFFFF),
|
||||
}
|
||||
|
||||
def _run(job):
|
||||
kind, name, params, span, fmode = job
|
||||
idx = _FIELDMODE_IDX[fmode]
|
||||
bufs = [(span_bufs_alias(d, span, idx), ck) for _, d, ck in _G]
|
||||
if kind == 'crc16':
|
||||
poly, init, refin, xorout = params
|
||||
got = [crc16(b, poly, init, refin, xorout) for b, _ in bufs]
|
||||
how = [match16(ck, v) for (_, ck), v in zip(bufs, got)]
|
||||
if all(how):
|
||||
return f'HIT crc16/{name} span={span} field={fmode} val={got[0]:04X} as={how[0]}'
|
||||
masks = [(ck ^ v) & 0xFFFF for (_, ck), v in zip(bufs, got)]
|
||||
if masks[0] == masks[1] == masks[2]:
|
||||
return (f'MASK16 crc16/{name} span={span} field={fmode} '
|
||||
f'mask={masks[0]:04X}')
|
||||
return None
|
||||
if kind == 'fast32':
|
||||
if name == 'ones_le':
|
||||
got = [ones_complement16(b, 'little') for b, _ in bufs]
|
||||
how = [match16(ck, v) for (_, ck), v in zip(bufs, got)]
|
||||
if all(how):
|
||||
return f'HIT ones-complement-LE span={span} field={fmode} as={how[0]}'
|
||||
return None
|
||||
if name == 'ones_be':
|
||||
got = [ones_complement16(b, 'big') for b, _ in bufs]
|
||||
how = [match16(ck, v) for (_, ck), v in zip(bufs, got)]
|
||||
if all(how):
|
||||
return f'HIT ones-complement-BE span={span} field={fmode} as={how[0]}'
|
||||
return None
|
||||
if name == 'wsum16_le':
|
||||
got = [wordsum16(b, 'little') for b, _ in bufs]
|
||||
elif name == 'wsum16_be':
|
||||
got = [wordsum16(b, 'big') for b, _ in bufs]
|
||||
elif name == 'bsd':
|
||||
got = [bsd_sum(b) for b, _ in bufs]
|
||||
elif name == 'sysv':
|
||||
got = [sysv_sum(b) for b, _ in bufs]
|
||||
else:
|
||||
fn = FAST32[name]
|
||||
got = [fn(b) for b, _ in bufs]
|
||||
if all(ck == v for (_, ck), v in zip(bufs, got)):
|
||||
return f'HIT {name} span={span} field={fmode} val={got[0]:08X}'
|
||||
masks = [(ck ^ v) & 0xFFFFFFFF for (_, ck), v in zip(bufs, got)]
|
||||
if masks[0] == masks[1] == masks[2]:
|
||||
return f'MASK32 {name} span={span} field={fmode} mask={masks[0]:08X}'
|
||||
return None
|
||||
how = [match16(ck, v) for (_, ck), v in zip(bufs, got)]
|
||||
if all(how):
|
||||
return f'HIT {name} span={span} field={fmode} val={got[0]:04X} as={how[0]}'
|
||||
return None
|
||||
# chained per-sect schemes
|
||||
nsects = [struct.unpack('<I', d[0x50:0x54])[0] for _, d, _ in _G]
|
||||
crcs_per_image = []
|
||||
for (_, d, _), n in zip(_G, nsects):
|
||||
off, lst = 0x54, []
|
||||
for _ in range(n):
|
||||
ln = struct.unpack('<I', d[off+8:off+12])[0]
|
||||
lst.append(binascii.crc32(d[off+12:off+12+ln]) & 0xFFFFFFFF)
|
||||
off += 12 + ln
|
||||
crcs_per_image.append(lst)
|
||||
if name == 'chain_crc_of_crcs':
|
||||
got = [binascii.crc32(struct.pack(f'<{len(c)}I', *c)) & 0xFFFFFFFF
|
||||
for c in crcs_per_image]
|
||||
elif name == 'chain_sum_of_crcs':
|
||||
got = [sum(c) & 0xFFFFFFFF for c in crcs_per_image]
|
||||
elif name == 'chain_xor_of_crcs':
|
||||
got = []
|
||||
for c in crcs_per_image:
|
||||
x = 0
|
||||
for v in c:
|
||||
x ^= v
|
||||
got.append(x)
|
||||
elif name == 'halved_crc16_pair':
|
||||
got = []
|
||||
for b, _ in bufs:
|
||||
h = len(b) // 2
|
||||
a = crc16(b[:h], 0xA001, 0, True, 0)
|
||||
c = crc16(b[h:], 0xA001, 0, True, 0)
|
||||
got.append(((a << 16) | c) & 0xFFFFFFFF)
|
||||
else:
|
||||
return None
|
||||
if all(ck == v for (_, ck), v in zip(bufs, got)):
|
||||
return f'HIT {name} span={span} field={fmode} val={got[0]:08X}'
|
||||
masks = [(ck ^ v) & 0xFFFFFFFF for (_, ck), v in zip(bufs, got)]
|
||||
if masks[0] == masks[1] == masks[2]:
|
||||
return f'MASK32 {name} span={span} field={fmode} mask={masks[0]:08X}'
|
||||
return None
|
||||
|
||||
def span_bufs_alias(d, span, idx):
|
||||
return span_bufs(d)[span][idx]
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('--jobs', type=int, default=max(1, (os.cpu_count() or 4) - 1))
|
||||
ap.add_argument('--out', default='/tmp/opencode/ck-phaseD.txt')
|
||||
ap.add_argument('--limit', type=int, default=None)
|
||||
a = ap.parse_args()
|
||||
blobs = load_images()
|
||||
packed = [(ver, d, ck) for ver, d, ck in blobs]
|
||||
for ver, d, ck in blobs:
|
||||
print(f'{ver}: size={len(d)} stored_ck={ck:08X}', flush=True)
|
||||
jobs = build_jobs()
|
||||
if a.limit:
|
||||
jobs = jobs[:a.limit]
|
||||
print(f'Phase D: {len(jobs)} combos, jobs={a.jobs}', flush=True)
|
||||
t0 = time.time()
|
||||
found, done = [], 0
|
||||
total = len(jobs)
|
||||
with mp.Pool(a.jobs, initializer=_init, initargs=(packed,)) as pool:
|
||||
for res in pool.imap_unordered(_run, jobs, chunksize=4):
|
||||
done += 1
|
||||
if res:
|
||||
print(f' *** {res}', flush=True)
|
||||
found.append(res)
|
||||
with open(a.out, 'a') as fh:
|
||||
fh.write(res + '\n')
|
||||
if done % 50 == 0 or done == total:
|
||||
print(f' [{done}/{total}] elapsed={time.time()-t0:.0f}s',
|
||||
flush=True)
|
||||
print(f'Phase D done: {total} combos in {time.time()-t0:.0f}s, '
|
||||
f'{len(found)} candidates.', flush=True)
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+139
@@ -0,0 +1,139 @@
|
||||
"""Shared parser/packer for Lofi-12 XT Sonicware .bin images.
|
||||
|
||||
Format: [cmtd 48B][mmtd 36B][sect xN -> EOF, exact fit]
|
||||
Each sect: b'sect' | u32 LE load_addr | u32 LE len | payload[len]
|
||||
|
||||
All addresses are DDR2 (0xC2xxxxxx, TMS320C6748). Code is C674x DSP LE.
|
||||
Stdlib only.
|
||||
"""
|
||||
import struct
|
||||
import json
|
||||
import binascii
|
||||
|
||||
CMTD_LEN = 48
|
||||
MMTD_LEN = 36
|
||||
|
||||
# Checksum seed: cmtd+0x08 coverage replaces file bytes [8:12] with this
|
||||
# constant before CRC32-IEEE (init 0). Proven 3/3 against stock images
|
||||
# (solved independently per image via GF(2), all give this same value;
|
||||
# it is also built literally in the bootloader: MVK 0x6282/MVKH 0xC27C).
|
||||
CKSEED = 0xC27C6282
|
||||
|
||||
|
||||
def compute_checksum(d: bytes) -> int:
|
||||
"""Valid cmtd+0x08 for a complete image: CRC32 of the image with
|
||||
bytes [8:12] replaced by CKSEED."""
|
||||
b = bytearray(d)
|
||||
b[8:12] = struct.pack('<I', CKSEED)
|
||||
return binascii.crc32(bytes(b)) & 0xFFFFFFFF
|
||||
|
||||
def parse_image(d: bytes) -> dict:
|
||||
assert d[0:4] == b'cmtd', "bad cmtd magic"
|
||||
assert d[0x30:0x34] == b'mmtd', "bad mmtd magic"
|
||||
fsize, cksum = struct.unpack('<II', d[4:12])
|
||||
assert fsize == len(d), f"cmtd filesize {fsize} != actual {len(d)}"
|
||||
cmtd = {
|
||||
'filesize': fsize,
|
||||
'checksum': cksum,
|
||||
'raw_0x0C_0x30': d[0x0C:0x30].hex(),
|
||||
'u32_0x10_0x30': list(struct.unpack('<8I', d[0x10:0x30])),
|
||||
}
|
||||
mmtd_rem, mmtd_flags, mmtd_ff = struct.unpack('<III', d[0x34:0x40])
|
||||
fw = struct.unpack('<III', d[0x40:0x4C])
|
||||
entry, nsect = struct.unpack('<II', d[0x4C:0x54])
|
||||
mmtd = {
|
||||
'remaining': mmtd_rem,
|
||||
'flags': mmtd_flags,
|
||||
'ff': mmtd_ff,
|
||||
'fw': list(fw),
|
||||
'entry': entry,
|
||||
'nsect': nsect,
|
||||
}
|
||||
assert mmtd_rem == len(d) - CMTD_LEN, "mmtd remaining mismatch"
|
||||
sects = []
|
||||
off = 0x54
|
||||
for i in range(nsect):
|
||||
assert d[off:off+4] == b'sect', f"bad sect magic at {off:#x}"
|
||||
addr, ln = struct.unpack('<II', d[off+4:off+12])
|
||||
payload = d[off+12:off+12+ln]
|
||||
assert len(payload) == ln, f"sect {i} truncated"
|
||||
sects.append({'index': i, 'off': off, 'addr': addr, 'len': ln,
|
||||
'end': addr + ln, 'payload': payload})
|
||||
off += 12 + ln
|
||||
assert off == len(d), f"sect chain ends at {off:#x}, EOF {len(d):#x}"
|
||||
return {'cmtd': cmtd, 'mmtd': mmtd, 'sects': sects}
|
||||
|
||||
|
||||
def build_image(meta: dict, payloads: list, checksum: int | None = None) -> bytes:
|
||||
"""Rebuild image from meta (cmtd/mmtd dicts) + payload list [(addr, bytes)].
|
||||
Recalculates filesize/remaining fields. Preserves entry/flags/fw unless
|
||||
caller edited meta. Checksum: explicit value, 'keep' preserves the
|
||||
original from meta, None (default) computes the valid checksum."""
|
||||
nsect = len(payloads)
|
||||
total = CMTD_LEN + MMTD_LEN + sum(12 + len(p) for _, p in payloads)
|
||||
# checksum covers the whole file, so build with a zero placeholder,
|
||||
# then finalize: explicit int wins, 'keep' preserves meta, None computes.
|
||||
ck = 0
|
||||
out = bytearray()
|
||||
out += b'cmtd'
|
||||
out += struct.pack('<II', total, ck)
|
||||
out += bytes.fromhex(meta['cmtd']['raw_0x0C_0x30'])
|
||||
# fix embedded filesize/remaining inside cmtd raw (last two u32s):
|
||||
# raw layout: 8 x u32 at 0x10..0x30 = [12,1,3,maj,min,patch,48,remaining]
|
||||
# patch remaining just in case caller changed payload sizes
|
||||
u = list(struct.unpack('<8I', out[0x10:0x30]))
|
||||
u[6] = CMTD_LEN
|
||||
u[7] = total - CMTD_LEN
|
||||
out[0x10:0x30] = struct.pack('<8I', *u)
|
||||
out += b'mmtd'
|
||||
out += struct.pack('<I', total - CMTD_LEN)
|
||||
out += struct.pack('<II', meta['mmtd']['flags'], meta['mmtd']['ff'])
|
||||
out += struct.pack('<III', *meta['mmtd']['fw'])
|
||||
out += struct.pack('<II', meta['mmtd']['entry'], nsect)
|
||||
for addr, p in payloads:
|
||||
out += b'sect'
|
||||
out += struct.pack('<II', addr, len(p))
|
||||
out += p
|
||||
assert len(out) == total
|
||||
if checksum == 'keep':
|
||||
final_ck = meta['cmtd']['checksum']
|
||||
elif checksum is None:
|
||||
final_ck = compute_checksum(bytes(out))
|
||||
else:
|
||||
final_ck = checksum
|
||||
out[8:12] = struct.pack('<I', final_ck)
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def headers_to_json(info: dict) -> dict:
|
||||
return {
|
||||
'cmtd': info['cmtd'],
|
||||
'mmtd': {**info['mmtd'],
|
||||
'entry_hex': f"{info['mmtd']['entry']:08X}",
|
||||
'flags_hex': f"{info['mmtd']['flags']:08X}"},
|
||||
'sects': [{'index': s['index'], 'off_hex': f"{s['off']:06X}",
|
||||
'addr_hex': f"{s['addr']:08X}", 'len': s['len'],
|
||||
'end_hex': f"{s['end']:08X}"} for s in info['sects']],
|
||||
}
|
||||
|
||||
|
||||
def find_sect_by_role(info: dict, role: str) -> dict:
|
||||
"""role='code' -> largest sect; role='rodata' -> sect holding b'Threshold'
|
||||
(fallback: second largest)."""
|
||||
sects = info['sects']
|
||||
if role == 'code':
|
||||
return max(sects, key=lambda s: s['len'])
|
||||
if role == 'rodata':
|
||||
for s in sects:
|
||||
if b'Threshold' in s['payload']:
|
||||
return s
|
||||
rest = sorted(sects, key=lambda s: s['len'], reverse=True)
|
||||
return rest[1] if len(rest) > 1 else rest[0]
|
||||
raise ValueError(role)
|
||||
|
||||
|
||||
def code_file_off(info: dict, addr: int) -> int | None:
|
||||
for s in info['sects']:
|
||||
if s['addr'] <= addr < s['end']:
|
||||
return s['off'] + 12 + (addr - s['addr'])
|
||||
return None
|
||||
Executable
+34
@@ -0,0 +1,34 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Repack directory (headers.json + sect*.bin) -> .bin. Verifies exact-fit chain."""
|
||||
import argparse, json, glob, os, struct, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lofi_image import build_image
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('indir')
|
||||
ap.add_argument('output')
|
||||
ap.add_argument('--checksum', default=None,
|
||||
help="'auto' (default: compute valid checksum), hex u32, or 'keep' (headers.json value)")
|
||||
a = ap.parse_args()
|
||||
meta = json.load(open(os.path.join(a.indir, 'headers.json')))
|
||||
# cmtd raw hex -> rebuild path needs raw_0x0C_0x30; headers.json stores it
|
||||
files = sorted(glob.glob(os.path.join(a.indir, 'sect*_addr*.bin')))
|
||||
assert files, 'no sect files found'
|
||||
payloads = []
|
||||
for f in files:
|
||||
base = os.path.basename(f)
|
||||
addr = int(base.split('addr')[1].split('.')[0], 16)
|
||||
payloads.append((addr, open(f, 'rb').read()))
|
||||
if a.checksum is None or a.checksum == 'auto':
|
||||
ck = None
|
||||
elif a.checksum == 'keep':
|
||||
ck = 'keep'
|
||||
else:
|
||||
ck = int(a.checksum, 16)
|
||||
out = build_image(meta, payloads, checksum=ck)
|
||||
open(a.output, 'wb').write(out)
|
||||
print(f"wrote {a.output} ({len(out)} B) checksum={struct.unpack('<I', out[8:12])[0]:08X}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+55
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Same-length UI string swap. Keeps every address stable (Level-0 mod).
|
||||
|
||||
Example: lofi_patch_string.py "Lofi-12 XT.bin" out.bin Threshold ThresholX
|
||||
"""
|
||||
import argparse, struct, sys, os
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lofi_image import parse_image, build_image, find_sect_by_role
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('image')
|
||||
ap.add_argument('output')
|
||||
ap.add_argument('old')
|
||||
ap.add_argument('new')
|
||||
ap.add_argument('--sect', type=int, default=None)
|
||||
ap.add_argument('--first-only', action='store_true')
|
||||
a = ap.parse_args()
|
||||
old_b, new_b = a.old.encode(), a.new.encode()
|
||||
assert len(old_b) == len(new_b), \
|
||||
f"lengths differ ({len(old_b)} vs {len(new_b)}); use equal length to keep addresses stable"
|
||||
d = open(a.image, 'rb').read()
|
||||
info = parse_image(d)
|
||||
tgt = None
|
||||
if a.sect is not None:
|
||||
tgt = info['sects'][a.sect]
|
||||
else:
|
||||
tgt = find_sect_by_role(info, 'rodata')
|
||||
hits = []
|
||||
p = tgt['payload']
|
||||
start = 0
|
||||
while True:
|
||||
i = p.find(old_b, start)
|
||||
if i < 0:
|
||||
break
|
||||
hits.append(i)
|
||||
start = i + 1
|
||||
if not hits:
|
||||
sys.exit(f"'{a.old}' not found in sect{tgt['index']} (addr {tgt['addr']:08X})")
|
||||
print(f"found {len(hits)} hit(s) in sect{tgt['index']} at offsets: "
|
||||
+ ', '.join(f"+{h:#x} (file {tgt['off']+12+h:#x})" for h in hits))
|
||||
if a.first_only:
|
||||
hits = hits[:1]
|
||||
np = bytearray(p)
|
||||
for h in hits:
|
||||
np[h:h+len(old_b)] = new_b
|
||||
payloads = [(s['addr'], bytes(np) if s['index'] == tgt['index'] else s['payload'])
|
||||
for s in info['sects']]
|
||||
out = build_image(info, payloads) # recomputes valid checksum
|
||||
open(a.output, 'wb').write(out)
|
||||
print(f"wrote {a.output} with fresh checksum "
|
||||
f"{struct.unpack('<I', out[8:12])[0]:08X} — verify on device.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+29
@@ -0,0 +1,29 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Unpack Lofi-12 XT .bin -> directory with headers.json + sect payloads."""
|
||||
import argparse, json, os, struct, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lofi_image import parse_image, headers_to_json
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('image')
|
||||
ap.add_argument('outdir')
|
||||
a = ap.parse_args()
|
||||
d = open(a.image, 'rb').read()
|
||||
info = parse_image(d)
|
||||
os.makedirs(a.outdir, exist_ok=True)
|
||||
open(os.path.join(a.outdir, 'headers.json'), 'w').write(
|
||||
json.dumps(headers_to_json(info), indent=2))
|
||||
for s in info['sects']:
|
||||
fn = f"sect{s['index']}_addr{s['addr']:08X}.bin"
|
||||
open(os.path.join(a.outdir, fn), 'wb').write(s['payload'])
|
||||
m = info['mmtd']
|
||||
print(f"fw {tuple(m['fw'])} entry={m['entry']:08X} nsect={m['nsect']} "
|
||||
f"cksum={info['cmtd']['checksum']:08X} size={len(d)}")
|
||||
for s in info['sects']:
|
||||
print(f" sect{s['index']} off={s['off']:06X} addr={s['addr']:08X} "
|
||||
f"len={s['len']} end={s['end']:08X}")
|
||||
print(f"wrote {a.outdir}/")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
Executable
+98
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""xref + slack scanner: LE32 pointers into code/rodata, NUL strings, zero gaps.
|
||||
|
||||
Usage: lofi_xref.py "Lofi-12 XT.bin" [--find TEXT] [--strings-min 6]
|
||||
"""
|
||||
import argparse, os, struct, sys
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lofi_image import parse_image, find_sect_by_role
|
||||
|
||||
def le_words(payload):
|
||||
for i in range(0, len(payload) - 3, 4):
|
||||
yield i, struct.unpack('<I', payload[i:i+4])[0]
|
||||
|
||||
def zero_runs(payload, minlen=32):
|
||||
out, i, n = [], 0, len(payload)
|
||||
while i < n:
|
||||
if payload[i] == 0:
|
||||
j = i
|
||||
while j < n and payload[j] == 0:
|
||||
j += 1
|
||||
if j - i >= minlen:
|
||||
out.append((i, j - i))
|
||||
i = j
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
def c_strings(payload, minlen=6):
|
||||
out, i, n = [], 0, len(payload)
|
||||
while i < n:
|
||||
if 32 <= payload[i] < 127:
|
||||
j = i
|
||||
while j < n and 32 <= payload[j] < 127:
|
||||
j += 1
|
||||
if j - i >= minlen and j < n and payload[j] == 0:
|
||||
out.append((i, payload[i:j].decode()))
|
||||
i = max(j, i + 1)
|
||||
else:
|
||||
i += 1
|
||||
return out
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument('image')
|
||||
ap.add_argument('--find', default=None)
|
||||
ap.add_argument('--strings-min', type=int, default=6)
|
||||
ap.add_argument('--slack-min', type=int, default=64)
|
||||
a = ap.parse_args()
|
||||
d = open(a.image, 'rb').read()
|
||||
info = parse_image(d)
|
||||
code = find_sect_by_role(info, 'code')
|
||||
ro = find_sect_by_role(info, 'rodata')
|
||||
print(f"code: sect{code['index']} {code['addr']:08X}..{code['end']:08X} len={code['len']}")
|
||||
print(f"rodata: sect{ro['index']} {ro['addr']:08X}..{ro['end']:08X} len={ro['len']}")
|
||||
# pointers in rodata -> code, and rodata -> self
|
||||
to_code = to_self = 0
|
||||
code_hits = []
|
||||
for off, w in le_words(ro['payload']):
|
||||
if code['addr'] <= w < code['end']:
|
||||
to_code += 1
|
||||
code_hits.append((off, w))
|
||||
elif ro['addr'] <= w < ro['end']:
|
||||
to_self += 1
|
||||
print(f"rodata xrefs: {to_code} -> code, {to_self} -> self")
|
||||
gaps = zero_runs(ro['payload'], a.slack_min)
|
||||
gaps.sort(key=lambda t: -t[1])
|
||||
print(f"top zero gaps in rodata (min {a.slack_min}):")
|
||||
for off, ln in gaps[:10]:
|
||||
print(f" +{off:#x} (file {ro['off']+12+off:#x}) len={ln}")
|
||||
if a.find:
|
||||
needle = a.find.encode()
|
||||
hits = []
|
||||
p = ro['payload']
|
||||
s = 0
|
||||
while True:
|
||||
i = p.find(needle, s)
|
||||
if i < 0:
|
||||
break
|
||||
hits.append(i)
|
||||
s = i + 1
|
||||
print(f"'{a.find}': {len(hits)} hit(s) in rodata")
|
||||
for h in hits:
|
||||
faddr = ro['addr'] + h
|
||||
refs = [off for off, w in code_hits if False] # placeholder
|
||||
# find pointers TO this string: scan rodata words == faddr
|
||||
# (vtable-adjacent tables) — cheap exact scan
|
||||
ptrs = []
|
||||
for off, w in le_words(ro['payload']):
|
||||
if w == faddr:
|
||||
ptrs.append(ro['off'] + 12 + off)
|
||||
print(f" +{h:#x} file={ro['off']+12+h:#x} load={faddr:08X} "
|
||||
f"referenced_by_{len(ptrs)}={['%#x' % x for x in ptrs[:8]]}")
|
||||
else:
|
||||
strs = c_strings(ro['payload'], a.strings_min)
|
||||
print(f"NUL strings len>={a.strings_min} in rodata: {len(strs)}")
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Verify cmtd+0x08 checksums: prove_checksum.py a.bin [b.bin ...]
|
||||
|
||||
Exit 0 iff every image's stored checksum equals compute_checksum(image).
|
||||
See lofi_image.compute_checksum for the algorithm.
|
||||
"""
|
||||
import os
|
||||
import struct
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from lofi_image import compute_checksum, parse_image
|
||||
|
||||
|
||||
def main() -> int:
|
||||
ok = True
|
||||
for path in sys.argv[1:]:
|
||||
d = open(path, "rb").read()
|
||||
info = parse_image(d) # validates container chain
|
||||
stored = info["cmtd"]["checksum"]
|
||||
calc = compute_checksum(d)
|
||||
match = stored == calc
|
||||
ok &= match
|
||||
fw = ".".join(map(str, info["mmtd"]["fw"]))
|
||||
print("%s fw=%s size=%d stored=%08X calc=%08X %s"
|
||||
% (path, fw, len(d), stored, calc,
|
||||
"MATCH" if match else "MISMATCH"))
|
||||
return 0 if ok and len(sys.argv) > 1 else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user