#!/usr/bin/env python3 """Symbolicate and decompile every build of a TA type, in a form that diffs cleanly. Why this exists: the project had one symbolicated build per TA type, so cross-version comparison was impossible -- there was nothing to compare against. `mkelf_ta.py` produced an ELF per corpus build; this ports the reference symbols onto each one and emits whole-program pseudocode. ## Porting tiers (lifted from psp_out/phase2_port_symbols.py, which proved the approach) EXACT identical function bytes MNEM identical mnemonic sequence, operands differ (the usual cross-version case: a relinked literal pool or branch displacement) FUZZY positional mnemonic similarity >= THRESHOLD (none) below threshold -> LEFT AS sub_*, never guessed A reference signature shared by two or more *differently named* functions is **ambiguous** and is not applied at all, rather than picking one arbitrarily. ## The self-validation gate Porting the reference onto its own build must yield ~100% EXACT. Anything else means the matcher, the base address or the body extraction is broken, and the run for that type is **void** -- reported as such, not silently emitted. This is the control that catches a rebasing error, which would otherwise mislabel every function in every build. ## Why the output is shaped the way it is Addresses shift between builds, so including them would make every diff hunk noise. Hence: * `decompile(include_addresses=False)`; * functions ordered by **name**, not address, so the same function lines up across versions; * unnamed `sub_*` functions collected at the end, ordered by size then body hash, since their numbering is meaningless across builds; * each function carries its porting tier, so a FUZZY name is never mistaken for a certainty. usage: ./ta_re.py --type gcp [--limit N] [--outdir fw/decomp] [--validate-only] ./ta_re.py --list """ from __future__ import annotations import argparse import glob import hashlib import json import os import re import struct import sys from pathlib import Path HERE = Path(__file__).resolve().parent REPO = HERE.parent sys.path.insert(0, str(Path.home() / "dev" / "ida-tui-maybe")) sys.path.insert(0, str(Path(__file__).resolve().parent)) import ta_metrics ELF_DIR = REPO / "psp_out" / "versions_win" / "elf_all" THRESHOLD = 0.90 # Reference build per type: the one that already carries hand-made symbols. # 12 Windows types -> fw/libdumps/.json (ea/size/name/hash, richest source). LIBDUMP_REF = { "auc": "16.0.0.9", "csu": "42.0.0.6", "fp": "19.0.0.8", "gcp": "22.0.4.50", "merit": "24.0.4.42", "oc": "50.0.0.1", "opm": "25.0.4.48", "powerplay": "34.0.0.20", "rap": "7.0.2.19", "tmz": "29.0.0.4", "wspe": "30.0.4.27", "xgmi": "32.0.0.20", } def elf_body(path: str) -> tuple[bytes, int]: raw = Path(path).read_bytes() ph = struct.unpack(" tuple[str, ...]: from capstone import Cs, CS_ARCH_ARM, CS_MODE_THUMB md = Cs(CS_ARCH_ARM, CS_MODE_THUMB) off = start - va if off < 0 or off + size > len(body): return () return tuple(i.mnemonic for i in md.disasm(body[off:off + size], start)) def sim(a: tuple, b: tuple) -> float: if not a or not b: return 0.0 n, m = min(len(a), len(b)), max(len(a), len(b)) return sum(1 for i in range(n) if a[i] == b[i]) / m def vtuple(ver: str) -> tuple[int, ...]: try: return tuple(int(x) for x in ver.split(".")) except ValueError: return (0, 0, 0, 0) # non-numeric (e.g. the handover "reference" module) def vdist(a: str, b: str) -> int: """Version distance, major-weighted, so 22.0.4.50 is near 22.0.4.49 and far from 22.0.0.53.""" ta_, tb = vtuple(a), vtuple(b) n = max(len(ta_), len(tb)) ta_ = ta_ + (0,) * (n - len(ta_)) tb = tb + (0,) * (n - len(tb)) w = [10 ** (3 * (n - 1 - i)) for i in range(n)] return sum(wi * abs(x - y) for wi, x, y in zip(w, ta_, tb)) POOL_TIERS = ("EXACT", "MNEM") def funcs_as_reference(funcs, assigned, body, va): """Turn a just-processed build into reference material for its neighbours. Only EXACT and MNEM names are contributed. A FUZZY name is a similarity judgement, and feeding one back as a seed would let a single bad guess propagate down the whole chain and look more confident at every hop. """ out = [] for fn in funcs: nm, tier = assigned.get(fn["addr"], (None, None)) if not nm or tier not in POOL_TIERS: continue ea, size = int(fn["addr"], 16), fn["size"] off = ea - va if off < 0 or size <= 0 or off + size > len(body): continue b = body[off:off + size] out.append({"name": nm, "ea": ea, "size": size, "bytes": b, "hash": hashlib.sha256(b).hexdigest(), "mnems": mnems(body, va, ea, size)}) return out # Types whose symbols live in psp_out/handover rather than fw/libdumps. These carry # prototypes and comments as well as names, which the libdump types do not. HANDOVER_REF = {"asd", "hdcp", "ras", "dtm"} def load_handover_reference(ta: str): """-> (ref_elf_path, funcs, protos, comments); reference is the recorded module itself. The reference ELF is psp_out/handover/elf/2.elf, the image those symbols were recorded against -- not a corpus build. Every one of its symbol addresses falls inside its LOAD segment (1562/1562 for ASD), so the pairing is sound; the self-port gate still has to confirm it before anything is emitted. """ refelf = str(REPO / "psp_out" / "handover" / "elf" / f"{ta}2.elf") sympath = REPO / "psp_out" / "handover" / "symbols" / f"{ta}.json" if not os.path.exists(refelf) or not sympath.exists(): raise SystemExit(f"handover reference missing for {ta!r}") body, va = elf_body(refelf) sym = json.loads(sympath.read_text()) out, protos, comments = [], {}, {} for k, v in sym.get("functions", {}).items(): ea = int(k, 16) end = int(v.get("end", 0)) size = end - ea name = v.get("name") if not name or size <= 0: continue off = ea - va if off < 0 or off + size > len(body): continue b = body[off:off + size] out.append({"name": name, "ea": ea, "size": size, "bytes": b, "hash": hashlib.sha256(b).hexdigest(), "mnems": mnems(body, va, ea, size)}) if v.get("proto"): protos[name] = v["proto"] if v.get("cmt"): comments[name] = v["cmt"] return refelf, out, protos, comments PSEUDO_SIG = re.compile( r"^((?:[A-Za-z_][\w \*]*?)\s+__fastcall\s+([A-Za-z_]\w*)\s*\([^;{]*\))\s*$", re.M) def pseudo_prototypes(ta: str) -> dict[str, str]: """name -> full C prototype, harvested from the hand-made fw/pseudo/*.c surface files. fw/libdumps carries only {ea,hash,name,size}, so the twelve Windows TA types have no recorded type information at all and every signature came out as int/_DWORD. The pseudo files are the one place those types exist -- they are already written as 'int __fastcall gcp_cmd01_AllocateDRMSession(struct gcp_ctx *, struct ta_params *)'. """ out: dict[str, str] = {} for p in glob.glob(str(HERE / "pseudo" / f"{ta}*.c")): try: txt = Path(p).read_text(errors="replace") except Exception: continue for sig, name in PSEUDO_SIG.findall(txt): out.setdefault(name, sig.strip()) return out CMD_IN_NAME = re.compile(r"_cmd([0-9A-Fa-f]{2,4})(?:_|$)") def cmd_prototypes(ta: str): """command id -> (semantic_name, prototype), from the hand-written pseudo files. Matching prototypes to functions BY NAME only reached 8 of GCP's 24, because the tree carries two naming generations that disagree about which command a handler serves. The hardware findings settle it: FINDING-GCP-ACQUIRE-GATE-BYPASS records 0xFE0C as CreateCryptoSession, which is what fw/pseudo says, while fw/libdumps calls that same function gcp_cmdFE0C_CreateAuthenticatedSession. Keying on the COMMAND ID and resolving the address through the dispatcher sidesteps the disagreement entirely -- the dispatcher is the only authority on which function serves which command. """ out: dict[int, tuple[str, str]] = {} for name, sig in pseudo_prototypes(ta).items(): m = CMD_IN_NAME.search(name) if not m: continue # a handler fragment (__hN) is not the command entry; skip it if re.search(r"__h\d+$", name): continue try: cmd = int(m.group(1), 16) except ValueError: continue out.setdefault(cmd, (name, sig)) return out def load_type_decls(ta: str) -> list[str]: """C declarations to parse into the database BEFORE any prototype is applied. This ordering is the whole point: a prototype naming a struct is rejected outright if the struct is not declared yet, which is why 202 of 1434 ASD prototypes failed on the first pass. Declared as ONE parse unit, because separate parses cannot see each other's types. """ decls: list[str] = [] sym = REPO / "psp_out" / "handover" / "symbols" / f"{ta}.json" if sym.exists(): lt = json.loads(sym.read_text()).get("local_types") or [] decls.extend(x for x in lt if isinstance(x, str)) # hand-written declarations that existed but were never applied by this pipeline try: src = (HERE / "ta_ida.py").read_text() # both the request struct and any INTERNAL structs for this component for pat in (r"^%s_DECLS\s*=\s*r?\"\"\"(.*?)\"\"\"", r"^%s_INTERNAL_DECLS\s*=\s*r?\"\"\"(.*?)\"\"\""): m = re.search(pat % ta.upper(), src, re.S | re.M) if m: decls.append(m.group(1)) # the TEE runtime state is shared by every module, so declare it unconditionally mr = re.search(r"^TEE_RUNTIME_DECLS\s*=\s*r?\"\"\"(.*?)\"\"\"", src, re.S | re.M) if mr: decls.append(mr.group(1)) except Exception: pass return decls def load_reference(ta: str): """-> (ref_elf_path, [ {name,ea,size,bytes,hash,mnems} ])""" ver = LIBDUMP_REF.get(ta) if ver is None: raise SystemExit(f"no reference build registered for type {ta!r}") # Pin the reference by HASH, not just version. MERIT ships two distinct 24.0.4.42 # builds; globbing on the version alone picked bc1706f8 while the libdump was recorded # from 444eceee, so 24 of 31 symbols landed on nothing and every MERIT build was capped # near 30% named. fw/syms/.meta.json records the exact image the symbols came from. want_hash = None meta = HERE / "syms" / f"{ta}.meta.json" if meta.exists(): mp = json.loads(meta.read_text()).get("path") or "" m = re.search(r"_([0-9a-f]{8})\.bin$", mp) if m: want_hash = m.group(1) cands = glob.glob(str(ELF_DIR / f"{ta}_{ver}_*.elf")) if want_hash: pinned = [c for c in cands if c.endswith(f"_{want_hash}.elf")] if pinned: cands = pinned else: print(" WARNING: symbols were recorded from %s but no matching ELF exists; " "falling back to %s" % (want_hash, os.path.basename(cands[0]) if cands else "nothing"), flush=True) if not cands: raise SystemExit(f"reference ELF missing: {ta}_{ver}_*.elf (run mkelf_ta.py --all)") refelf = sorted(cands)[0] body, va = elf_body(refelf) lib = json.loads((HERE / "libdumps" / f"{ta}.json").read_text()) # Apply LIB_RENAME to the SEED names as well. Renaming only the live databases made the # pool inconsistent: the seed still carried gcp_lib_cede31a8_14 while the processed # reference build contributed gcp_isdigit for the SAME body hash, so build_index saw one # hash mapping to two names, marked it ambiguous and refused to name it anywhere. That is # the whole of the 138-name regression -- ambiguity in build 22.0.0.53 went from 8 to 36 -- # and it was neither a rename defect nor a collision, which were my two earlier guesses. try: import importlib.util as _iu _sp = _iu.spec_from_file_location("_tid", HERE / "ta_ida.py") _tid = _iu.module_from_spec(_sp); _sp.loader.exec_module(_tid) _lr = getattr(_tid, "LIB_RENAME", {}) except Exception: _lr = {} out = [] for f in lib: name, size, ea = f.get("name"), f.get("size", 0), f.get("ea") if name and _lr: for _ph, _sem in _lr.items(): if name == "%s_%s" % (ta, _ph): name = "%s_%s" % (ta, _sem); break if name.startswith("%s_%s__h" % (ta, _ph)): name = name.replace("%s_%s" % (ta, _ph), "%s_%s" % (ta, _sem), 1); break if not name or size <= 0 or ea is None or name.startswith("sub_"): continue off = ea - va if off < 0 or off + size > len(body): continue b = body[off:off + size] out.append({ "name": name, "ea": ea, "size": size, "bytes": b, "hash": hashlib.sha256(b).hexdigest(), "mnems": mnems(body, va, ea, size), }) return refelf, out def build_index(ref): """Signature -> name, with signatures shared by different names marked ambiguous.""" by_hash, by_mnem = {}, {} for r in ref: by_hash.setdefault(r["hash"], set()).add(r["name"]) if r["mnems"]: by_mnem.setdefault(r["mnems"], set()).add(r["name"]) amb = {"hash": {k for k, v in by_hash.items() if len(v) > 1}, "mnem": {k for k, v in by_mnem.items() if len(v) > 1}} return by_hash, by_mnem, amb def match_build(ref, by_hash, by_mnem, amb, tgt_funcs, body, va): """-> {addr_hex: (name, tier)}, stats""" assigned: dict[str, tuple[str, str]] = {} used: set[str] = set() stats = {"EXACT": 0, "MNEM": 0, "FUZZY": 0, "none": 0, "ambiguous": 0} pending = [] for fn in tgt_funcs: ea, size = int(fn["addr"], 16), fn["size"] off = ea - va if off < 0 or size <= 0 or off + size > len(body): stats["none"] += 1 continue b = body[off:off + size] h = hashlib.sha256(b).hexdigest() if h in amb["hash"]: stats["ambiguous"] += 1 continue names = by_hash.get(h) if names: n = next(iter(names)) if n not in used: assigned[fn["addr"]] = (n, "EXACT"); used.add(n); stats["EXACT"] += 1 continue pending.append((fn, b, ea, size)) for fn, b, ea, size in pending: mm = mnems(body, va, ea, size) if mm and mm in amb["mnem"]: stats["ambiguous"] += 1 continue names = by_mnem.get(mm) if mm else None if names: n = next(iter(names)) if n not in used: assigned[fn["addr"]] = (n, "MNEM"); used.add(n); stats["MNEM"] += 1 continue best, score = None, 0.0 for r in ref: if r["name"] in used or not r["mnems"] or not mm: continue s = sim(mm, r["mnems"]) if s > score: best, score = r["name"], s if best and score >= THRESHOLD: assigned[fn["addr"]] = (best, "FUZZY"); used.add(best); stats["FUZZY"] += 1 else: stats["none"] += 1 return assigned, stats def ta_entry_points(body: bytes, va: int): """(marker, trampoline, create, invoke) from the image's own header. Layout per psp_emu/pspemu/ta_entries.py, which validated it 3/3 against independently known entries: header at the load base, +0x00 marker 0x200, +0x10 trampoline VA, then trampoline+0xA0 = TA_Create, +0xB0 = TA_InvokeCommand (Thumb bit set). """ marker = struct.unpack_from(" len(body): raise ValueError("trampoline %#x outside image" % tramp) create = struct.unpack_from(" dict: """address -> entry-point name, for all five slots.""" marker = struct.unpack_from(" len(body): return {} out = {} for slot, nm in ENTRY_SLOTS: ea = struct.unpack_from(" int: if imm12 >> 10 == 0: k, b = (imm12 >> 8) & 3, imm12 & 0xFF return [b, b << 16 | b, b << 24 | b << 8, b << 24 | b << 16 | b << 8 | b][k] un, rot = 0x80 | (imm12 & 0x7F), (imm12 >> 7) & 0x1F return ((un >> rot) | (un << (32 - rot))) & 0xFFFFFFFF def svc_stub_opcode(code: bytes): """The RPC opcode a per-module SVC stub loads for itself, or None. These stubs decompile to four uninformative lines -- "SVC 0xF2; return 2" -- and I recorded early on that they "carry no semantic information whatsoever". That is true of the SHARED thunks, where the caller sets the operation, and false of the per-module stubs, which load their own opcode and store it as the first word of the request frame. The rule is structural rather than statistical: track MOV/MOVW/MOVS immediates per register, then return the value of whichever register is stored to [SP, #0]. That word is the opcode, because R1 is pointed at SP and passed to the supervisor call. Validated against the 940 stubs the libdump already names *_rpc_N: **940 match, 0 disagree**, and it declines to answer on none of them. The first version of this function instead took the sole in-range immediate, which scored 296/299 and gave no answer on 102 stubs; the structural rule supersedes it because it is both stronger and complete. Picking the "first" immediate would have scored 4/4 on the multi-immediate cases, but all four were the same function in four builds, which is n=1 dressed up as n=4 and not evidence. """ if b"\xf2\xdf" not in code: return None reg, i, n = {}, 0, len(code) while i < n - 1: hw1 = struct.unpack_from("> 8) & 0xF] = _thumb_expand( ((hw1 >> 10) & 1) << 11 | ((hw2 >> 12) & 7) << 8 | (hw2 & 0xFF)) elif (hw1 & 0xFBF0) == 0xF240 and not hw2 & 0x8000: # MOVW Rd,#imm16 reg[(hw2 >> 8) & 0xF] = ((hw1 & 0xF) << 12 | ((hw1 >> 10) & 1) << 11 | ((hw2 >> 12) & 7) << 8 | (hw2 & 0xFF)) i += 4 continue if (hw1 & 0xF800) == 0xE800 and i + 3 < n: hw2 = struct.unpack_from("> 12) & 0xF if rt in reg: return reg[rt] i += 4 continue if (hw1 & 0xF800) == 0x2000: # MOVS Rd,#imm8 reg[(hw1 >> 8) & 7] = hw1 & 0xFF i += 2 continue if (hw1 & 0xF800) == 0x9000 and (hw1 & 0xFF) == 0: # STR Rt,[SP,#0] rt = (hw1 >> 8) & 7 if rt in reg: return reg[rt] i += 2 # Fall back to the count rule where no store to [SP,#0] is visible. Both rules are validated # independently -- structural 940/940, count 296/299 -- and the structural one is preferred # where it answers. Without this fallback the switch to the stronger rule LOST names: OPM went # from 75% to 72% and GCP from 91% to 90%, because those stubs build the frame in a way this # decoder does not follow. Caught by comparing per-module coverage before and after, not by the # corpus total, which went up and hid it. cand = [v for v in reg.values() if 0x80 <= v <= 0xFFFF] return cand[0] if len(cand) == 1 else None CASE_RE = re.compile(r"case\s+(0x[0-9A-Fa-f]+|\d+)\s*:") CALL_RE = re.compile(r"\b([A-Za-z_]\w*)\s*\(") def dispatch_map(client, remote_ops, body, va, funcs, max_depth=1): """command id -> handler address, recovered from the dispatcher's switch. Byte/mnemonic porting cannot name a handler whose code diverged, which is how gcp_cmdFE0C -- a function with a known exploitable bug -- ended up unnamed on six GCP builds. The dispatch table does not care about code similarity: whatever sits in the 0xFE0C arm *is* the 0xFE0C handler, by construction. Hex-Rays recovers the switch; parsing its output beats hand-rolling Thumb jump-table decoding. Callee tokens are resolved through the live function list rather than by assuming they look like sub_, so this works whether or not names were applied first. """ name2addr = {f["name"]: f["addr"] for f in funcs} try: _m, _t, _c, invoke = ta_entry_points(body, va) except ValueError as exc: return {}, "header: %s" % exc, None # Only the invoke entry and its DIRECT callees are candidate dispatchers. Walking deeper # swept up nested sub-command switches inside individual handlers and invented commands # that do not exist (a bogus cmd 0x0F for GCP, which has no such command). candidates = [] seen: set[str] = set() frontier = [(hex(invoke), 0)] while frontier: addr, depth = frontier.pop(0) if addr in seen or depth > max_depth: continue seen.add(addr) try: r = client.call(remote_ops.decompile, addr=addr, include_addresses=False) code = (r or {}).get("code") or "" except Exception: continue if not code: continue parts = CASE_RE.split(code) if len(parts) > 1: m: dict[str, str] = {} for i in range(1, len(parts) - 1, 2): cmd, blk = parts[i], parts[i + 1] for tok in CALL_RE.findall(blk): if tok in name2addr: m.setdefault("0x%x" % int(cmd, 0), name2addr[tok]) break if m: candidates.append((depth, len(m), addr, m)) if depth < max_depth: for tok in CALL_RE.findall(code): if tok in name2addr and name2addr[tok] not in seen: frontier.append((name2addr[tok], depth + 1)) if not candidates: return {}, "no switch found within depth %d of invoke" % max_depth, None # Take ONLY the richest switch at the shallowest depth. Merging every sibling switch at # that depth was wrong: a command handler reached from the entry has its own sub-command # switch, and its arms were absorbed as if they were top-level commands. That is how GCP # acquired phantom commands 0x8, 0x9, 0xA and 0xF -- which are sub-commands of 0xFE0A -- # and the name-versus-dispatcher audit is what exposed it. The handler names were right; # this map was wrong. best_depth = min(c[0] for c in candidates) at_depth = [c for c in candidates if c[0] == best_depth] at_depth.sort(key=lambda c: -c[1]) _d, _n, chosen, out = at_depth[0] return dict(out), None, chosen def named_declaration(sig: str, nm: str) -> str | None: """Turn a recorded prototype into a declaration IDA will accept. Handover prototypes are stored WITHOUT a function name, in two shapes: the obvious 'int __fastcall(args)' and the bare 'int()'. Only special-casing __fastcall left 205 of 239 ASD rejections -- every 'int()' became the unparsable 'int();'. The name goes before the first parenthesis in all cases; pseudo-file prototypes already carry it and are used as they are. """ sig = sig.strip().rstrip(";") if not sig: return None if re.search(r"\b%s\s*\(" % re.escape(nm), sig): return sig + ";" i = sig.find("(") if i < 0: return None head = sig[:i].rstrip() if not head: return None return "%s %s%s;" % (head, nm, sig[i:]) # Components whose dispatcher stages the host request in a local buffer, and the struct that # describes it. Declaring a struct only makes it available; nothing uses it until a variable # is typed with it, and for these the variable is a dispatcher local rather than an argument. # How each component's dispatcher exposes the host request, and the struct describing it. # ("frame", size) the request is COPIED into a stack buffer of that size (XGMI) # ("deref", argname) the dispatcher works on the host buffer directly via `v = *arg` # Declaring a struct only makes it available; nothing uses it until a variable is typed. STAGING = { "xgmi": ("struct xgmi_req", ("frame", 0xC20)), "oc": ("struct oc_req *", ("deref", "a4")), "wspe": ("struct wspe_req *", ("deref", "a4")), "hdcp": ("struct hdcp_req *", ("deref", "params")), "powerplay": ("struct powerplay_req *", ("deref", "a4")), "auc": ("struct auc_req *", ("deref", "a4")), "fp": ("struct fp_req *", ("deref", "a4")), "rap": ("struct rap_req *", ("deref", "a4")), "opm": ("struct opm_req *", ("deref", "a4")), "csu": ("struct csu_req *", ("deref", "a4")), "merit": ("struct merit_req *", ("deref", "a4")), "dtm": ("struct dtm_req *", ("deref", "a4")), } def type_staging_lvar(client, remote_ops, ta, dispatcher_addr): """Type the dispatcher's staging buffer. -> (ok, detail) The local is found by its FRAME SIZE, not its name: Hex-Rays numbers locals per build, so 'v12' in one version is something else in the next, while the staged request keeps the size the copy and the size check both use. """ spec = STAGING.get(ta) if not spec or not dispatcher_addr: return False, None decl, (mode, key) = spec try: code = (client.call(remote_ops.decompile, addr=dispatcher_addr, include_addresses=False) or {}).get("code") or "" except Exception as exc: return False, str(exc) if mode == "frame": # '_DWORD v12[3]; // [sp+8h] [bp-C20h] BYREF' m = re.search(r"\b(\w+)\s*\[\s*\d+\s*\]\s*;\s*//\s*\[sp\+[0-9A-Fa-f]+h\]\s*" r"\[bp-%Xh\]" % key, code) if not m: return False, "no local with frame size %#x" % key else: # 'v5 = *a4;' -- the local that holds the host request pointer. The argument's NAME # varies by build: where a prototype has been applied it is 'params', otherwise # Hex-Rays calls it 'a4'. Keying on one name typed only 2 of HDCP's 7 builds, so the # declared name is tried first and then every parameter of the function. body_code = "\n".join(l for l in code.splitlines() if not l.lstrip().startswith("//")) head = body_code[:body_code.find("{")] if "{" in body_code else body_code params_list = re.findall(r"(\w+)\s*(?:,|\))", head[head.find("(") + 1:]) \ if "(" in head else [] # ONLY the request argument is a candidate: the declared name, else the LAST # parameter, which is the params pointer in every TA entry seen here. Trying every # parameter matched an unrelated assignment and typed the wrong local. cands = [key] + ([params_list[-1]] if params_list else []) m = None for cand in cands: if not re.match(r"^[A-Za-z_]\w*$", cand or ""): continue # an applied prototype can introduce a cast: 'v5 = (int *)*params;' # the cast can sit on either side of the dereference: # 'v5 = (int *)*a4;' and 'v6 = *(unsigned int **)a4;' m = re.search(r"\b(\w+)\s*=\s*(?:\([^)]*\)\s*)?\*\s*(?:\([^)]*\)\s*)?%s\s*;" % re.escape(cand), code) if m: break if not m: return False, "no local assigned * (tried %s)" % ",".join( c for c in cands if c) var = m.group(1) try: r = client.call(remote_ops.set_lvar_type, addr=dispatcher_addr, variable=var, type=decl) return bool(r.get("ok")), r.get("error") or var except Exception as exc: return False, str(exc) ASD_STUB = (0xE1A00002, 0xE59FC010, 0xE12FFF3C, 0xEF000075) ASD_DISPATCH_OFF = 0x1C def asd_dispatcher(body: bytes, va: int): """ASD's RPC dispatcher, via the trampoline's +0x1C slot. ASD has no TA entry trampoline, so ta_entry_points does not apply. The stub shape is verified rather than the offset assumed, exactly as psp_emu/pspemu/ta_entries.py does -- if the layout ever changes, this returns nothing instead of a wrong address. """ def rd(addr, n=1): return struct.unpack_from("<%dI" % n, body, addr - va) try: if rd(va)[0] != 0x200: return None, "header marker is not 0x200" tramp = rd(va + 0x10)[0] if not (va <= tramp < va + len(body) - 0x20): return None, "trampoline %#x outside image" % tramp if rd(tramp, 4) != ASD_STUB: return None, "trampoline stub shape differs" return rd(tramp + ASD_DISPATCH_OFF)[0] & ~1, None except Exception as exc: return None, str(exc) def retype_arg(client, remote_ops, addr, fname, stype, idx=1): """Rewrite only the Nth parameter of a function's LIVE prototype (1-based). Replacing the whole declaration would discard the rest of the signature, which for the handover components came from recorded prototypes and is better than anything inferred here. """ try: r = client.call(remote_ops.decompile, addr=addr, include_addresses=False) or {} code_f = r.get("code") or "" if "(" not in code_f: return False body_c = "\n".join(l for l in code_f.splitlines() if not l.lstrip().startswith("//")) head = body_c[:body_c.find("{")] mm = re.match(r"\s*(.+?)\s+(\w+)\s*\((.*)\)\s*$", head.strip(), re.S) if not mm: return False ret, args = mm.group(1), mm.group(3) parts = [x.strip() for x in args.split(",")] if not parts or not parts[0] or parts[0] == "void": return False if idx < 1 or idx > len(parts): return False pn = re.findall(r"(\w+)\s*$", parts[idx - 1]) parts[idx - 1] = "%s%s" % (stype, pn[0] if pn else "a%d" % idx) decl = "%s %s(%s);" % (ret, fname, ", ".join(parts)) rr = client.call(remote_ops.set_type, edits=[{"addr": addr, "signature": decl}]) return bool((rr.get("result") or [{}])[0].get("ok")) except Exception: return False def retype_arg1(client, remote_ops, addr, fname, stype): """Backwards-compatible alias for the first parameter.""" return retype_arg(client, remote_ops, addr, fname, stype, 1) def cmd_label(ta: str, cmd: int) -> str: """Match the existing convention: gcp_cmd01, gcp_cmdFE0C.""" return "%s_cmd%s" % (ta, ("%02X" if cmd <= 0xFF else "%04X") % cmd) def thumb_bw_target(hw1: int, hw2: int): """Decode a 32-bit Thumb-2 B.W and return its pc-relative displacement. ASD's 4-byte thunks are Thumb B.W, not ARM B. An earlier attempt to decode them as ARM read 41 of them as top byte 0xB8, 37 as 0xBE and so on -- those are the SECOND halfword of a Thumb branch, which is how the wrong assumption announced itself. """ if (hw1 & 0xF800) != 0xF000 or (hw2 & 0xD000) != 0x9000: return None s_, imm10 = (hw1 >> 10) & 1, hw1 & 0x3FF j1, j2, imm11 = (hw2 >> 13) & 1, (hw2 >> 11) & 1, hw2 & 0x7FF v = (s_ << 24) | ((1 - (j1 ^ s_)) << 23) | ((1 - (j2 ^ s_)) << 22) \ | (imm10 << 12) | (imm11 << 1) return v - (1 << 25) if s_ else v def decompile_all(client, remote_ops, funcs, assigned, body=None, va=None): rows = [] for fn in funcs: name, tier = assigned.get(fn["addr"], (None, "none")) try: r = client.call(remote_ops.decompile, addr=fn["addr"], include_addresses=False) code = r.get("code") if isinstance(r, dict) else None if code is None: code = "/* decompile failed: %s */" % ( (r or {}).get("error", "no code returned")) except Exception as exc: code = "/* decompile failed: %s */" % exc # If porting gave no name, take the one the DECOMPILER states in the signature. # Hex-Rays resolves thunks and prints 'j_' even where the stored symbol is # still sub_*, so thousands of 4-byte ASD thunks carry a perfectly good derived name # that this pipeline was discarding and reporting as ANON. This is IDA's own thunk # resolution rather than an inference, so it is recorded as tier=THUNK to keep it # distinguishable from a ported name. if not name: msig = re.match(r"\s*(?:[\w \*]+?)\s+([A-Za-z_]\w*)\s*\(", "\n".join(l for l in code.splitlines() if not l.lstrip().startswith("//"))) cand = msig.group(1) if msig else None # A j_sub_* candidate is kept HERE on purpose, even though it is address-derived: # the re-resolution pass below needs the target address to recover a real name from # it. Anything still address-derived after that pass is dropped at the end, so the # artifacts never claim a name like sub_D07364_12 is one. A plain sub_* candidate # carries no target and is rejected immediately. if cand and (cand.startswith("j_") or not ta_metrics.is_addr_name(cand)): name, tier = cand, ("THUNK" if cand.startswith("j_") else "IDA") rows.append({"addr": fn["addr"], "size": fn["size"], "name": name, "tier": tier, "code": code, "fallback": fn["name"]}) # Late thunk re-resolution. The j_ name above is whatever Hex-Rays printed at the # moment that function was decompiled, so a thunk decompiled before its target was renamed # keeps a stale j_sub_D06CE4 even though the target now has a real name. That left 87 ASD # thunks carrying an address-derived name -- which also quietly inflated the "named" count, # since the name field was set. # # Rewriting from `assigned` rather than re-decompiling keeps this a pure lookup: the target # name is one this pipeline already decided on, so no new inference is introduced. Thunks # whose target is still unnamed are left alone, because j_sub_* is the honest answer there. # The address-name test lives in ta_metrics so it cannot drift between the pipeline, the # repair pass and the audit; it previously drifted in all three at once. real = {a.lower(): nm for a, (nm, _t) in assigned.items() if ta_metrics.is_meaningful(nm)} used_names = {r["name"] for r in rows if r["name"]} fixed = 0 for row in rows: m = re.match(r"^j_sub_([0-9A-Fa-f]+)(?:_\d+)?$", row["name"] or "") if not m: continue tgt = real.get("0x" + m.group(1).lower()) if tgt: # Uniquify: many thunks share a target, and a colliding rename is refused, which # would leave the banner and the body disagreeing. On the ASD reference only 55 of # 786 reconciled before this was added. base, nm, k = "j_" + tgt, "j_" + tgt, 2 while nm in used_names: nm, k = "%s_%d" % (base, k), k + 1 used_names.add(nm) row["name"] = nm fixed += 1 if fixed: print(" thunk names re-resolved after target naming: %d" % fixed) # Final honesty pass: whatever is still an IDA address default is not a name. These rows # become anonymous, which puts them in the .anon.c file and out of the named count, instead # of inflating coverage with 1,295 entries that tell a reader nothing. dropped = 0 for row in rows: if ta_metrics.is_addr_name(row["name"]): row["name"], row["tier"], dropped = None, "none", dropped + 1 if dropped: print(" address-derived names dropped as uninformative: %d" % dropped) # Thunks that Hex-Rays did not label at all. Runs AFTER the drop above, deliberately: before # it, these rows still carry the stale j_sub_ label and so look named, which is why the # first version of this pass resolved nothing. The re-resolution only repairs a name # that already reads j_sub_; 909 ASD thunks had NO name, so nothing matched them even # though their target is properly named. Decoding the branch from the ELF settles where each # one goes without asking the decompiler, which makes this a resolution and not an inference, # the same justification the existing THUNK tier rests on. by_ea = {} if body is not None and va is not None: by_ea = {int(r["addr"], 16): r for r in rows} taken = {r["name"] for r in rows if r["name"]} taken |= used_names resolved = 0 for row in rows: if row["name"] or row["size"] != 4: continue off = int(row["addr"], 16) - va if off < 0 or off + 4 > len(body): continue hw1, hw2 = struct.unpack_from(" is not # unique and a plain rename is refused on collision -- which left the banner and # the body disagreeing. Suffix the duplicates instead. The suffix here is honest: # these really are separate functions with the same destination, unlike the # address-derived _N suffixes that were dropped earlier. base = "j_" + t["name"] nm, k = base, 2 while nm in taken: nm, k = "%s_%d" % (base, k), k + 1 taken.add(nm) row["name"], row["tier"] = nm, "THUNK" resolved += 1 if resolved: print(" 4-byte thunks resolved by decoding Thumb B.W: %d" % resolved) # Reconcile the artifact with itself. Both thunk passes above set row["name"] in this # script's metadata only, so the banner could read j_asd_clear_link_outputs while the # signature inside the body still read j_sub_D07E10_16 -- 1,018 functions disagreed with # their own banner. A reader seeing those two differ cannot tell which to trust, which is # worse than leaving the function anonymous. # # So push every derived name into the database and re-decompile the ones that still # disagree. Names are uniquified first because several distinct thunks can branch to the # same target, and a colliding rename is silently refused. SIGNAME = re.compile(r"^\s*[\w \*]+?\s+([A-Za-z_]\w*)\s*\(", re.M) def _body_name(code): txt = "\n".join(l for l in (code or "").splitlines() if not l.lstrip().startswith(("//", "/*"))) m = SIGNAME.search(txt) return m.group(1) if m else None stale = [r for r in rows if r["name"] and _body_name(r["code"]) not in (None, r["name"])] if stale: try: client.call(remote_ops.rename, batch={"func": [{"addr": r["addr"], "name": r["name"]} for r in stale], "allow_overwrite": False}) agreed = 0 for r in stale: rr = client.call(remote_ops.decompile, addr=r["addr"], include_addresses=False) code = rr.get("code") if isinstance(rr, dict) else None if code: r["code"] = code if _body_name(code) == r["name"]: agreed += 1 print(" banner/body reconciled: %d of %d" % (agreed, len(stale))) except Exception as exc: print(" banner/body reconcile failed: %s" % exc) return rows REF_TOKEN = re.compile(r"_(?:[0-9A-Fa-f]{6,8})(?:__h\d+)?$|_[0-9A-Fa-f]{8}_") def _banner(fh, meta, nrows, nnamed, nanon, kind): fh.write("/* %s %s (%s) -- %s\n" % (meta["type"], meta["version"], meta["hash8"], kind)) fh.write(" * generated by fw/ta_re.py -- do not edit\n") fh.write(" * reference: %s\n" % meta["reference"]) fh.write(" * functions: %d total in build, %d named, %d anonymous\n" % (nrows, nnamed, nanon)) fh.write(" * porting tiers: %s\n" % json.dumps(meta["stats"], sort_keys=True)) fh.write(" *\n" " * Addresses are omitted and functions are ordered by NAME so that the same\n" " * function lines up across versions. See the .index.json sidecar for\n" " * name -> address.\n" " *\n" " * CAUTION on ported names: a name is only an identity claim at tier=EXACT.\n" " * At tier=MNEM/FUZZY the bytes differ from the reference, so any ADDRESS or\n" " * CONTENT HASH embedded in the name (e.g. *_4025F8, gcp_lib__*) still\n" " * refers to the REFERENCE build and does not describe this one.\n */\n\n") def emit_c(path: Path, meta: dict, rows: list): """Named functions and anonymous ones go to separate files. Mixing them defeats the purpose: a function named in build A but anonymous in build B reorders everything after it, so a two-build comparison came out as ~2900 diff lines when only ~48 functions had actually changed. The named file is the one worth diffing. """ named = [r for r in rows if r["name"]] anon = [r for r in rows if not r["name"]] named.sort(key=lambda r: r["name"]) # sub_* numbering is build-specific and meaningless across versions; order by shape anon.sort(key=lambda r: (r["size"], hashlib.sha256(r["code"].encode()).hexdigest())) with path.open("w") as fh: _banner(fh, meta, len(rows), len(named), len(anon), "named functions") for r in named: flag = "" if r["tier"] == "EXACT" or not REF_TOKEN.search(r["name"]) \ else " REF-DERIVED-NAME" fh.write("/* ==== %s [tier=%s size=%d]%s ==== */\n" % (r["name"], r["tier"], r["size"], flag)) fh.write(r["code"].rstrip() + "\n\n") apath = path.with_suffix(".anon.c") with apath.open("w") as fh: _banner(fh, meta, len(rows), len(named), len(anon), "UNNAMED functions") fh.write("/* These did not match the reference above the %.2f similarity floor and are\n" " * deliberately left unnamed rather than guessed. Ordered by size then body\n" " * hash, because sub_* numbering is not stable across builds.\n */\n\n" % THRESHOLD) for r in anon: fh.write("/* ==== ANON size=%d ==== */\n" % r["size"]) fh.write(r["code"].rstrip() + "\n\n") idx = {"type": meta["type"], "version": meta["version"], "hash8": meta["hash8"], "stats": meta["stats"], "functions": [{"name": r["name"], "addr": r["addr"], "size": r["size"], "tier": r["tier"], "ida_name": r["fallback"]} for r in rows]} path.with_suffix(".index.json").write_text(json.dumps(idx, indent=1, sort_keys=True)) def run_type(ta: str, outdir: Path, limit: int | None, validate_only: bool) -> int: from idatui import remote_ops from idatui.nexus_client import NexusClient protos: dict[str, str] = {} fn_comments: dict[str, str] = {} standalone_comments: dict[str, str] = {} ref_is_corpus = True if ta in HANDOVER_REF: refelf, seed_ref, protos, fn_comments = load_handover_reference(ta) ref_is_corpus = False # the reference module is not one of the corpus builds try: standalone_comments = json.loads( (REPO / "psp_out" / "handover" / "symbols" / f"{ta}.json").read_text() ).get("comments") or {} except Exception: standalone_comments = {} else: refelf, seed_ref = load_reference(ta) protos = pseudo_prototypes(ta) type_decls = load_type_decls(ta) print(" types: %d declarations, %d prototypes, %d function comments" % (len(type_decls), len(protos), len(fn_comments)), flush=True) _bh, _bm, _amb = build_index(seed_ref) print("type %s: reference %s, %d named reference functions " "(%d ambiguous byte sigs, %d ambiguous mnem sigs)" % (ta, os.path.basename(refelf), len(seed_ref), len(_amb["hash"]), len(_amb["mnem"])), flush=True) builds = sorted(glob.glob(str(ELF_DIR / f"{ta}_*.elf"))) vers = {} for b in builds: m = re.match(r"^(.+?)_([0-9.]+)_([0-9a-f]{8})\.elf$", os.path.basename(b)) if m: vers[b] = m.group(2) # CHAINED ORDER: grow outward from the reference, always taking the unprocessed build # closest to something already named. Porting every build from the single distant # reference left the 22.0.0.62-77 branch at 52% named; adjacent builds share far more # exact bytes, so each one is seeded from its nearest already-processed neighbour. order = [refelf] remaining = [b for b in builds if b != refelf and b in vers] if not ref_is_corpus: vers[refelf] = "0.0.0.0" # unknown; leave chain growth to version order below while remaining: nxt = min(remaining, key=lambda b: min(vdist(vers[b], vers[d]) for d in order)) order.append(nxt) remaining.remove(nxt) if limit: order = order[:limit + 1] outdir.mkdir(parents=True, exist_ok=True) gate_ok = None summary = [] # reference pool: the seed libdump plus every build processed so far pool = [{"ver": vers.get(refelf, LIBDUMP_REF.get(ta, "0.0.0.0")), "funcs": seed_ref, "src": os.path.basename(refelf)}] for path in order: name = os.path.basename(path) m = re.match(r"^(.+?)_([0-9.]+)_([0-9a-f]{8})\.elf$", name) if not m and path != refelf: print(" SKIP unparsable name %s" % name); continue _t, ver, h8 = m.groups() if m else (ta, "reference", f"{ta}2") # Union of every source processed so far, nearest version first. The union is what # lifts coverage: a function unchanged since an old build matches EXACT against that # build even when the reference has diverged. srcs = sorted(pool, key=lambda s: vdist(ver, s["ver"])) ref = [r for s in srcs for r in s["funcs"]] by_hash, by_mnem, amb = build_index(ref) nearest = srcs[0]["src"] if srcs else "-" body, va = elf_body(path) client = NexusClient(path) try: client.connect(progress=None) funcs, off = [], 0 while True: r = client.call(remote_ops.list_funcs, queries=[{"count": 500, "offset": off}]) blk = r["result"][0] funcs.extend(blk["data"]) off = blk["next_offset"] if off >= blk["total"]: break # On the reference build, create functions at any recorded symbol address that # auto-analysis did not define. Without this the seed pool silently shrinks to # whatever IDA happened to find: MERIT contributed only 7 of its 31 recorded # symbols, capping every MERIT build at ~30% named. if path == refelf: starts0 = {int(f["addr"], 16) for f in funcs} missing = sorted({r["ea"] for r in ref} - starts0) if missing: try: client.call(remote_ops.define_func, items=[{"addr": hex(e)} for e in missing]) except Exception as exc: print(" define_func failed: %s" % exc, flush=True) funcs, off = [], 0 while True: r = client.call(remote_ops.list_funcs, queries=[{"count": 500, "offset": off}]) blk = r["result"][0] funcs.extend(blk["data"]) off = blk["next_offset"] if off >= blk["total"]: break recovered = len({int(f["addr"], 16) for f in funcs} & set(missing)) print(" defined %d/%d missing reference functions" % (recovered, len(missing)), flush=True) assigned, stats = match_build(ref, by_hash, by_mnem, amb, funcs, body, va) # Dispatcher-anchored naming. Independent of code similarity, so it both fills # the gaps porting cannot reach and cross-checks the names porting did apply. dmap, derr, disp_addr = dispatch_map(client, remote_ops, body, va, funcs) if not dmap: # Some TAs put the switch one level deeper (a thin invoke -> guard -> # dispatcher chain). Only fall back when depth 1 found nothing at all, so # the usual case cannot pick up nested sub-command switches. dmap, derr, disp_addr = dispatch_map(client, remote_ops, body, va, funcs, max_depth=2) if dmap: derr = None stats["DISPATCH"] = 0 stats["dispatch_conflict"] = 0 conflicts = [] # One handler commonly serves several commands (GCP routes 0xFE03-0xFE07 to one # function), so group by address before naming or judging agreement. by_addr: dict[str, list[int]] = {} for cmd, haddr in dmap.items(): by_addr.setdefault(haddr, []).append(int(cmd, 0)) for haddr, cmds in sorted(by_addr.items()): cmds.sort() want = cmd_label(ta, cmds[0]) have = assigned.get(haddr, (None, None))[0] if have is None: assigned[haddr] = (want, "DISPATCH") stats["DISPATCH"] += 1 stats["none"] = max(0, stats["none"] - 1) continue # agreement = the ported name mentions ANY command this handler serves if any(cmd_label(ta, c).lower() in have.lower() for c in cmds): continue # Only a name that itself claims a command can CONTRADICT the dispatcher. # HDCP's symbols are not named _cmdNN at all, and treating every such # name as a conflict reported 16 of them on a build with 16 commands. claim = re.search(r"_cmd([0-9A-Fa-f]+)", have) if not claim: continue stats["dispatch_conflict"] += 1 conflicts.append(("/".join("%#x" % c for c in cmds), haddr, have, want)) if derr: print(" dispatch unavailable (%s)" % derr, flush=True) for cmd, haddr, have, want in conflicts: print(" CONFLICT cmd %s @%s ported=%s dispatch=%s" % (cmd, haddr, have, want), flush=True) is_ref = (path == refelf) if is_ref: # The gate must test the MATCHER, not IDA's function discovery. Dividing by # every reference symbol failed types where auto-analysis simply does not # define a function at a recorded address (TMZ scored 17% that way, with a # perfectly sound matcher). Denominator is therefore the reference functions # that actually exist as function starts here and are not ambiguous. starts = {int(f["addr"], 16) for f in funcs} eligible = [r for r in ref if r["ea"] in starts and r["hash"] not in amb["hash"]] denom = max(1, len(eligible)) exact_rate = stats["EXACT"] / denom gate_ok = exact_rate >= 0.95 orphan = sum(1 for r in ref if r["ea"] not in starts) print(" GATE self-port %s: EXACT %d/%d eligible (%.0f%%) " "[%d ref syms, %d not a function start here] -> %s" % (ver, stats["EXACT"], denom, 100 * exact_rate, len(ref), orphan, "PASS" if gate_ok else "FAIL -- type is VOID"), flush=True) # The eligible-only denominator makes the gate robust to IDA discovery gaps, # but that also makes it blind to the reference ELF being the WRONG BUILD -- # which shows up as most symbols landing on nothing, not as a bad match rate. if len(ref) and orphan / len(ref) > 0.10: print(" WARNING: %d/%d (%.0f%%) reference symbols are not function " "starts in the reference build -- suspect a build/symbol mismatch" % (orphan, len(ref), 100 * orphan / len(ref)), flush=True) if not gate_ok: return 2 if validate_only: return 0 if assigned: items = [{"addr": a, "name": n} for a, (n, _t2) in assigned.items()] rr = client.call(remote_ops.rename, batch={"func": items, "allow_overwrite": False}) # rename returns {"func":[...], "summary":{"ok":N,"failed":M}}; the old # reading looked for a "result" list that never exists, so this counter # reported zero errors unconditionally and hid every failed rename. errs = int((rr.get("summary") or {}).get("failed", 0)) if errs: print(" %d rename errors" % errs, flush=True) # 1. TYPES FIRST. A prototype that names a struct is rejected if the struct is # not in the database yet, so declaration order decides how many prototypes # actually land. One parse unit: separate parses cannot see each other's types. ntypes = 0 if type_decls: try: rr = client.call(remote_ops.declare_type, decls=["\n".join(type_decls)]) ntypes = sum(1 for r in (rr.get("result") or []) if r.get("ok")) if not ntypes: # one bad declaration voids the batch; fall back rr = client.call(remote_ops.declare_type, decls=type_decls) ntypes = sum(1 for r in (rr.get("result") or []) if r.get("ok")) except Exception as exc: print(" declare_type failed: %s" % exc, flush=True) # 2a. Command-anchored prototypes and names. Resolved through the dispatcher by # COMMAND ID rather than by name, so the two disagreeing naming generations in # this tree cannot cause a miss (or, worse, a confident mislabel). ncmdproto = nrelabel = 0 cprotos = cmd_prototypes(ta) if ta not in HANDOVER_REF else {} if cprotos and dmap: cedits, renames = [], [] for cmd_s, haddr in dmap.items(): ent = cprotos.get(int(cmd_s, 0)) if not ent: continue sem, sig = ent decl = named_declaration(sig, sem) if decl: cedits.append({"addr": haddr, "signature": decl}) have = assigned.get(haddr, (None, None))[0] claim = CMD_IN_NAME.search(have or "") if have and claim and int(claim.group(1), 16) != int(cmd_s, 0): # the existing name claims a DIFFERENT command than the dispatcher renames.append({"addr": haddr, "name": sem}) assigned[haddr] = (sem, "DISPATCH") if cedits: try: rr = client.call(remote_ops.set_type, edits=cedits) ncmdproto = sum(1 for r in (rr.get("result") or []) if r.get("ok")) except Exception: pass if renames: try: rr = client.call(remote_ops.rename, batch={"func": renames, "allow_overwrite": True}) nrelabel = len(renames) for r_ in renames: print(" RELABEL %s -> %s (dispatcher)" % (r_["addr"], r_["name"]), flush=True) except Exception: pass stats["cmd_protos"] = ncmdproto stats["relabelled"] = nrelabel # 2b. Prototypes matched by name. nproto = nproto_try = 0 if protos: edits = [] for addr, (nm, _t3) in assigned.items(): sig = protos.get(nm) if not sig: continue decl = named_declaration(sig, nm) if decl: edits.append({"addr": addr, "signature": decl}) if edits: nproto_try = len(edits) try: rr = client.call(remote_ops.set_type, edits=edits) nproto = sum(1 for r in (rr.get("result") or []) if r.get("ok")) except Exception as exc: print(" set_type failed: %s" % exc, flush=True) # 3. Comments -- the actual RE notes, the most valuable annotation in the tree. # Function comments are keyed by NAME so they follow a function across builds; # the standalone address-keyed set is only meaningful on the reference module. ncmt = 0 citems = [] for addr, (nm, _t4) in assigned.items(): c = fn_comments.get(nm) if c: citems.append({"addr": addr, "comment": c}) if path == refelf and standalone_comments: for k, v in standalone_comments.items(): if isinstance(v, str) and v.strip(): citems.append({"addr": "0x" + k.lstrip("0x"), "comment": v}) if citems: try: rr = client.call(remote_ops.set_comments, items=citems) rows_ = rr.get("result") or [] ncmt = sum(1 for r in rows_ if not r.get("error")) except Exception as exc: print(" set_comments failed: %s" % exc, flush=True) if ntypes or nproto_try or ncmt or ncmdproto: print(" types=%d prototypes=%d/%d cmd-anchored=%d relabelled=%d " "comments=%d" % (ntypes, nproto, nproto_try, ncmdproto, nrelabel, ncmt), flush=True) stats["types"] = ntypes stats["protos"] = nproto stats["comments"] = ncmt # Retype the first argument of the functions whose a1 is a known internal # structure. ASD's dereferences are concentrated: 86% of its a1 accesses are in # ~20 functions forming two families, so typing those converts far more lines than # the dispatcher did. The rest of each signature is preserved by rewriting only the # first parameter of the LIVE prototype rather than inventing a whole declaration. # Re-apply the durable call-graph name map before anything else reads names. # fw/ta_cgname.py writes it to fw/cgnames/.json precisely so that rebuilding a # database does not silently discard those names, which has already happened. ncg = 0 cgf = HERE / "cgnames" / f"{ta}.json" if cgf.exists(): try: cgmap = json.loads(cgf.read_text()).get(name.replace(".elf", ""), {}) except Exception: cgmap = {} if cgmap: have = {f["addr"] for f in funcs} items = [{"addr": ad, "name": nm2} for ad, nm2 in cgmap.items() if ad in have] if items: try: rr = client.call(remote_ops.rename, batch={"func": items, "allow_overwrite": False}) ncg = int((rr.get("summary") or {}).get("ok", 0)) print(" call-graph names re-applied: %d/%d" % (ncg, len(items)), flush=True) except Exception as exc: print(" cgname re-apply failed: %s" % exc, flush=True) # The five GP entry points, from the image header. This is the strongest naming # evidence available for these addresses -- the loader itself uses these slots -- so # it is applied for every module, but only where porting left the address unnamed, so # a genuine ported name is never overwritten. Any disagreement is printed rather than # silently resolved. try: ents = ta_entry_names(body, va, ta) if ents: cur = {f["addr"].lower(): (assigned.get(f["addr"], (None,))[0]) for f in funcs} # Do not introduce a SECOND holder of a role. RAS's libdump already names # TA_CreateEntryPoint and TA_DestroyEntryPoint, at addresses the header # assigns to OpenSession and CloseSession -- a systematic two-slot # disagreement. Applying the header names there would leave the artifact # asserting two different Create entry points, which is worse than either # answer alone. Where a role is already claimed, the conflict is reported # and nothing is written; 18 RAS instances take this path. roles_taken = {} for f in funcs: nmx = assigned.get(f["addr"], (None,))[0] or "" m_ = re.search(r"TA_\w+EntryPoint", nmx) if m_: roles_taken[m_.group(0)] = nmx todo, conflict = [], [] for ad, nm3 in ents.items(): if ad not in cur: continue role = nm3.split("%s_" % ta, 1)[-1] have_nm = cur[ad] if not have_nm and role in roles_taken: conflict.append((ad, "role held by " + roles_taken[role], nm3)) elif not have_nm: todo.append({"addr": ad, "name": nm3}) roles_taken[role] = nm3 elif nm3.split("_TA_")[-1] not in have_nm: conflict.append((ad, have_nm, nm3)) if todo: rr = client.call(remote_ops.rename, batch={"func": todo, "allow_overwrite": False}) nok = int((rr.get("summary") or {}).get("ok", 0)) for it in todo: assigned[it["addr"]] = (it["name"], "ENTRY") print(" GP entry points named from header: %d/%d" % (nok, len(todo)), flush=True) for ad, a_, b_ in conflict: print(" ENTRY conflict %s ported=%s header=%s" % (ad, a_, b_), flush=True) except Exception as exc: print(" entry-point naming failed: %s" % exc, flush=True) # Per-module SVC stubs, named from the opcode they load for themselves. try: taken_rpc = {(assigned.get(f["addr"], (None,))[0] or "") for f in funcs} todo_rpc = [] for f in funcs: if assigned.get(f["addr"], (None,))[0]: continue sz = f.get("size") or 0 if sz < 8 or sz > 200: continue o = int(f["addr"], 16) - va if o < 0 or o + sz > len(body): continue op = svc_stub_opcode(body[o:o + sz]) if op is None: continue # Prefix matches the libdump's own convention, sysdrv_rpc_, not a new # one. ta_diff.py showed why this matters: with "_rpc_3" here and # "_sysdrv_rpc_3" in the libdump, a cross-version diff reported 8 # functions added and 8 removed between 22.0.4.49 and 22.0.4.50 when nothing # had changed except which naming pass reached them first. The decoded # numbers already agree with the libdump's digits exactly. base = "%s_sysdrv_rpc_%X" % (ta, op) nm4, k = base, 2 while nm4 in taken_rpc: nm4, k = "%s_%d" % (base, k), k + 1 taken_rpc.add(nm4) todo_rpc.append({"addr": f["addr"], "name": nm4}) if todo_rpc: rr = client.call(remote_ops.rename, batch={"func": todo_rpc, "allow_overwrite": False}) nok = int((rr.get("summary") or {}).get("ok", 0)) for it in todo_rpc: assigned[it["addr"]] = (it["name"], "SVCOP") print(" SVC stubs named from their own opcode: %d/%d" % (nok, len(todo_rpc)), flush=True) except Exception as exc: print(" SVC stub naming failed: %s" % exc, flush=True) # Shared-runtime arg1 typing, for every module. Keyed on the content-hash name # fragment so it follows the byte-identical cluster across modules and versions. try: import importlib.util as _ilu0 _sp0 = _ilu0.spec_from_file_location("_ti0", HERE / "ta_ida.py") _ti0 = _ilu0.module_from_spec(_sp0); _sp0.loader.exec_module(_ti0) shared = getattr(_ti0, "SHARED_ARG1", []) except Exception: shared = [] nshared = 0 # Key on BODY HASH, not on the name. Matching name fragments tied this typing to # the names LIB_RENAME upgrades, so enabling the rename silently un-typed these # functions. Content identity is what established the cluster, and it survives any # rename. try: fmt_hashes = set(getattr(_ti0, "TEE_FMT_HASHES", ())) except Exception: fmt_hashes = set() if fmt_hashes: for fn0 in funcs: ad = fn0["addr"] off0 = int(ad, 16) - va if off0 < 0 or fn0["size"] <= 0 or off0 + fn0["size"] > len(body): continue h0 = hashlib.sha256(body[off0:off0 + fn0["size"]]).hexdigest() if h0 not in fmt_hashes: continue nm0 = assigned.get(ad, (None, None))[0] or fn0["name"] if retype_arg1(client, remote_ops, ad, nm0, "struct tee_fmt_state *"): nshared += 1 elif shared: for ad, (nm0, _t0) in list(assigned.items()): stype = next((st for frag, st in shared if frag in nm0), None) if not stype: continue if retype_arg1(client, remote_ops, ad, nm0, stype): nshared += 1 if nshared: print(" shared runtime arg1 typed on %d functions" % nshared, flush=True) # Any module that declares a name-keyed arg1 map, not just ASD. if ta in getattr(_ti0, "ARG1_BY_TA", {}): try: _ti = _ti0 _mapname = getattr(_ti, "ARG1_BY_TA", {}).get(ta) want = getattr(_ti, _mapname, {}) if _mapname else {} except Exception: want = {} a2n = {nm: ad for ad, (nm, _tt) in assigned.items()} nret = 0 for fname, stype in want.items(): ad = a2n.get(fname) if not ad: continue # Use the shared helper. This block used to carry its own inline copy of # the rewrite that did NOT strip comment lines before parsing the head, so # on any function carrying a recorded comment the parse ran across the # comment and produced a declaration IDA rejected. That silently cost the # single largest typing target in the corpus: asd_program_link_by_signal # kept 504 raw dereferences while reporting as "applied". if retype_arg1(client, remote_ops, ad, fname, stype): nret += 1 if want: print(" %s arg1 structs applied to %d/%d functions" % (ta, nret, len(want)), flush=True) # Semantic name corrections, applied by name so they hold for every build. try: fixes = dict(getattr(_ti0, "RENAME_FIX", {}).get(ta, {})) except Exception: fixes = {} # Content-hash placeholders upgraded to the real name, for every module. The # mapping is module-independent, so it is expanded with this module's prefix. # LIB_RENAME is now ON. It was gated off last pass because applying it cost ~1175 # typed field accesses: the shared-runtime typing keyed on NAME fragments, so the # rename moved those functions out from under the rule that typed their argument. # That typing is now keyed on body hash instead, and the trade is gone -- every # affected module gains on BOTH counts (gcp 82.5%->83.6% share with placeholders # 389->143, wspe 91.3%->92.6% / 252->133, powerplay 65.3%->70.7% / 186->60, # xgmi 69.1%->71.0% / 100->55). if True: try: for _ph, _sem in getattr(_ti0, "LIB_RENAME", {}).items(): fixes["%s_%s" % (ta, _ph)] = "%s_%s" % (ta, _sem) for _h in range(1, 6): fixes["%s_%s__h%d" % (ta, _ph, _h)] = \ "%s_%s__h%d" % (ta, _sem, _h) except Exception: pass if fixes: nfix = 0 # Names can arrive from porting OR from the durable call-graph map, and the # fix must see both: looking only at `assigned` left four builds carrying the # old FE0C name because their copy was named by the map instead. byname2 = {nm5: ad5 for ad5, (nm5, _t5) in assigned.items()} try: for _ad, _nm in (cgmap or {}).items(): byname2.setdefault(_nm, _ad) except Exception: pass # Two classes, and they need different collision policy. The explicit # RENAME_FIX corrections target a name nothing else holds, so forcing is safe. # The LIB_RENAME upgrades map several placeholder clusters onto shared library # names, and forcing those STRIPS the name from whichever function already held # it -- that is what cost 138 names across gcp, powerplay, wspe and xgmi, in # proportion to the number of renames applied. Upgrades are therefore refused # on collision rather than allowed to steal. explicit = set(getattr(_ti0, "RENAME_FIX", {}).get(ta, {})) items5 = [] items5_soft = [] for old, new in fixes.items(): ad5 = byname2.get(old) if ad5: (items5 if old in explicit else items5_soft).append( {"addr": ad5, "name": new}) # the address may have come from the call-graph map rather than from # porting, in which case it has no `assigned` entry yet assigned[ad5] = (new, assigned.get(ad5, (None, "IDA"))[1]) for batch_, force in ((items5, True), (items5_soft, False)): if not batch_: continue try: rr5 = client.call(remote_ops.rename, batch={"func": batch_, "allow_overwrite": force}) nfix += int((rr5.get("summary") or {}).get("ok", 0)) except Exception: pass if items5 or items5_soft: print(" semantic name fixes applied: %d/%d (%d forced, %d soft)" % (nfix, len(items5) + len(items5_soft), len(items5), len(items5_soft)), flush=True) # Structures that arrive as an argument other than the first. # Structures that arrive as an argument other than the first. try: argn = getattr(_ti0, "ARGN_BY_TA", {}).get(ta, {}) except Exception: argn = {} if argn: a2n2 = {nm: ad for ad, (nm, _t9) in assigned.items()} nargn = 0 for fname, (idx, stype3) in argn.items(): ad5 = a2n2.get(fname) if not ad5: continue if retype_arg(client, remote_ops, ad5, fname, stype3, idx): nargn += 1 if argn: print(" %s argN structs applied to %d/%d functions" % (ta, nargn, len(argn)), flush=True) # Generic signature-based a1 typing, driven by SIG_BY_TA. A candidate must have # EVERY a1 offset inside the structure's declared FIELDS (padding excluded, because # an access into padding means the layout does not describe that function), touch at # least min_distinct of them, and include at least one distinctive offset. # # HDCP's rule was measured back when hdcp2_session was derived -- 3 unnamed # functions, 135 dereferences, zero false positives among the named -- and then # never wired in. Those dereferences stayed raw in the three builds where # hdcp2_rx_RepeaterAuth_ReceiverID_List is unnamed, which no name-keyed rule can # reach. try: sig_spec = getattr(_ti0, "SIG_BY_TA", {}).get(ta) except Exception: sig_spec = None if sig_spec: sname, stype2, mind, distinct = sig_spec mstruct = re.search(r"struct %s \{(.*?)\};" % re.escape(sname), (HERE / "ta_ida.py").read_text(), re.S) cov = set() if mstruct: cur2 = 0 for mm in re.finditer( r"(unsigned int|unsigned short|unsigned char)\s+(\w+)" r"(?:\[(\d+)\])?;", mstruct.group(1)): wd = {"unsigned int": 4, "unsigned short": 2, "unsigned char": 1}[mm.group(1)] cnt = int(mm.group(3)) if mm.group(3) else 1 if not mm.group(2).startswith("pad"): cov |= set(range(cur2, cur2 + wd * cnt)) cur2 += wd * cnt already = set() try: _mn = getattr(_ti0, "ARG1_BY_TA", {}).get(ta) already = set(getattr(_ti0, _mn, {})) if _mn else set() except Exception: already = set() nsig2 = 0 if cov: for fn4 in funcs: ad4 = fn4["addr"] nm4 = assigned.get(ad4, (None, None))[0] or fn4["name"] if nm4 in already: continue try: rr4 = client.call(remote_ops.decompile, addr=ad4, include_addresses=False) or {} cf4 = rr4.get("code") or "" except Exception: continue offs4 = {int(x, 0) for x in re.findall( r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*a1\s*\+\s*" r"(\d+|0x[0-9A-Fa-f]+)\s*\)", cf4)} if len(offs4) < mind or not offs4 <= cov: continue if not (offs4 & set(distinct)): continue if retype_arg1(client, remote_ops, ad4, nm4, stype2): nsig2 += 1 if nsig2: print(" %s signature-typed a1 on %d functions" % (ta, nsig2), flush=True) # Signature-based a1 typing for functions no name-keyed rule reaches. # Signature+size typing for the shared formatting state, every module. try: fmt_off = set(_ti0.FMT_SIG_OFFSETS); fmt_sz = set(_ti0.FMT_SIG_SIZES) fmt_ty = _ti0.FMT_SIG_TYPE; fmt_min = _ti0.FMT_SIG_MIN_DEREFS except Exception: fmt_off, fmt_sz, fmt_ty = set(), set(), None nfmt = 0 if fmt_off and fmt_ty: for fn3 in funcs: if fn3["size"] not in fmt_sz: continue ad3 = fn3["addr"] nm3 = assigned.get(ad3, (None, None))[0] or fn3["name"] if any(fr in nm3 for fr, _st in shared): continue # already typed by name try: rr3 = client.call(remote_ops.decompile, addr=ad3, include_addresses=False) or {} cf3 = rr3.get("code") or "" except Exception: continue hits3 = re.findall(r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*a1\s*\+\s*" r"(\d+|0x[0-9A-Fa-f]+)\s*\)", cf3) if len(hits3) < fmt_min: continue offs3 = {int(x, 0) for x in hits3} if not offs3 or not offs3 <= fmt_off: continue if retype_arg1(client, remote_ops, ad3, nm3, fmt_ty): nfmt += 1 if nfmt: print(" fmt-state signature-typed a1 on %d functions" % nfmt, flush=True) if ta == "asd": core = set(getattr(_ti, "ASD_SIG_CORE", ()) if "_ti" in dir() else ()) try: core = set(_ti.ASD_SIG_CORE); sigtype = _ti.ASD_SIG_TYPE minsz = _ti.ASD_SIG_MIN_SIZE except Exception: core, sigtype, minsz = set(), None, 1 << 30 nsig = 0 if core and sigtype: for fn2 in funcs: if fn2["size"] < minsz: continue ad2 = fn2["addr"] if assigned.get(ad2, (None, None))[0] in \ getattr(_ti, "ASD_ARG1_STRUCT", {}): continue # already typed by name try: rr2 = client.call(remote_ops.decompile, addr=ad2, include_addresses=False) or {} cf = rr2.get("code") or "" except Exception: continue offs2 = {int(x, 0) for x in re.findall( r"\*\(\s*_?\w+\s*\*\s*\)\s*\(\s*a1\s*\+\s*" r"(\d+|0x[0-9A-Fa-f]+)\s*\)", cf)} if not offs2 or not core <= offs2: continue nm2 = assigned.get(ad2, (None, None))[0] or fn2["name"] if retype_arg1(client, remote_ops, ad2, nm2, sigtype): nsig += 1 if nsig: print(" asd signature-typed a1 on %d functions" % nsig, flush=True) if ta == "asd": dsp, derr2 = asd_dispatcher(body, va) if dsp is None: print(" asd dispatcher: NOT resolved (%s)" % derr2, flush=True) else: try: rr = client.call( remote_ops.set_type, edits=[{"addr": hex(dsp), "signature": "unsigned int __fastcall asd_dispatch" "(struct asd_req *req, int a2, int a3, " "int a4);"}]) okd = bool((rr.get("result") or [{}])[0].get("ok")) if okd and hex(dsp) not in assigned: client.call(remote_ops.rename, batch={"func": [{"addr": hex(dsp), "name": "asd_dispatch"}], "allow_overwrite": True}) assigned[hex(dsp)] = ("asd_dispatch", "DISPATCH") print(" asd dispatcher @%s: struct asd_req %s" % (hex(dsp), "applied" if okd else "REJECTED"), flush=True) except Exception as exc: print(" asd dispatcher typing failed: %s" % exc, flush=True) if ta in STAGING: # OC and WSPE dispatch with if/else chains rather than a switch, so no case # labels exist and dispatch_map returns nothing -- but their invoke entry IS # the dispatcher, so fall back to it instead of skipping the struct. try: _m2, _t2, _c2, inv2 = ta_entry_points(body, va) inv_hex = hex(inv2) except Exception: inv_hex = None # 'deref' describes the ENTRY's handling of the params pointer, so it must be # read there. Using the switch-bearing function instead failed every HDCP # build, because HDCP's switch is not in the entry that does 'v5 = *a4'. mode = STAGING[ta][1][0] sd = (inv_hex or disp_addr) if mode == "deref" else (disp_addr or inv_hex) ok, detail = type_staging_lvar(client, remote_ops, ta, sd) print(" staging struct: %s (%s)" % ("applied" if ok else "NOT applied", detail), flush=True) # Local-variable names for the finding-bearing handlers. Applied after every # rename and type so the function is in its final form, and before the cache # invalidation below so the emitted text picks them up. try: lnames = getattr(_ti0, "LOCAL_NAMES", {}).get(ta, {}) except Exception: lnames = {} if lnames: byname6 = {nm6: ad6 for ad6, (nm6, _t6) in assigned.items()} edits6 = [] for fname6, vmap in lnames.items(): ad6 = byname6.get(fname6) if not ad6: continue for old6, new6 in vmap.items(): edits6.append({"func_addr": ad6, "old": old6, "new": new6}) if edits6: try: rr6 = client.call(remote_ops.rename, batch={"local": edits6}) ok6 = int((rr6.get("summary") or {}).get("ok", 0)) print(" local variable names applied: %d/%d" % (ok6, len(edits6)), flush=True) except Exception as exc: print(" local rename failed: %s" % exc, flush=True) # Invalidate every cached pseudocode before emitting. # Invalidate every cached pseudocode before emitting. Hex-Rays caches ctext per # function and remote_ops.rename refreshes only the function it renamed, so a # CALLER keeps printing the old callee name. That is how the dispatcher kept # showing gcp_cmdFE0C_CreateAuthenticatedSession in four builds after the rename # had in fact succeeded -- a stale cache reading as a failed rename. try: client.call(remote_ops.force_recompile, items=[{"addr": fn5["addr"]} for fn5 in funcs]) except Exception as exc: print(" force_recompile failed: %s" % exc, flush=True) rows = decompile_all(client, remote_ops, funcs, assigned, body, va) meta = {"type": ta, "version": ver, "hash8": h8, "stats": stats, "reference": os.path.basename(refelf)} emit_c(outdir / f"{ta}_{ver}_{h8}.c", meta, rows) try: client.save_database() except Exception: pass nf = sum(1 for r in rows if r["code"].startswith("/* decompile failed")) # measurable proof that types actually reached the output, rather than a # prototype count that says only that IDA accepted something styped = sum(1 for r in rows if re.search(r"\b(struct|union|enum)\s+\w+", r["code"].split("\n", 1)[0])) stats["struct_typed_sigs"] = styped print(" %-30s funcs=%-4d named=%-4d E=%-3d M=%-3d F=%-3d D=%-3d none=%-3d " "amb=%-3d cmds=%-3d conf=%-2d fail=%d" % (f"{ver}_{h8}", len(funcs), len(assigned), stats["EXACT"], stats["MNEM"], stats["FUZZY"], stats["DISPATCH"], stats["none"], stats["ambiguous"], len(dmap), stats["dispatch_conflict"], nf), flush=True) print(" -> struct-typed signatures in output: %d" % styped, flush=True) pool.append({"ver": ver, "src": name, "funcs": funcs_as_reference(funcs, assigned, body, va)}) summary.append({"version": ver, "hash8": h8, "funcs": len(funcs), "nearest_source": nearest, "commands": dmap, "named": len(assigned), "stats": stats, "decomp_fail": nf}) finally: try: client.close() except Exception: pass (outdir / f"{ta}.summary.json").write_text(json.dumps(summary, indent=1)) return 0 def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--type", help="TA type, e.g. gcp") ap.add_argument("--list", action="store_true") ap.add_argument("--limit", type=int, default=None, help="process only the first N non-reference builds") ap.add_argument("--outdir", default=str(HERE / "decomp")) ap.add_argument("--validate-only", action="store_true", help="run the self-port gate and stop") a = ap.parse_args() if a.list: seen: dict[str, int] = {} for p in sorted(glob.glob(str(ELF_DIR / "*.elf"))): t = os.path.basename(p).split("_")[0] seen[t] = seen.get(t, 0) + 1 for k in sorted(seen): print(" %-10s %2d builds%s" % (k, seen[k], "" if k in LIBDUMP_REF else " (no libdump reference)")) return 0 if not a.type: ap.error("--type or --list required") return run_type(a.type, Path(a.outdir), a.limit, a.validate_only) if __name__ == "__main__": sys.exit(main())