#!/usr/bin/env python3 """Backward-slice the LENGTH register at every call into an ARM-mode copy helper. Why this exists: the pseudocode-level hunt was measuring nothing, because these TAs reach their copy helpers with `BLX` into **ARM-mode** code and Hex-Rays drops the arguments at exactly those sites. And the older capstone audit reported "r2 unknown" for 15 of its 18 leads, because it only looked at the instruction immediately preceding the call. So: identify the ARM-mode copy helpers (BLX targets whose ARM body is a load/store loop), then for every call site slice the length register backwards through register moves and arithmetic, up to a few levels, until it reaches an immediate, a memory load, or nothing. The shape that matters is HDCP's confirmed overflow: LDRB.W R8, [R5,#0x38] ; a request byte ADD.W R7, R8, R8,LSL#2 ; len = 5*N BLX sub_401058 ; memcpy(stack_buffer, src, len) so the slicer must survive one move and one arithmetic step, which the old last-write heuristic could not. CONTROL: run against HDCP and the known site must appear with a request-byte origin. Usage: ./ta_copylen_slice.py --selftest ./ta_copylen_slice.py --all """ from __future__ import annotations import argparse import json import re from pathlib import Path import capstone as cs HERE = Path(__file__).resolve().parent REPO = HERE.parent THUMB = cs.Cs(cs.CS_ARCH_ARM, cs.CS_MODE_THUMB) ARM = cs.Cs(cs.CS_ARCH_ARM, cs.CS_MODE_ARM) def arm_copy_helpers(b: bytes, fns: list, base: int) -> set[int]: """BLX targets whose ARM-mode body looks like a load/store loop.""" blx = set() for f in fns: sz = f.get("size", 0) if not sz: continue for i in THUMB.disasm(b[f["ea"] - base:f["ea"] - base + sz], f["ea"]): if i.mnemonic == "blx" and (i.op_str or "").startswith("#"): blx.add(int(i.op_str[1:], 16)) out = set() for t in blx: off = t - base if not (0 <= off < len(b) - 16): continue ins = list(ARM.disasm(b[off:off + 0x60], t)) txt = " ".join(i.mnemonic for i in ins) # A BLX target is ARM, but not every BLX target is a copy: OPM's __stack_chk_fail # produced three false "copies" whose "length" was actually the saved canary. Two # guards: the ARM decode must be plausible (coprocessor/undefined opcodes mean we # are decoding Thumb or data as ARM), and the body must both load AND store. if any(i.mnemonic.startswith(("cdp", "mcr", "mrc", "stc", "ldc", "undefined")) for i in ins): continue has_load = any(i.mnemonic.startswith(("ldr", "ldm")) for i in ins) has_store = any(i.mnemonic.startswith(("str", "stm")) for i in ins) if has_load and has_store: out.add(t) return out def slice_reg(ins: list, at: int, reg: str, depth: int = 4) -> str: """Describe where `reg` came from, walking backwards from index `at`.""" cur = reg for _ in range(depth): for k in range(at - 1, -1, -1): i = ins[k] ops = i.op_str or "" m = re.match(r"(%s)\s*,\s*(.+)$" % re.escape(cur), ops) if not m or i.mnemonic.startswith(("cmp", "cmn", "tst", "str", "push", "b")): continue rhs = m.group(2) if i.mnemonic.startswith("ldr"): return "LOAD %s %s" % (i.mnemonic, rhs) # <- the interesting case if re.fullmatch(r"#(0x[0-9a-f]+|\d+)", rhs.strip()): return "immediate %s" % rhs.strip() m2 = re.match(r"(r\d+|sb|sl|fp|ip)\b", rhs.strip()) if m2: cur = m2.group(1) # follow the chain at = k break return "%s %s" % (i.mnemonic, rhs) else: return "unresolved (%s never written in this function)" % cur return "chain too deep (last: %s)" % cur def scan(ta: str, base: int = 0x400100): meta = json.loads((HERE / "syms" / ("%s.meta.json" % ta)).read_text()) body = Path(meta["path"]) if not body.is_absolute(): body = REPO / body b = body.read_bytes() fns = json.loads((HERE / "libdumps" / ("%s.json" % ta)).read_text()) helpers = arm_copy_helpers(b, fns, base) rows = [] for f in fns: sz = f.get("size", 0) if not sz: continue ins = list(THUMB.disasm(b[f["ea"] - base:f["ea"] - base + sz], f["ea"])) for n, i in enumerate(ins): if i.mnemonic not in ("blx", "bl") or not (i.op_str or "").startswith("#"): continue if int(i.op_str[1:], 16) not in helpers: continue rows.append((f["name"], i.address, slice_reg(ins, n, "r2"), slice_reg(ins, n, "r0"), slice_reg(ins, n, "r1"))) return helpers, rows def dst_class(d: str) -> str: """A host-controlled DESTINATION with a fixed length is as exploitable as an unbounded length, and nothing here had searched for it.""" if d.startswith("LOAD"): return "POINTER-FROM-MEMORY" # the case worth reading if "sp" in d: return "stack" if d.startswith("immediate"): return "immediate" return "other" def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--selftest", action="store_true") ap.add_argument("--all", action="store_true") ap.add_argument("--prefix") args = ap.parse_args() if args.selftest: # HDCP: the known overflow site must show a request-derived length import types meta = types.SimpleNamespace(path=str(REPO / "psp_out" / "hdcp_code.bin")) b = Path(meta.path).read_bytes() fns = [{"ea": 0x402A88, "size": 0x300, "name": "sub_402A88"}] helpers = {0x401058} ins = list(THUMB.disasm(b[0x402A88 - 0x400100:0x402A88 - 0x400100 + 0x300], 0x402A88)) found = None for n, i in enumerate(ins): if i.mnemonic in ("blx", "bl") and (i.op_str or "").startswith("#") \ and int(i.op_str[1:], 16) in helpers: ln = slice_reg(ins, n, "r2") if "LOAD" in ln or "add" in ln: found = (hex(i.address), ln) print("SELFTEST on HDCP's confirmed overflow site:") print(" %s" % (("FOUND %s len <- %s" % found) if found else "MISSED")) print("\nverdict: %s" % ("TRUSTWORTHY" if found else "NOT trustworthy")) return 0 if found else 1 idx = json.loads((HERE / "syms" / "index.json").read_text()) for ta in (sorted(idx) if args.all else [args.prefix]): helpers, rows = scan(ta) ptr_dst = [r for r in rows if dst_class(r[3]) == "POINTER-FROM-MEMORY"] print("\n=== %s: %d helper(s), %d call site(s), %d with a MEMORY-SOURCED DESTINATION" % (ta, len(helpers), len(rows), len(ptr_dst))) for nm, ea, ln, dst, src in ptr_dst: print(" %-30s @%#x dst <- %-28s len <- %-22s src <- %s" % (nm[:30], ea, dst[:28], ln[:22], src[:22])) return 0 if __name__ == "__main__": raise SystemExit(main())