From 4042aeda6c34b20abc89132c383cd7b3dffb526e Mon Sep 17 00:00:00 2001 From: Zachery Aaron Shores-Chmielewski Date: Fri, 21 Aug 2026 18:45:47 +0400 Subject: [PATCH] P1-C: WA dispatch table located (65-entry MCU_EXT_CMD @0x10201304, ~30 ABI-named handlers); scan_tables.py structural method; ForceDisasmPost; findings F7 --- tools/ghidra_scripts/ForceDisasmPost.py | 39 ++++++++++++++++ tools/ghidra_scripts/dump_decomp.py | 6 +-- tools/ghidra_scripts/find_string_xref.py | 46 ++++++++++++++++++ tools/ghidra_scripts/force_disasm.py | 50 ++++++++++++++++++++ tools/ref/mcu_ext_cmd.enum | 54 ++++++++++++++++++++++ tools/ref/mcu_wa_ext_cmd.enum | 0 tools/scan_tables.py | 59 ++++++++++++++++++++++++ 7 files changed, 251 insertions(+), 3 deletions(-) create mode 100644 tools/ghidra_scripts/ForceDisasmPost.py create mode 100755 tools/ghidra_scripts/find_string_xref.py create mode 100755 tools/ghidra_scripts/force_disasm.py create mode 100644 tools/ref/mcu_ext_cmd.enum create mode 100644 tools/ref/mcu_wa_ext_cmd.enum create mode 100755 tools/scan_tables.py diff --git a/tools/ghidra_scripts/ForceDisasmPost.py b/tools/ghidra_scripts/ForceDisasmPost.py new file mode 100644 index 0000000..2863463 --- /dev/null +++ b/tools/ghidra_scripts/ForceDisasmPost.py @@ -0,0 +1,39 @@ +# Headless post-script: force-disassemble all executable blocks and +# re-run auto-analysis. Run with: +# analyzeHeadless -process -noanalysis \ +# -scriptPath tools/ghidra_scripts -postScript ForceDisasmPost.py +#@category Analysis + +from ghidra.app.cmd.disassemble import DisassembleCommand +from ghidra.app.plugin.core.analysis import AutoAnalysisManager +from ghidra.program.model.address import AddressSet + +listing = currentProgram.getListing() +mem = currentProgram.getMemory() +monitor.setMessage('collecting executable blocks') + +total = AddressSet() +for b in mem.getBlocks(): + if b.isExecute() and 'elf' not in b.getName(): + total.addRange(b.getStart(), b.getEnd()) + +before_ins = listing.getNumInstructions() +before_fun = currentProgram.getFunctionManager().getFunctionCount() +print('ForceDisasm before: %d instructions, %d functions' + % (before_ins, before_fun)) + +cmd = DisassembleCommand(total, total, False) +tx = currentProgram.startTransaction('force disasm') +try: + ok = cmd.applyTo(currentProgram, monitor) +finally: + currentProgram.endTransaction(tx, True) +print('ForceDisasm disasm apply: %s' % ok) + +mgr = AutoAnalysisManager.getAnalysisManager(currentProgram) +mgr.reAnalyzeAll(None) +mgr.startAnalysis(monitor) + +print('ForceDisasm after: %d instructions, %d functions' + % (listing.getNumInstructions(), + currentProgram.getFunctionManager().getFunctionCount())) diff --git a/tools/ghidra_scripts/dump_decomp.py b/tools/ghidra_scripts/dump_decomp.py index d9393c8..0a93ff4 100755 --- a/tools/ghidra_scripts/dump_decomp.py +++ b/tools/ghidra_scripts/dump_decomp.py @@ -17,9 +17,9 @@ from ghidra.util.task import ConsoleTaskMonitor # noqa: E402 elf, proj_dir, proj_name = sys.argv[1], sys.argv[2], sys.argv[3] mode = sys.argv[4] if len(sys.argv) > 4 else 'entry_only' -with pyghidra.open_program(elf, project_location=proj_dir, - project_name=proj_name, analyze=True) as flat: - prog = flat.getCurrentProgram() +with pyghidra.program_context(pyghidra.open_project( + __import__('os').path.abspath(proj_dir), proj_name), + '/' + __import__('os').path.basename(elf)) as prog: fm = prog.getFunctionManager() def addr(a): diff --git a/tools/ghidra_scripts/find_string_xref.py b/tools/ghidra_scripts/find_string_xref.py new file mode 100755 index 0000000..8f86647 --- /dev/null +++ b/tools/ghidra_scripts/find_string_xref.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Find strings matching a regex and decompile their referencing functions. + +Usage: dump-like launcher args: [max_funcs] +""" +import re +import sys + +import pyghidra + +pyghidra.start() + +from ghidra.app.decompiler import DecompInterface # noqa: E402 +from ghidra.util.task import ConsoleTaskMonitor # noqa: E402 + +elf, proj_dir, proj_name, pat = sys.argv[1:5] +maxf = int(sys.argv[5]) if len(sys.argv) > 5 else 3 + +with pyghidra.program_context(pyghidra.open_project( + __import__('os').path.abspath(proj_dir), proj_name), + '/' + __import__('os').path.basename(elf)) as prog: + listing = prog.getListing() + fm = prog.getFunctionManager() + rm = prog.getReferenceManager() + rx = re.compile(pat) + dec = DecompInterface() + dec.openProgram(prog) + seen = set() + for d in listing.getDefinedData(True): + dt = d.getDataType().getName().lower() + if 'string' not in dt and 'char' not in dt: + continue + v = d.getValue() + if v is None or not rx.search(str(v)): + continue + print('STRING %s %r' % (d.getAddress(), str(v)[:100])) + for ref in rm.getReferencesTo(d.getAddress()): + f = fm.getFunctionContaining(ref.getFromAddress()) + if f is None or f.getEntryPoint() in seen: + continue + seen.add(f.getEntryPoint()) + print(' <- %s %s' % (f.getEntryPoint(), f.getName())) + if len(seen) <= maxf: + r = dec.decompileFunction(f, 90, ConsoleTaskMonitor()) + if r.decompileCompleted(): + print(r.getDecompiledFunction().getC()[:6000]) diff --git a/tools/ghidra_scripts/force_disasm.py b/tools/ghidra_scripts/force_disasm.py new file mode 100755 index 0000000..22dc719 --- /dev/null +++ b/tools/ghidra_scripts/force_disasm.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Force-disassemble every executable block, then re-analyze. + +Firmware images have no entry graph into most code, so default analysis +leaves large undisassembled regions (no xrefs to their strings). + +Usage: +""" +import os +import sys + +import pyghidra + +pyghidra.start() + +from ghidra.app.cmd.disassemble import DisassembleCommand # noqa: E402 +from ghidra.app.plugin.core.analysis import AutoAnalysisManager # noqa: E402 +from ghidra.program.model.address import AddressSet # noqa: E402 +from ghidra.util.task import ConsoleTaskMonitor # noqa: E402 + +proj_dir, proj_name, prog_path = sys.argv[1], sys.argv[2], sys.argv[3] +monitor = ConsoleTaskMonitor() + +proj = pyghidra.open_project(os.path.abspath(proj_dir), proj_name) +with pyghidra.program_context(proj, prog_path) as prog: + mem = prog.getMemory() + listing = prog.getListing() + total = AddressSet() + for b in mem.getBlocks(): + if b.isExecute() and 'elf' not in b.getName(): + total.addRange(b.getStart(), b.getEnd()) + before_ins = listing.getNumInstructions() + before_fun = prog.getFunctionManager().getFunctionCount() + print('before: %d instructions, %d functions' + % (before_ins, before_fun)) + cmd = DisassembleCommand(total, total, False) + with pyghidra.transaction(prog, 'force disasm'): + ok = cmd.applyTo(prog, monitor) + print('disasm apply:', ok) + from ghidra.app.script import GhidraScriptUtil + from ghidra.program.flatapi import FlatProgramAPI + GhidraScriptUtil.acquireBundleHostReference() + try: + FlatProgramAPI(prog).analyzeAll(prog) + finally: + GhidraScriptUtil.releaseBundleHostReference() + print('after: %d instructions, %d functions' + % (listing.getNumInstructions(), + prog.getFunctionManager().getFunctionCount())) +proj.close() diff --git a/tools/ref/mcu_ext_cmd.enum b/tools/ref/mcu_ext_cmd.enum new file mode 100644 index 0000000..76ecdc0 --- /dev/null +++ b/tools/ref/mcu_ext_cmd.enum @@ -0,0 +1,54 @@ +# MCU_EXT_CMD_* ids from mt76_connac_mcu.h (torvalds master) +# Same enum serves WM and WA: MCU_WA_EXT_CMD(t) = MCU_EXT_CMD(t) | WA-routing bit. +enum { + MCU_EXT_CMD_EFUSE_ACCESS = 0x01, + MCU_EXT_CMD_RF_REG_ACCESS = 0x02, + MCU_EXT_CMD_RF_TEST = 0x04, + MCU_EXT_CMD_ID_RADIO_ON_OFF_CTRL = 0x05, + MCU_EXT_CMD_PM_STATE_CTRL = 0x07, + MCU_EXT_CMD_CHANNEL_SWITCH = 0x08, + MCU_EXT_CMD_SET_TX_POWER_CTRL = 0x11, + MCU_EXT_CMD_FW_LOG_2_HOST = 0x13, + MCU_EXT_CMD_TXBF_ACTION = 0x1e, + MCU_EXT_CMD_EFUSE_BUFFER_MODE = 0x21, + MCU_EXT_CMD_THERMAL_PROT = 0x23, + MCU_EXT_CMD_STA_REC_UPDATE = 0x25, + MCU_EXT_CMD_BSS_INFO_UPDATE = 0x26, + MCU_EXT_CMD_EDCA_UPDATE = 0x27, + MCU_EXT_CMD_DEV_INFO_UPDATE = 0x2A, + MCU_EXT_CMD_THERMAL_CTRL = 0x2c, + MCU_EXT_CMD_WTBL_UPDATE = 0x32, + MCU_EXT_CMD_SET_DRR_CTRL = 0x36, + MCU_EXT_CMD_SET_RDD_CTRL = 0x3a, + MCU_EXT_CMD_ATE_CTRL = 0x3d, + MCU_EXT_CMD_PROTECT_CTRL = 0x3e, + MCU_EXT_CMD_DBDC_CTRL = 0x45, + MCU_EXT_CMD_MAC_INIT_CTRL = 0x46, + MCU_EXT_CMD_RX_HDR_TRANS = 0x47, + MCU_EXT_CMD_MUAR_UPDATE = 0x48, + MCU_EXT_CMD_BCN_OFFLOAD = 0x49, + MCU_EXT_CMD_RX_AIRTIME_CTRL = 0x4a, + MCU_EXT_CMD_SET_RX_PATH = 0x4e, + MCU_EXT_CMD_EFUSE_FREE_BLOCK = 0x4f, + MCU_EXT_CMD_TX_POWER_FEATURE_CTRL = 0x58, + MCU_EXT_CMD_RXDCOC_CAL = 0x59, + MCU_EXT_CMD_GET_MIB_INFO = 0x5a, + MCU_EXT_CMD_TXDPD_CAL = 0x60, + MCU_EXT_CMD_CAL_CACHE = 0x67, + MCU_EXT_CMD_RED_ENABLE = 0x68, + MCU_EXT_CMD_CP_SUPPORT = 0x75, + MCU_EXT_CMD_SET_RADAR_TH = 0x7c, + MCU_EXT_CMD_SET_RDD_PATTERN = 0x7d, + MCU_EXT_CMD_MWDS_SUPPORT = 0x80, + MCU_EXT_CMD_SET_SER_TRIGGER = 0x81, + MCU_EXT_CMD_TWT_AGRT_UPDATE = 0x94, + MCU_EXT_CMD_FW_DBG_CTRL = 0x95, + MCU_EXT_CMD_OFFCH_SCAN_CTRL = 0x9a, + MCU_EXT_CMD_SET_RDD_TH = 0x9d, + MCU_EXT_CMD_MURU_CTRL = 0x9f, + MCU_EXT_CMD_SET_SPR = 0xa8, + MCU_EXT_CMD_GROUP_PRE_CAL_INFO = 0xab, + MCU_EXT_CMD_DPD_PRE_CAL_INFO = 0xac, + MCU_EXT_CMD_PHY_STAT_INFO = 0xad, + MCU_EXT_CMD_WF_RF_PIN_CTRL = 0xbd, +}; diff --git a/tools/ref/mcu_wa_ext_cmd.enum b/tools/ref/mcu_wa_ext_cmd.enum new file mode 100644 index 0000000..e69de29 diff --git a/tools/scan_tables.py b/tools/scan_tables.py new file mode 100755 index 0000000..3374275 --- /dev/null +++ b/tools/scan_tables.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 +"""Scan firmware regions for consecutive function-pointer tables. + +Finds runs of >= min_run consecutive little-endian dwords that all fall in a +given code range — the shape of MCU command-handler dispatch tables. + +Usage: scan_tables.py [min_run] [tolerance] +tolerance = max absolute non-code dwords allowed inside a run (0/NULL counted). +""" +import struct +import sys +from pathlib import Path + +path = Path(sys.argv[1]) +lo, hi = int(sys.argv[2], 0), int(sys.argv[3], 0) +min_run = int(sys.argv[4]) if len(sys.argv) > 4 else 8 +tol = int(sys.argv[5]) if len(sys.argv) > 5 else 0 + +d = path.read_bytes() +base = 0 +# If the region file corresponds to a load address, pass that as lo bound +# via argv; region offsets here are file offsets, so also print file offset. +words = struct.unpack_from('<%dI' % (len(d) // 4), d) +run_start = None +run = [] +tables = [] + + +def flush(end): + global run, run_start + code = sum(1 for w in run if lo <= w < hi) + if len(run) >= min_run and code >= min_run: + tables.append((run_start, end, len(run), code)) + run = [] + run_start = None + + +for i, w in enumerate(words): + ok = lo <= w < hi or w == 0 + if ok: + if run_start is None: + run_start = i * 4 + run.append(w) + else: + if run_start is not None and tol: + run.append(w) # tolerate, next check trims + if sum(1 for x in run[-3:] if not (lo <= x < hi or x == 0)) >= 2: + flush(i * 4) + else: + flush(i * 4) + +for t in tables: + start_off, end_off, n, code = t + print('table @ file+0x%x..0x%x (%d words, %d code ptrs)' + % (start_off, end_off, n, code)) + vals = words[start_off // 4:end_off // 4] + for j, w in enumerate(vals[:64]): + mark = '' if lo <= w < hi else ('(0)' if w == 0 else '(?)') + print(' [%2d] 0x%08x %s' % (j, w, mark))