#!/usr/bin/env python3 """Carve MediaTek Connac2 WiFi firmware containers. Layouts transcribed from Linux: drivers/net/wireless/mediatek/mt76/mt76_connac_mcu.h (struct mt76_connac2_fw_trailer / fw_region / patch_hdr / patch_sec) RAM images (mt7981_wm.bin, mt7981_wa.bin): raw region data packed from offset 0, optional hidden second trailer (100B WM / 36B WA; the WM one carries the full build string), then n_region * 40-byte region table, then 36-byte trailer at EOF. Patch image (mt7981_rom_patch.bin): 92-byte BE header, 64-byte BE section table, section data at explicit offsets. """ import argparse import json import math import re import struct from pathlib import Path TRAILER = struct.Struct('<5B2s10s15sI') # 36 bytes REGION = struct.Struct('16s4sIIHH5I11I') # 92 bytes PATCH_SEC = struct.Struct('>III13I') # 64 bytes FEAT_BITS = {0: 'ENCRYPT', 4: 'ENCRY_MODE', 5: 'OVERRIDE_ADDR', 6: 'NON_DL'} def entropy(data: bytes) -> float: if not data: return 0.0 freq = [0] * 256 for b in data: freq[b] += 1 n = len(data) return -sum((c / n) * math.log2(c / n) for c in freq if c) def feature_names(feat: int): names = [name for bit, name in FEAT_BITS.items() if feat & (1 << bit)] if feat & 0b00000110: names.append(f'KEY_IDX={(feat >> 1) & 3}') return names def carve_ram(path: Path, outdir: Path) -> dict: d = path.read_bytes() t = TRAILER.unpack_from(d, len(d) - TRAILER.size) chip_id, eco, n_region, fmt_ver, fmt_flag, _rsv, fw_ver, bdate, crc = t info = { 'file': str(path), 'format': 'connac2-ram', 'chip_id': chip_id, 'eco': eco, 'n_region': n_region, 'format_ver': fmt_ver, 'format_flag': fmt_flag, 'fw_ver': fw_ver.decode(errors='replace'), 'build_date': bdate.decode(errors='replace'), 'trailer_crc': f'{crc:08x}', 'size': len(d), } print(f"{path.name}: chip=0x{chip_id:02x} eco={eco} n_region={n_region} " f"ver='{info['fw_ver']}' date='{info['build_date']}' crc=0x{crc:08x}") regions = [] off = 0 table_base = len(d) - TRAILER.size - n_region * REGION.size for i in range(n_region): r = REGION.unpack_from(d, table_base + i * REGION.size) decomp_crc, decomp_len, blk, _r, addr, ln, feat, typ, _r1 = r data = d[off:off + ln] fn = outdir / f'r{i}_t{typ}_a{addr:08x}.bin' fn.write_bytes(data) reg = { 'idx': i, 'type': typ, 'addr': f'0x{addr:08x}', 'len': ln, 'compressed': bool(decomp_len), 'decomp_len': decomp_len, 'decomp_blk_sz': blk, 'decomp_crc': f'{decomp_crc:08x}', 'feature_set': f'0x{feat:02x}', 'features': feature_names(feat), 'entropy': round(entropy(data[:65536]), 2), 'file': fn.name, } print(f" r{i}: type={typ} addr={reg['addr']} len={ln:>8} " f"comp={int(reg['compressed'])} feat={reg['feature_set']}" f"{(' [' + ','.join(reg['features']) + ']') if reg['features'] else ''} " f"entropy={reg['entropy']:.2f}") regions.append(reg) off += ln gap = len(d) - off - n_region * REGION.size - TRAILER.size info['regions'] = regions if gap > 0: # Second trailer between region data and the kernel-parsed table. # Observed family-wide: 100B on WM blobs (carries the full build # string stripped from the kernel-visible trailer), 36B on WA blobs # (no string). Layout: 16 x '#', 4-byte fields, optional # length-prefixed version string, '#'-padded to size. g = d[off:off + gap] runs = [r.strip(b'#') for r in re.findall(rb'[\x20-\x7e]{8,}', g)] runs = [r for r in runs if len(r) >= 8] hidden = {'size': gap, 'string': runs[0].decode() if runs else None, 'raw_head': f'{g[:24].hex()}'} info['hidden_trailer'] = hidden print(f" hidden trailer: {gap}B ver='{hidden['string']}'") return info def carve_patch(path: Path, outdir: Path) -> dict: d = path.read_bytes() h = PATCH_HDR.unpack_from(d, 0) bdate, plat, hw_sw, pver, cksum, _rsv, dver, subsys, feat, n_region, dcrc = h[:11] info = { 'file': str(path), 'format': 'connac2-patch', 'platform': plat.decode(errors='replace'), 'build_date': bdate.decode(errors='replace'), 'hw_sw_ver': f'0x{hw_sw:08x}', 'patch_ver': f'0x{pver:08x}', 'checksum': f'0x{cksum:04x}', 'desc_patch_ver': f'0x{dver:08x}', 'subsys': f'0x{subsys:08x}', 'feature': f'0x{feat:08x}', 'n_region': n_region, 'desc_crc': f'{dcrc:08x}', 'size': len(d), } print(f"{path.name}: platform={info['platform']} date='{info['build_date']}' " f"hw_sw={info['hw_sw_ver']} patch_ver={info['patch_ver']} n_region={n_region}") secs = [] for i in range(n_region): s = PATCH_SEC.unpack_from(d, PATCH_HDR.size + i * PATCH_SEC.size) typ, offs, size = s[0], s[1], s[2] addr, ln, key_idx, align = s[3:7] enc_type = (key_idx >> 24) & 0xff data = d[offs:offs + size] fn = outdir / f's{i}_a{addr:08x}.bin' fn.write_bytes(data) sec = { 'idx': i, 'type': f'0x{typ:x}', 'offset': offs, 'size': size, 'addr': f'0x{addr:08x}', 'len': ln, 'enc_type': enc_type, 'key': key_idx & 0xff, 'align_len': align, 'entropy': round(entropy(data), 2), 'file': fn.name, } print(f" s{i}: type={sec['type']} addr={sec['addr']} len={ln:>8} " f"enc={enc_type} key={sec['key']} entropy={sec['entropy']:.2f}") secs.append(sec) info['sections'] = secs return info def main(): ap = argparse.ArgumentParser() ap.add_argument('blobs', nargs='+') ap.add_argument('-o', '--out', default='extracted') args = ap.parse_args() outdir = Path(args.out) manifest = [] for blob in args.blobs: p = Path(blob) d = outdir / p.stem d.mkdir(parents=True, exist_ok=True) if 'patch' in p.name: manifest.append(carve_patch(p, d)) else: manifest.append(carve_ram(p, d)) (outdir / 'manifest.json').write_text(json.dumps(manifest, indent=2)) print(f"wrote {outdir / 'manifest.json'}") if __name__ == '__main__': main()