Scaffold: licenses, Makefile, README, PLAN w/ decision function; extractor parses container + hidden build-string trailer; findings log F1-F4 + unknowns registry

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-08-20 21:09:43 +04:00
commit b2ac192df2
8 changed files with 472 additions and 0 deletions

14
.gitignore vendored Normal file
View file

@ -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*

21
LICENSE Normal file
View file

@ -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.

12
Makefile Normal file
View file

@ -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)

140
PLAN.md Normal file
View file

@ -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.

23
README.md Normal file
View file

@ -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).

13
docs/LICENSE-DOCS Normal file
View file

@ -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.

88
docs/findings.md Normal file
View file

@ -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.

161
tools/mtk_fw_extract.py Executable file
View file

@ -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('<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()