commit b2ac192df2e84cb7ac551ddd7c863a297e1a2425 Author: Zachery Aaron Shores-Chmielewski Date: Thu Aug 20 21:09:43 2026 +0400 Scaffold: licenses, Makefile, README, PLAN w/ decision function; extractor parses container + hidden build-string trailer; findings log F1-F4 + unknowns registry diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9c5e326 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# Blob-derived outputs: carved firmware bytes must never be committed +extracted*/ +out/ +firmware/ + +# Python +__pycache__/ +*.pyc + +# RE tooling state +.ghidra/ +*.rep +*.gpr +*.id* diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..59e6f24 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Zachery Aaron Shores-Chmielewski + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..f4fd96e --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +BLOBS ?= $(wildcard /lib/firmware/mediatek/mt79*_wm.bin) \ + $(wildcard /lib/firmware/mediatek/mt79*_wa.bin) \ + $(wildcard /lib/firmware/mediatek/mt79*_rom_patch.bin) + +.PHONY: help extract + +help: + @echo "make extract - carve all MediaTek Connac2 blobs found on this host" + @echo " (set BLOBS=... to override)" + +extract: + python3 tools/mtk_fw_extract.py $(BLOBS) diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..5acbde7 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,140 @@ +# mtk-wifi-fw (working name) — Project Plan + +Goal: cut teeth on real firmware RE while producing community-useful tooling, +docs, and update tracking for MediaTek Connac2 WiFi firmware. The product is +the tooling/knowledge and the skills built making it — not a modified router. + +## Target facts (verified, keep cited) + +- Hardware: GL-MT3000 (Beryl AX), MT7981B (Filogic 820), OpenWrt 24.10.5. + Radios on-SoC at `platform/soc/18000000.wifi`, driver `mt7915e` (mt76). +- "The blob" = 4 images, 3 NDS32LE cores inside the SoC: + - `mt7981_wm.bin` 2.05MB — WM, main MAC/PHY MCU ("neptune", build 20240823) + - `mt7981_wa.bin` 494KB — WA, offload/stats MCU + - `mt7981_wo.bin` 2.45MB — WED packet offload core (FreeRTOS v10, loaded by + `mtk_wed`, not mt76 — separate container format, TODO) + - `mt7981_rom_patch.bin` 9.8KB — boot-ROM patch ("ALPS") + - NDS32LE evidence: `GCC: (2018-10-23_nds32le-elf-mculib-v3) 4.9.4` in wa; + `portable/GCC/NDS32/port.c` in wo. +- No signature/encryption wall: loader is the kernel driver + (`mt7915_load_firmware` → `mt76_connac2_load_patch` + `load_ram`); protocol + supports per-region encrypt (`FW_FEATURE_SET_ENCRYPT`) but blobs ship + plaintext. Source-verified; empirical proof = M3 milestone. +- Container formats (from `mt76_connac_mcu.h`): + - RAM images: region data from offset 0, n×40B region table, 36B trailer at + EOF. `fw_ver` stripped (`____000000`); build_date is the only versioning. + - Patch: 92B BE header + 64B BE section table at start. +- Family coverage: mt7915/7916/7981/7986 all parse. mt7915 blobs = 2022 + build, others = 2024 — free pilot for version diffing. +- Tooling: Ghidra has an official NDS32 module (`NDS32:LE:32:default`, some + V3 instr gaps); Andes GNU toolchain public (exact 2018 GCC vintage exists). +- Prior art: cyrozap/mediatek-wifi-re (older chip gen; CC-BY-SA notes, + compatible). No public RE of Connac2-generation firmware exists. + +## Strategy shape + +RE first, fork later. Nobody can price replacement firmware before the boot +and core-boundary picture exists, so the plan front-loads cheap-certain RE and +gates expensive authoring behind a data-informed go/no-go. + +- Phase 1 static RE: carve → ELF → Ghidra → boot/ABI map (no decision needed) +- Phase 2 dynamic RE: patch bytes, reload, observe (patching = verification + instrument, no decision needed) +- Fork point (after boot/handshake + WM↔WA boundary mapped): choose between + a) audit & document track, and/or + b) authoring ladder, easiest-first: custom WO (self-contained, no radio) + → WM handshake-only → beacon TX → association → data path. + Each rung independently valuable/stoppable. +- Version diffing runs parallel throughout (pure static, high community value). + +## MVP v0.1 — "toolkit + changelog" (no hardware required, ~8–12 evenings) + +Scope: extract (family-wide, correct) + to-elf + diff report over +linux-firmware git history + format/boot docs + CI cron that tracks upstream +blob drops. The repo becomes the public changelog for silently-updated +firmware. + +Worklist in order: +1. Repo scaffolding: git init, dirs, dual LICENSE (MIT code / CC-BY-SA + docs), Makefile; commit existing extractor. +2. Parser correctness: resolve or document-as-unknown the 100/36B gap, + region `type` semantics, feature bit 0x80. +3. Test fixtures: golden manifests per blob (metadata only, no blob bytes). +4. ELF emitter at true load addresses (Ghidra/objdump acceptance test). +5. Dataset: walk linux-firmware git history for mediatek WiFi files. +6. Diff engine + pilot report (mt7915 2022 vs 2024; string-table add/remove + is the human-readable signal). +7. Docs: format.md (every field cited), boot.md, README quickstart. +8. CI: fixtures on commit; weekly cron vs linux-firmware HEAD regenerating + reports. +9. Polish, tag v0.1.0, undersold announcement (OpenWrt forum + r/re). + +Not in v0.1: Ghidra annotation projects, symbol DBs, patched blobs, anything +router-dependent. + +## Quality gates ("not noise") + +- Every claim cites evidence (kernel line, blob offset) or is marked unknown. + No invented semantics. +- Family coverage verified in CI, not asserted in README. +- Reproducible in ~3 commands, plain Linux, no blobs committed + (`make fetch-fw` pulls from kernel.org). +- Undersell announcements. + +## Status log + +- 2026-08-20: Extractor `tools/mtk_fw_extract.py` written and run on + mt7981/7915/7916/7986 wm+wa+patch (7 files, 4 chips) — all parse clean. + Output in `extracted*/`, `extracted/manifest.json`. Solved same day: the + "gap" is a second trailer family-wide (wm 100B, wa 36B) sitting between + region data and the kernel-parsed trailer; WM's carries the full build + string MTK strips from the kernel-visible trailer (branch, MP tag, build + timestamp) — recovered version metadata for the changelog. Region type: + all parsed blobs are FW_TYPE_DEFAULT(0); loader does use `type` elsewhere + (mt7921 FW_TYPE_CLC). Remaining unknowns: feature bit 0x80 (wm 0xf0xxxxxx + regions), hidden-trailer non-string fields. Verified claims + citations: + docs/findings.md. +- Router unreachable (thinkpad jump host down). Not blocking: v0.1 is pure + static. Hardware options when needed: fix thinkpad / wire devuan-hpz to + router LAN / join `zach` WiFi from devuan-hpz (key + likely MAC-whitelisted, + but changes this machine's network path mid-session). + +## Open items + +- Repo name + hosting (GitHub reach vs Codeberg/self-hosted Forgejo; + mirroring makes this non-blocking). +- Router access path (needed from M3/dynamic phase). +- Fork decision (audit vs authoring ladder mix) — after boot/boundary map. +- WO container format (from `mtk_wed_mcu.c`, different loader) — M2-ish. + +## Decision function & finish criteria + +Project has no natural completion point; finish = satisficing set + per-node +gates. All checks binary. + +**Finish conditions (both required):** +- F1 public artifact: v0.1 shipped passing quality gates, then one of + community uptake / cron keeps it alive / documented sunset ("last verified + against linux-firmware X"). Silent rot is noise; sunset is finish. +- F2 teeth cut, each backed by an artifact: container parsed (done); one + *verified cited* claim from disassembly; changed firmware behavior and + observed it on hardware (P2); optionally self-written code on target. + +**Universal gate (every checkpoint):** advance iff next gate is reachable +with known techniques AND has a stated cost ceiling AND its failure would not +invalidate shipped artifacts. Slipped >2x twice → stop-and-replan. Outputs +ship as verified or documented-unknown, never "probably"; unknowns are valid +P1 exits but never authoring foundations. + +**Node exits:** MVP = worklist 1–9 + quality gates. P1 = boot chain writable +step-by-step with citations; ≥30 functions named via dispatch anchors, 3 +traced end-to-end; memory map complete; WM↔WA characterized; fork memo. +Authoring rung N+1 opens iff rung N demonstrated on hardware AND N+1 +interface mapped AND unbrick path proven. + +**Kill criteria:** 3 consecutive flash cycles with no hypothesis advance → +rung closed, findings published. ROM-verified lockout discovered → authoring +dead, audit track unaffected. Router unreachable >4 weeks → P2 paused only. + +**Zombie rule:** no commits and no CI activity for 2 quarters → archive with +last-verified statement. diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5c84e7 --- /dev/null +++ b/README.md @@ -0,0 +1,23 @@ +# mtk-wifi-fw (working name) + +Tooling, documentation, and update tracking for MediaTek Connac2 WiFi +firmware blobs (`mt7915/7916/7981/7986_{wm,wa,rom_patch}.bin` as shipped in +linux-firmware) — the WiFi firmware running on Filogic 820/830 routers +(GL.iNet, Banana Pi, Xiaomi, Netgear, TP-Link, ...). + +Status: pre-release. Working now: container extractor (`tools/mtk_fw_extract.py`). +Planned: ELF export for Ghidra/objdump, cross-version diff reports, CI that +tracks upstream blob updates, format + boot-architecture documentation. + +No proprietary firmware bytes are committed to this repository. Tools operate +on blobs from your own linux-firmware copy (see `make extract`). + +Quickstart: + + make extract # carve every MediaTek Connac2 blob on this host + python3 tools/mtk_fw_extract.py /lib/firmware/mediatek/mt7981_wm.bin -o extracted + +Project plan and working notes: [PLAN.md](PLAN.md) (development log, quality +gates, decision function). Documentation as it lands: [docs/](docs/). + +License: MIT (code), CC-BY-SA-4.0 (docs). diff --git a/docs/LICENSE-DOCS b/docs/LICENSE-DOCS new file mode 100644 index 0000000..8d7ca64 --- /dev/null +++ b/docs/LICENSE-DOCS @@ -0,0 +1,13 @@ +Documentation in the docs/ directory (including format specifications, +architecture notes, and reports) is licensed under the Creative Commons +Attribution-ShareAlike 4.0 International License (CC-BY-SA-4.0). + +You should have received a copy of the license along with this work; if not, +see: https://creativecommons.org/licenses/by-sa/4.0/legalcode + +Copyright (c) 2026 Zachery Aaron Shores-Chmielewski + +Note: derived works must carry attribution; CC-BY-SA is chosen for +compatibility with prior art (cyrozap/mediatek-wifi-re notes). + +Code in tools/ and tests/ is MIT-licensed; see the repository root LICENSE. diff --git a/docs/findings.md b/docs/findings.md new file mode 100644 index 0000000..6a0b268 --- /dev/null +++ b/docs/findings.md @@ -0,0 +1,88 @@ +# Findings log + +One entry per claim. Every claim carries evidence (kernel source reference or +blob offset + observed bytes). Claims that cannot be evidenced are registered +in the Unknowns section instead. Blobs referenced by filename are the +linux-firmware copies listed in `manifest.json` (SHA-256 TBD in fixtures). + +Blobs observed: mt7981/mt7915/mt7916/mt7986 × {wm, wa, rom_patch} +(linux-firmware snapshot 2026-04-20, Debian). + +## F1 — Connac2 RAM container layout + +`[region data, packed from offset 0 in table order][optional hidden trailer +(see F2)][n_region × 40-byte region table][36-byte trailer at EOF]` + +Evidence: `mt76_connac_mcu.c` `mt76_connac2_load_ram()` + +`mt76_connac2_send_ram()` (region data streamed sequentially from offset 0; +table and trailer located from EOF); `mt76_connac_mcu.h` +`struct mt76_connac2_fw_region` / `_fw_trailer`. Confirmed by exact layout +match on 8 RAM blobs (sum(region.len) + table + trailer (+ hidden trailer) == +file size). + +Region fields: `decomp_crc/decomp_len/decomp_blk_sz` (MCU-side decompression, +unused when decomp_len==0 — all observed blobs), `addr`, `len`, +`feature_set`, `type`. + +## F2 — Hidden second trailer carries the full build string + +Between region data and the kernel-parsed table sits a trailer the upstream +loader never reads. WM blobs: 100 bytes; WA blobs: 36 bytes (no string). +Observed family-wide (all 4 chips). + +WM hidden trailer = 16×'#' + `40 00 00 00` + 1-byte string length + flags + +full build string + '#'-padding. Recovered strings: + +- mt7981_wm: `t-neptune-main-mt7915-1953-MT7981_MP2111_IMP-20240823161204` +- mt7916_wm: `t-neptune-main-mt7915-1953-MT7916_MP2111_IMP-20240823170147` +- mt7986_wm: `t-neptune-main-mt7915-1953-MT7986_MP2111_IMP-20240823160608` +- mt7915_wm: `t-neptune-mp-mt7915-2045-MT7915_MP_7_4_2045-20220929103802` + +Significance: the kernel-visible trailer's `fw_ver[10]` is stripped +(`____000000` WM / `DEV_000000` WA); the hidden trailer restores branch +("main"/"mp"), internal build number (1953/2045), MP tag, and build +timestamp. This is the version metadata the changelog tooling keys on. + +Evidence: bytes at offset `sum(region.len)` in each blob (see +`manifest.json` → `hidden_trailer.raw_head`). + +## F3 — region `type` is a content tag used by some loaders + +All parsed mt79-family blobs: `type == 0` (`FW_TYPE_DEFAULT`). It is not +ignored upstream: mt7921's loader selects +`feature_set & FW_FEATURE_NON_DL && type == FW_TYPE_CLC` regions as +country/location-calibration data instead of downloading them to the MCU. + +Evidence: `mt7921/mcu.c` CLC handling; `FW_TYPE_CLC` definition in +`mt76_connac_mcu.h`. + +## F4 — Patch container format + +92-byte big-endian header (16-byte build date, 4-byte platform tag — +"ALPS" on all four — hw_sw_ver, patch_ver, checksum, descriptor), 64-byte BE +section table, plaintext section data at explicit offsets. All observed +patch sections: `enc_type == 0` (plaintext). + +Evidence: `mt76_connac2_load_patch()` + `struct mt76_connac2_patch_hdr/_sec` +in `mt76_connac_mcu.h`; exact parse of 4 patch blobs. + +## Unknowns registry + +- **U1 — `feature_set` bit 7 (0x80):** observed only on WM regions at + `0xf0xxxxxx` addresses (mt7981 r7–r10, mt7986 equivalents). Upstream + defines bits 0–6 only; vendor-SDK search found no definition (2026-08-20). + Correlation with address family noted; semantics undetermined. May relate + to a second download destination or content class. +- **U2 — hidden-trailer non-string fields:** the `40 00 00 00` and flag + bytes before/around the string (F2) are uninterpreted. +- **U3 — WO container:** `mt7981_wo.bin` uses the mtk_wed loader + (`mtk_wed_mcu.c`), a different format; not yet parsed. + +## Related verified facts + +- NDS32LE target: `GCC: (2018-10-23_nds32le-elf-mculib-v3) 4.9.4` string in + mt7981_wa region data; FreeRTOS v10.0.0 + `portable/GCC/NDS32/port.c` + strings in mt7981_wo. +- No per-region encryption in any observed blob (`FW_FEATURE_SET_ENCRYPT` + clear; patch `enc_type == 0`). Optional protocol support exists in the + loader (key from EFUSE path) but is not exercised by shipped images. diff --git a/tools/mtk_fw_extract.py b/tools/mtk_fw_extract.py new file mode 100755 index 0000000..752e29a --- /dev/null +++ b/tools/mtk_fw_extract.py @@ -0,0 +1,161 @@ +#!/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()