aboutsummaryrefslogtreecommitdiffstats
path: root/hw/gcp_asd202_leak_probe.py
blob: c63aba47d8f5e3bf1d41a98fa496f1288f4ed164 (plain) (blame)
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
#!/usr/bin/env python3
"""End-to-end test of the GCP -> ASD 0x202 disclosure chain on real silicon.

Static chain (fw/AUDIT-NEW-TAS-RE.md):
    host INVOKE_CMD(GCP, cmd 4)
      -> gcp_cmd04_GetContentKey
      -> ASD service-2 opcode 0x202          (thunk at 0x405446, IDA-confirmed)
      -> ASD copies 16 uninitialised stack bytes to arg3 + 16,
         and arg3 = host_params + 36, so they land at host_params + 0x34.

This fills the shared params page with a marker, invokes GCP command 4, and reads the
page back. If bytes appear at +0x34 that are not the marker, ASD wrote into the host's
buffer and the disclosure is host-visible.

Controls, because "bytes changed" alone proves little:
  C1 baseline  -- read the page back after filling it and BEFORE any invoke, to show the
                  marker survives (i.e. nothing else scribbles there).
  C2 other cmd -- invoke a command that does NOT call the 0x202 thunk (cmd 1,
                  AllocateDRMSession) and confirm +0x34 stays marked. Without this, any
                  incidental write by the invoke machinery would read as a leak.
  C3 repeat    -- invoke cmd 4 twice; stale-stack contents may differ between calls,
                  which is itself evidence it is uninitialised memory rather than a
                  fixed structure.

Safety: loads a signed AMD TA and invokes one documented command with a zeroed page.
No SPI, no register pokes. GCP is unloaded afterwards; a warm-up TA load is done first
because the PSP refuses some TAs as the very first LOAD_TA after reset.

usage: AM_RESET=1 PYTHONPATH=/root/psp/tinygrad gcp_asd202_leak_probe.py --apply
"""
import argparse
import os
import struct
import subprocess
import sys

MARK = 0xCC
PARAMS_CMD_OFF = 0x00      # dispatcher switches on params[0]
PARAMS_IDX_OFF = 0x10      # v7 = *(a2+16), bounds-checked < 0x20
LEAK_OFF = 0x34            # arg3 (= a2+36) + 16


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument('--apply', action='store_true')
    ap.add_argument('--gcp', default='/root/psp/contall/ta_22-0-4-50_81b082b4.bin')
    ap.add_argument('--ta-bin', default='/lib/firmware/amdgpu/psp_13_0_0_ta.bin')
    args = ap.parse_args()
    if not args.apply:
        print('dry run')
        return 0

    from tinygrad import Device
    import tinygrad.runtime.autogen.am.am as am

    adev = Device["AMD"].iface.dev_impl
    psp = adev.psp

    def alloc(n):
        pa = adev.mm.palloc(n, align=0x1000, zero=True, boot=False)
        return pa, adev.paddr2mc(pa)

    app_pa, app_mc = alloc(0x80000)
    shm_pa, shm_mc = alloc(0x8000)

    def load(blob, load_cmd=None):
        n = (len(blob) + 3) & ~3
        adev.vram.view(app_pa, n)[:] = blob + b'\x00' * (n - len(blob))
        adev.gmc.flush_hdp()
        c = am.struct_psp_gfx_cmd_resp(cmd_id=load_cmd or am.GFX_CMD_ID_LOAD_TA)
        t = c.cmd.cmd_load_ta
        t.app_phy_addr_lo, t.app_phy_addr_hi = app_mc & 0xFFFFFFFF, app_mc >> 32
        t.app_len = len(blob)
        t.cmd_buf_phy_addr_lo, t.cmd_buf_phy_addr_hi = shm_mc & 0xFFFFFFFF, shm_mc >> 32
        t.cmd_buf_len = 0x8000
        try:
            r = psp._ring_submit(c)
            return r.resp.status & 0xFFFFFFFF, r.resp.session_id
        except Exception as e:
            try:
                return int(str(e).strip().split()[-1]) & 0xFFFFFFFF, None
            except Exception:
                return -1, None

    def unload(sid):
        c = am.struct_psp_gfx_cmd_resp(cmd_id=am.GFX_CMD_ID_UNLOAD_TA)
        c.cmd.cmd_unload_ta.session_id = sid
        try:
            return psp._ring_submit(c).resp.status & 0xFFFFFFFF
        except Exception:
            return -1

    def fill_and_prime(cmd, idx=0):
        # The page must be MOSTLY ZERO. gcp_cmd04 reaches the 0x202 thunk only when
        # sub_4010C4(zeroed_buf, a2+20, 16) == 0, i.e. the 16 bytes at a2+20 must be
        # zero; a 0xCC fill takes the crypto branch instead and never calls the thunk
        # (first run: status 0, page untouched at +0x34).
        # So: zeros everywhere, marker ONLY in the window we are watching.
        buf = bytearray(0x1000)
        buf[LEAK_OFF:LEAK_OFF + 16] = bytes([MARK]) * 16
        struct.pack_into('<I', buf, PARAMS_CMD_OFF, cmd)
        struct.pack_into('<I', buf, PARAMS_IDX_OFF, idx)
        adev.vram.view(shm_pa, len(buf))[:] = bytes(buf)
        adev.gmc.flush_hdp()

    def read_back(n=0x200):
        adev.gmc.flush_hdp()
        return bytes(adev.vram.view(shm_pa, n)[:])

    def invoke(sid, cmd):
        c = am.struct_psp_gfx_cmd_resp(cmd_id=am.GFX_CMD_ID_INVOKE_CMD)
        iv = c.cmd.cmd_invoke_cmd
        iv.session_id, iv.ta_cmd_id = sid, cmd
        try:
            return psp._ring_submit(c).resp.status & 0xFFFFFFFF
        except Exception as e:
            try:
                return int(str(e).strip().split()[-1]) & 0xFFFFFFFF
            except Exception:
                return -1

    def show(tag, data):
        region = data[LEAK_OFF:LEAK_OFF + 16]
        changed = any(b != MARK for b in region)
        # Also report EVERY offset the TA touched. If nothing anywhere changed, the TA
        # never saw our page and a null result at +0x34 means nothing.
        touched = [i for i, b in enumerate(data)
                   if b != (MARK if LEAK_OFF <= i < LEAK_OFF + 16 else 0)]
        rng = ('%#x..%#x (%d bytes)' % (touched[0], touched[-1], len(touched))
               if touched else 'NOTHING')
        # GCP writes its own status into the page; the value identifies the path taken:
        #   1043/0x413 v7 >= 0x20 | 1045/0x415 the ASD 0x202 call returned non-zero
        #   1051/0x41B crypto setup failed | 1052/0x41C rpc_7 failed | 0 success
        dws = struct.unpack_from('<4I', data, 0)
        names = {0: 'ok', 1043: 'idx>=0x20', 1045: 'ASD-0x202-call-FAILED',
                 1051: 'crypto-setup-failed', 1052: 'rpc_7-failed'}
        stat = ' '.join('%#x(%s)' % (d, names.get(d, '?')) for d in dws)
        print('  %-28s +0x34: %s  %-7s touched: %-22s dw0-3: %s'
              % (tag, region.hex(), 'CHANGED' if changed else 'marker', rng, stat))
        return changed

    # warm-up: some TAs are refused as the first LOAD_TA after reset
    tab = args.ta_bin
    if not os.path.exists(tab) and os.path.exists(tab + '.zst'):
        subprocess.run(['zstd', '-d', '-f', '-o', '/tmp/ta.bin', tab + '.zst'],
                       check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
        tab = '/tmp/ta.bin'
    # CRITICAL: ASD must be LOADED, or GCP's service-2 RPCs fail (0x2f) and every
    # crypto path bails. Earlier runs misread that as "needs DRM key material".
    d = open(tab, 'rb').read()
    conts, p_ = [], 0
    while True:
        m = d.find(b'$PS1', p_)
        if m < 0:
            break
        p_ = m + 4
        c0 = m - 0x10
        if c0 < 0:
            continue
        n = struct.unpack_from('<I', d, c0 + 0x14)[0]
        if not (0 < n < 0x400000) or c0 + 0x200 + n > len(d):
            continue
        if d[c0 + 0x100:c0 + 0x104] == b'\x00\x02\x00\x00':
            conts.append(d[c0:c0 + 0x200 + n])
    asd_blob = max(conts, key=len)
    ast, asid = load(asd_blob, am.GFX_CMD_ID_LOAD_ASD)
    print('LOAD_ASD (%#x bytes): status %#x' % (len(asd_blob), ast))

    st, sid = load(open(args.gcp, 'rb').read())
    print('GCP load: %#x sid=%s' % (st, sid))
    if st != 0:
        return 1
    try:
        fill_and_prime(4)
        base = read_back()
        print('\nC1 baseline (no invoke yet):')
        show('filled, not invoked', base)

        print('\nC2 control -- cmd 1 (AllocateDRMSession, does NOT call the thunk):')
        fill_and_prime(1)
        s1 = invoke(sid, 1)
        c2 = show('after cmd 1 (status %#x)' % s1, read_back())

        # C4: the 1045 path means ASD refused. Its copy sits behind ASD's crypto
        # pipeline, so try establishing DRM session state first with GCP's own session
        # commands, then re-run cmd 4 and see whether the status leaves 1045.
        print('\nC4 prime a DRM session, then cmd 4:')
        for pc in (1, 2, 0xFE03, 0xFE02):
            fill_and_prime(pc)
            st_p = invoke(sid, pc)
            d_p = read_back()
            print('     prime cmd %-6s status %#-6x dw2=%#x'
                  % (hex(pc), st_p, struct.unpack_from('<I', d_p, 8)[0]))
        fill_and_prime(4)
        invoke(sid, 4)
        dprime = read_back()
        show('cmd 4 after priming', dprime)

        print('\nC3 the chain -- cmd 4 (GetContentKey), twice:')
        fill_and_prime(4)
        s4a = invoke(sid, 4)
        d1 = read_back()
        a1 = show('after cmd 4 #1 (status %#x)' % s4a, d1)
        fill_and_prime(4)
        s4b = invoke(sid, 4)
        d2 = read_back()
        a2 = show('after cmd 4 #2 (status %#x)' % s4b, d2)

        same = d1[LEAK_OFF:LEAK_OFF + 16] == d2[LEAK_OFF:LEAK_OFF + 16]
        print('\n  two cmd-4 runs identical at +0x34: %s' % same)
        # NB: "bytes changed at +0x34" is NOT evidence of a disclosure. GetContentKey is
        # *supposed* to return key material at that offset, so a changed window is the
        # expected result of the command working. Separating intended output from leaked
        # stack needs the CONTENT judged: entropy shape, address-shaped dwords, and above
        # all whether a constant dword repeats across calls (the stack guard is fixed for
        # the life of a process, so a leaked window containing it would repeat).
        # Measured on this part: high entropy, no pointers, nothing constant -> consistent
        # with intended key output, not a leak. See the ASD 0x202 section of
        # fw/AUDIT-NEW-TAS-RE.md.
        print('\nRESULT: %s' % (
            'cmd 4 writes 16 bytes at +0x34 where cmd 1 does not -- reachability of the '
            '0x202 path, NOT a disclosure; judge the content separately'
            if (a1 and not c2) else
            'not demonstrated (cmd4 changed=%s, cmd1 changed=%s)' % (a1, c2)))
    finally:
        unload(sid)
        print('GCP unloaded')
    return 0


if __name__ == '__main__':
    raise SystemExit(main())