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
|
"""Ring-level fuzzer for the PSP GFX command surface + disclosure detector.
Prior campaigns only fuzzed TA shared-buffer CONTENTS. The host also fully
controls the 1024-byte psp_gfx_cmd_resp struct itself (cmd_id and every
address/length/session field). That is the most directly host-reachable PSP
surface and had never been fuzzed.
SAFETY (psp_out/CONSTRAINTS.md: no flash / no TSU):
cmd_id 7 (DESTROY_TMR -> sysdrv_get_system_info, flash ops) is EXCLUDED.
cmd_id 5/6/8/9/10 (TMR/VMR/IP_FW/SAVE_RESTORE) excluded from field fuzzing --
they reprogram memory protection or load firmware. We fuzz the TA lifecycle
(LOAD_TA/LOAD_ASD/UNLOAD/INVOKE), attestation, and undefined cmd_ids.
DISCLOSURE DETECTOR: the cmd buffer is filled with a marker before each submit.
Any byte the PSP overwrites with something that is neither marker nor a known
response field is candidate leaked PSP memory.
"""
import ctypes,struct,time,random,sys,collections
from tinygrad import Device
from tinygrad.runtime.autogen.am import am
from tinygrad.helpers import lo32,hi32
d=Device["AMD"]; adev=d.iface.dev_impl; psp=adev.psp
SZ=ctypes.sizeof(am.struct_psp_gfx_cmd_resp); P='/Users/user/dev/amd-psp-re/psp_out/'
MARK=0xA5
FORBIDDEN={7,5,6,8,9,10} # flash / memory-protection / fw-load
def alloc(n):
pa=adev.mm.palloc(n,align=0x1000,zero=True); return pa,adev.paddr2mc(pa)
app_pa,app_mc=alloc(0x40000); shm_pa,shm_mc=alloc(0x8000)
blob=open(P+'hdcp_full.bin','rb').read()
adev.vram.view(app_pa,len(blob))[:]=blob
adev.gmc.flush_hdp()
def submit(raw:bytes, tmo_note=""):
adev.vram.view(psp.cmd_paddr,SZ)[:]=raw
c=am.struct_psp_gfx_cmd_resp.from_buffer_copy(raw)
t0=time.time()
try:
r=psp._ring_submit(c); return "OK",bytes(adev.vram.view(psp.cmd_paddr,SZ)[:]),time.time()-t0
except RuntimeError as e:
return "ERR",bytes(adev.vram.view(psp.cmd_paddr,SZ)[:]),time.time()-t0
except TimeoutError:
return "TIMEOUT",None,time.time()-t0
def mkcmd(cid, fields:dict):
b=bytearray(SZ) # zero-filled: a marker fill corrupts reserved/resp fields and everything is rejected at parse
struct.pack_into('<I',b,8,cid) # cmd_id lives at offset 8
for off,val in fields.items(): struct.pack_into('<I',b,off,val&0xffffffff)
return bytes(b)
rng=random.Random(int(sys.argv[1]) if len(sys.argv)>1 else 11)
ROUNDS=int(sys.argv[2]) if len(sys.argv)>2 else 4000
EX=[0,1,2,3,0xffffffff,0x7fffffff,0x80000000,0xfffffffe,0xffff,0x10000,0x100000,0x100001,
0xfffff,0x200,0x8000,0x8001,0x40000,0x40001,255,256,0x2817,0x2818,64,63]
# byte offsets of the cmd union payload inside psp_gfx_cmd_resp
UNION=28 # verified by ctypes sentinel probing: cmd union starts at byte 28
stats=collections.Counter(); anoms=[]; leaks=[]
t0=time.time(); lastrep=0
print("ring-level GFX fuzz, forbidden cmd_ids=%s"%sorted(FORBIDDEN))
for r in range(ROUNDS):
mode=rng.randrange(4)
if mode==0: cid=rng.choice([1,4]) # LOAD_TA / LOAD_ASD
elif mode==1: cid=rng.choice([2,3]) # UNLOAD / INVOKE
elif mode==2: cid=rng.choice([0xf,0x20,0x22,0x27]) # attestation / toc / bootcfg / sriov
else: cid=rng.choice([0,0xc,0xd,0xe,0x11,0x17,0x1f,0x23,0x26,0x28,0x3f,0xff,0x10000,0xf0000])
if cid in FORBIDDEN: continue
fields={}
for k in range(6):
off=UNION+4*rng.randrange(0,12)
fields[off]=rng.choice(EX)
# sometimes make it a plausible well-formed load/invoke so we get past the gate
if cid in (1,4) and rng.random()<0.5:
fields={UNION+0:hi32(app_mc),UNION+4:lo32(app_mc),UNION+8:rng.choice([len(blob)]+EX),
UNION+12:hi32(shm_mc),UNION+16:lo32(shm_mc),UNION+20:rng.choice([0x8000]+EX)}
raw=mkcmd(cid,fields)
st,out,dt=submit(raw)
code=struct.unpack_from('<I',out,864)[0] if out else None
stats[(cid,code)]+=1
if st=="TIMEOUT" or dt>0.5:
anoms.append((r,cid,st,dt)); print(" [r=%d] ANOMALY cid=%#x st=%s dt=%.2f alive=%s"%(r,cid,st,dt,psp.is_sos_alive()))
open('gfxanom_%02x_r%d.bin'%(cid,r),'wb').write(mkcmd(cid,fields))
if not psp.is_sos_alive(): print(" !! sOS DEAD"); break
if len(anoms)>5: break
continue
# disclosure: bytes the PSP wrote that are not our marker, outside the header (first 0x40)
# disclosure: non-zero bytes outside the header+status window we know the PSP writes
# disclosure: bytes that DIFFER from what we submitted, outside the known response window
changed=[i for i,(a,b) in enumerate(zip(raw,out)) if a!=b and not (860<=i<876)]
if changed:
sig=(cid,len(changed),changed[0],changed[-1])
if sig not in [l[0] for l in leaks] and len(leaks)<25:
leaks.append((sig,out))
print(" [r=%d] PSP WROTE %d bytes at 0x%x..0x%x for cid=%#x"%(r,len(changed),changed[0],changed[-1],cid))
open('gfxleak_%02x_r%d.bin'%(cid,r),'wb').write(out)
if time.time()-lastrep>60:
lastrep=time.time(); print(" ..r=%d %.0fs alive=%s"%(r,time.time()-t0,psp.is_sos_alive()))
print("\nrounds=%d elapsed=%.0fs anomalies=%d distinct-write-patterns=%d"%(ROUNDS,time.time()-t0,len(anoms),len(leaks)))
print("status by cmd_id:")
agg=collections.defaultdict(collections.Counter)
for (cid,st),n in stats.items(): agg[cid][st]=n
for cid in sorted(agg): print(" %#07x : %s"%(cid,dict(agg[cid])))
print("sos_alive:",psp.is_sos_alive())
|