"""Fuzz the ASD command surface (the component HDCP forwards into). Target: the ASD dispatcher sub_D013B8(req, ...), where req = {cmd, a1, a2, a3, a4}. This is what an SVC 0xF2 service-2 call lands on, i.e. everything HDCP can reach via cmd 0x12/0x13/0x0F forwards (0x1A2/0x1A3/0x1A7/0x1A8/0x200/0x300..0x308/0x100...). Design: pointer arguments always point at REAL allocated buffers and we fuzz the BUFFER CONTENTS, not the pointer values. On hardware the sOS gate region-validates every pointer, so content is the attacker-controlled part; feeding wild pointers would only manufacture false positives. Detection is hook-free (UC_HOOK_MEM_WRITE corrupts Thumb ITSTATE): * unicorn faults; ASD's __stack_chk_fail trap reads 0xDEADBEEF -> CANARY_SMASH * redzone sentinels after each argument buffer and after the receiver-ID global table byte_D1F038 * instruction budget for hangs """ import os import random import sys from collections import Counter sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) from pspemu import Component, consts # noqa: E402 DISPATCH = consts.ASD["dispatcher"] # 0xD013B8 # asd_validate_ptr. Resolved from the image, not hardcoded: it moves between # builds (0xD0C9F8 .. 0xD12E90 across the 12 images in psp_out/elf), so a # hardcoded address makes this harness fetch from 0 the moment it is pointed at # another build -- e.g. the card's own ASD 33.0.0.217, where it lives at # 0xD0FF08. ta_entries.find_asd_validate requires a UNIQUE signature match and # raises otherwise, so a build it cannot place is an error rather than a guess. # 0xD12E90 is kept only as the expected answer for the default image. VALIDATE_DEFAULT = 0xD12E90 FEATURE = 0xD116AC # asd_feature_enabled (no longer patched) RXID_BUF = consts.ASD["rxid_buf"] # 0xD1F038 RET1 = bytes([0x01, 0x20, 0x70, 0x47]) # MOVS R0,#1 ; BX LR # The caller (TA) window sOS maps in for an RPC; asd_validate_ptr accepts # 0x400000..0x600000 and nothing else, so argument buffers must live here. CALLER_WINDOW, CALLER_WINDOW_SZ = 0x00400000, 0x00040000 RED = b"\xC0\xDE\xFA\xCE" * 4 # every command the dispatcher decodes CMDS = ([0x100, 0x101, 0x102, 0x103] + list(range(0x180, 0x18F)) + list(range(0x200, 0x20F)) + list(range(0x300, 0x30A)) + [0x160, 0x161, 0x402, 0x403, 0x404, 0x405, 0x800, 0x801, 0x802] + [0xFFFF0000 + i for i in range(8)]) class AsdFuzzer: def __init__(self, seed=0): self.rng = random.Random(seed) c = self.c = Component("asd") from pspemu import ta_entries, services self.validate = ta_entries.find_asd_validate(c) # SVCs 0x51/0x57/0x5D/0xB4 used to fall through to a fabricated 0. # These are the values the real sOS returns, measured by watching # sos_svc_dispatch in full_system.py -- 0x51 and 0x5D are sequential # handle allocators, not constants. ASD_RAW_SVC=1 restores the old # fabricated default for comparison. if os.environ.get("ASD_RAW_SVC") != "1": services.register_measured_sos_svcs(c) c.svc.register(0xF2, lambda comp, n: 0) # crypto / nested RPC c.svc.register(0x8D, lambda comp, n: 0x31) # ASIC id c.svc.register(0x5E, lambda comp, n: 0) c.svc.register(0x5F, lambda comp, n: 0) c.svc.register(0x76, lambda comp, n: 0) c.svc.register(0x7B, lambda comp, n: 0) c.svc.register(0x7C, lambda comp, n: 0) c.svc.register(0x93, lambda comp, n: 0) # asd_feature_enabled is deliberately NOT patched any more. Measured: # it already returns 1 for every id 0..0x1F unpatched, because the # feature-config byte it reads (0xD447A8, loaded via the literal at # 0xD11730) is 0 in the image and the `cbz` path at 0xD116E4 is # `movs r0,#1; bx lr` -- "no restrictions configured" means enabled. # The old `c.write(FEATURE, RET1)` stub was therefore redundant, and # strictly worse: a stub can never return 0, so if anything configures # that byte mid-run the stub would hide real feature gating. # Verified identical for ids 0..0x1F, patched vs unpatched. # asd_validate_ptr: patch with a code stub that always accepts. # NOTE: do NOT implement this as a UC_HOOK_CODE that writes PC=LR -- # writing PC from inside a code hook is unreliable in Unicorn and # silently corrupts control flow (it manufactured 194 bogus # FETCH_UNMAPPED "PC control" findings before this was caught). # asd_validate_ptr is NO LONGER stubbed. It is a genuine range check: # it accepts only 0x400000..0x600000 -- the CALLER's window, which sOS # maps in for the RPC via the SVC 0xF2 gate -- and rejects ASD's own # image and scratch. This harness used to allocate its argument buffers # in ASD scratch (0xD70000..), which the real check correctly REFUSES, # and then patched the check out so the fuzz could proceed. # # That inverted the point of the exercise: with the validator neutered # the fuzzer explored pointer states real ASD would have rejected at the # door, so any finding reached through one was suspect by construction. # # Model the gate instead of removing it: map the caller window and put # the buffers inside it, so the REAL validator sees pointers it accepts. c.uc.mem_map(CALLER_WINDOW, CALLER_WINDOW_SZ) self.req = c.alloc(0x40) self.bufs = [CALLER_WINDOW + 0x10000 + i * 0x4000 for i in range(4)] self.reds = [b + 0x2000 for b in self.bufs] # Self-check: prove the validator is LIVE, not bypassed, and that it # actually accepts what we hand it. Without this, reverting to the old # RET1 stub -- or moving the buffers back out of the caller window -- # would silently restore the unfaithful configuration and nothing would # say so. Two directions, because "accepts our buffers" alone is also # true of a stub that accepts everything. for b in self.bufs: if not c.run_function(self.validate, (b, 0x2000, 0, 0)): raise RuntimeError( "asd_validate_ptr rejects argument buffer %#x; it must live" " in the caller window %#x..%#x" % (b, CALLER_WINDOW, CALLER_WINDOW + CALLER_WINDOW_SZ)) if c.run_function(self.validate, (0x00D70000, 0x2000, 0, 0)): raise RuntimeError( "asd_validate_ptr accepted ASD scratch (0xD70000) -- it has been" " stubbed out, so pointer arguments are no longer gated and any" " finding reached through one would be unfaithful") self.rxid_red = RXID_BUF + 0x1400 # end of the 5120-byte table self.rxid_save = c.read(RXID_BUF, 0x1400) self.stats = Counter() self.findings = [] def reset(self): c = self.c for b, r in zip(self.bufs, self.reds): c.write(b, b"\x00" * 0x2000) c.write(r, RED) c.write(RXID_BUF, self.rxid_save) c.write(self.rxid_red, RED) c.write(self.req, b"\x00" * 0x40) def run_one(self): self.reset() c, rng = self.c, self.rng cmd = rng.choice(CMDS) c.wr32(self.req, cmd) used_scalar = False for i in range(4): roll = rng.random() if roll < 0.12: v = 0 # exercise NULL paths elif roll < 0.42: v = rng.choice([1, 2, 4, 8, 0x20, 0x40, 0x1400, 0x13D0, 0xFFFF, rng.getrandbits(16)]) # genuine scalar args used_scalar = True else: v = self.bufs[rng.randrange(4)] # a validated buffer c.wr32(self.req + 4 * (i + 1), v) # fuzz buffer contents, biased to plausible header shapes for b in self.bufs: n = rng.choice([16, 64, 256, 0x400, 0x1400]) c.write(b, bytes(rng.getrandbits(8) for _ in range(n))) if rng.random() < 0.5: c.wr8(b + 0, rng.choice([0x81, 0x91, 0x80, 0x90])) # nibble header try: # Match the real entry state. ASD has NO init phase: its entry # trampoline at 0xD19868 (ARM) is # MOV R0,R2 ; LDR IP,[PC,#0x10] ; BLX IP ; SVC 0x75 # with *(0xD19884) = 0xD013B9 = sub_D013B8|1, i.e. the dispatcher. # sOS passes the request in R2, so R2 must equal req -- several # handlers (e.g. sub_D131DC -> sub_D131F8) forward leftover R1-R3 as # "dropped args", so entry register state is observable. c.run_function(DISPATCH, (self.req, 0, self.req, 0), count=2_000_000) fault = None except Exception as e: fault = e if fault is not None: f = c.faults[-1] if c.faults else {} addr = f.get("addr", 0) kind = "CANARY_SMASH" if addr == 0xDEADBEEF else f.get("kind", "FAULT") # Scalar-deref noise: several handlers (0x20B/0x802/0x309/0x301) deref a # pointer arg without calling asd_validate_ptr, so a fuzzed scalar lands # as a near-zero address. Those are not host-reachable (the sOS gate # validates the request and HDCP supplies these pointers from its own # code), so classify by fault address instead of discarding coverage. # A raw scalar in a pointer slot makes the run unrepresentative by # construction: handlers that deref/WRITE through it can clobber the # emulator stack, and the subsequent fault is a garbage PC (large), # which an address-based filter cannot catch. Classify by INPUT. if kind != "CANARY_SMASH" and (addr < 0x100000 or used_scalar): kind = "scalar_deref_noise" self.stats[kind] += 1 if kind not in ("CANARY_SMASH", "scalar_deref_noise"): self.findings.append((kind, hex(cmd), hex(f.get("pc", 0)), hex(addr))) else: self.stats["ok"] += 1 for i, r in enumerate(self.reds): if c.read(r, 16) != RED: self.stats["REDZONE_argbuf"] += 1 self.findings.append(("REDZONE_argbuf%d" % i, hex(cmd), "-", "-")) if c.read(self.rxid_red, 16) != RED: self.stats["REDZONE_rxid_table"] += 1 self.findings.append(("REDZONE_rxid_table", hex(cmd), "-", "-")) def main(iters=5000, seed=1): f = AsdFuzzer(seed) for _ in range(iters): f.run_one() print("ASD fuzz: iterations=%d seed=%d" % (iters, seed)) print(" asd_validate_ptr resolved to %#x%s" % (f.validate, " (== the expected address for the default image)" if f.validate == VALIDATE_DEFAULT else " (NOT the default image's %#x -- a different ASD build)" % VALIDATE_DEFAULT)) unh = sorted({n for n, _s in f.c.svc.unhandled if isinstance(n, int)}) if unh: print(" QUALIFIED: %d SVC(s) answered with a fabricated 0: %s" % (len(unh), ", ".join(hex(n) for n in unh))) measured = {0x51, 0x5D, 0xB4} unmeasured = [n for n in unh if n not in measured] print(" 0x51/0x5d/0xb4 are MEASURED load-bearing: flipping any one to a") print(" failure changes ASD coverage by up to 48%.") if unmeasured: print(" %s appeared only at higher iteration counts and is UNMEASURED" % ", ".join(hex(n) for n in unmeasured)) print(" -- not a claim that it is harmless.") print(" Coverage below is an UPPER BOUND and absence of faults is not") print(" evidence of absence. See") print(" psp_out/FINDING-ASD-SVC-STUBS-LOAD-BEARING.md") for k, v in f.stats.most_common(): print(" %-24s %d" % (k, v)) if f.findings: print("\ndistinct findings:") seen = set() for t in f.findings: key = (t[0], t[2], t[3]) if key in seen: continue seen.add(key) print(" kind=%-20s cmd=%-12s pc=%-12s addr=%s" % t) if len(seen) >= 30: break else: print("\nno faults or redzone violations") if __name__ == "__main__": main(int(sys.argv[1]) if len(sys.argv) > 1 else 5000, int(sys.argv[2]) if len(sys.argv) > 2 else 1)