P1-C: WA dispatch table located (65-entry MCU_EXT_CMD @0x10201304, ~30 ABI-named handlers); scan_tables.py structural method; ForceDisasmPost; findings F7
Some checks are pending
ci / test (push) Waiting to run
ci / track (push) Waiting to run

This commit is contained in:
Zachery Aaron Shores-Chmielewski 2026-08-21 18:45:47 +04:00
parent b9c46bc11d
commit 4042aeda6c
7 changed files with 251 additions and 3 deletions

View file

@ -0,0 +1,39 @@
# Headless post-script: force-disassemble all executable blocks and
# re-run auto-analysis. Run with:
# analyzeHeadless <proj_dir> <proj> -process <prog> -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()))

View file

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

View file

@ -0,0 +1,46 @@
#!/usr/bin/env python3
"""Find strings matching a regex and decompile their referencing functions.
Usage: dump-like launcher args: <elf> <proj_dir> <proj_name> <regex> [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])

View file

@ -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: <proj_dir> <proj_name> <program_path_in_project>
"""
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()

View file

@ -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,
};

View file

59
tools/scan_tables.py Executable file
View file

@ -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 <region.bin> <code_lo> <code_hi> [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))