1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
|
"""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)
|