mtk-wifi-fw/tools/mtk_fw_extract.py

162 lines
6.3 KiB
Python
Raw Normal View History

#!/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('<III4sIIBB14s') # 40 bytes
PATCH_HDR = 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()