diff options
| author | user <user@clank> | 2026-09-28 16:19:54 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-28 16:19:54 +0200 |
| commit | 2bac54abd7ea6f9fbaba4cffe54c252cbf61fa97 (patch) | |
| tree | 6894e29da02ef53c7dc1f2d3bbf74c53e06c3bd7 /f32 | |
| parent | Reach the PSR-SU dirty-rect path by state injection; writes stay bounded (diff) | |
| download | amd-psp-re-2bac54abd7ea6f9fbaba4cffe54c252cbf61fa97.tar.gz amd-psp-re-2bac54abd7ea6f9fbaba4cffe54c252cbf61fa97.tar.xz amd-psp-re-2bac54abd7ea6f9fbaba4cffe54c252cbf61fa97.zip | |
Synthesise the corpus: test the strongest compound, and reorder the targets
A deliberate pass over all findings asking whether any two combine into
something neither is alone.
The strongest candidate is tested and falsified. FINDING-10 leaks memory with no
memory-safety bug -- it turns an unreadable value into measurable time -- and its
only real limitation is that the DMUB inbox is kernel-only. SDMA's POLL_REGMEM
has the identical shape on queues that ARE user-submittable, and since a poll
writes nothing, the gate covering SRBM_WRITE and POLL_REG_WRITE_MEM need not have
applied to it. On silicon the privileged control shows the oracle plainly (match
0.08 ms vs mismatch 3002 ms) and it vanishes at RB_PRIV=0, where both outcomes
become an indistinguishable hang. SDMA's register boundary is therefore
consistent across write, read-to-memory and read-by-timing -- a stronger claim
than any single probe made.
Recorded alongside it: an untested cross-engine question (SDMA's security is one
bit, DMCUB's types 1/3 are an intentional arbitrary register write -- can one
engine set another's privilege bit?); the systemic pattern that memory is
confined by hardware while registers are confined by firmware convention, which
is exactly what rollback undoes; and that DMUB rollback needs no power cycle,
unlike sOS rollback.
The useful output is a reordered target list. The only user-submittable surfaces
here are SDMA queues (audited, gated) and PM4 -> MEC, which went unaudited only
because MEC was unreadable. It is RV64 and readable now, and the SDMA
methodology transfers directly, so MEC-PM4-FOOTHOLD.md becomes the
highest-expected-value target left.
Rejected compounds are kept too, including FINDING-10 against the HDCP canary
fence (no PSP memory aperture reachable from DMCUB) and FINDING-1 against the
PSR state machine (the host owns CW6 anyway, which lowers FINDING-1's severity).
Diffstat (limited to 'f32')
| -rw-r--r-- | f32/hw/sdma_pollreg_oracle.py | 122 |
1 files changed, 122 insertions, 0 deletions
diff --git a/f32/hw/sdma_pollreg_oracle.py b/f32/hw/sdma_pollreg_oracle.py new file mode 100644 index 0000000..6a76c47 --- /dev/null +++ b/f32/hw/sdma_pollreg_oracle.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Compound test: is SDMA's POLL_REGMEM a register read oracle for an *unprivileged* queue? + +This comes from putting two findings together. + + * DMCUB FINDING-10 (`../../dmcub/AUDIT-DMCUB-05-REGSEQ.md`): `REG_REG_WAIT` leaks + memory because it converts a value the host cannot read into something the host + *can* measure -- the time the command takes to retire. No memory-safety bug is + involved; the channel is the wait itself. + * SDMA (`../AUDIT-SDMA-01.md`): register *access* is gated on `RB_CNTL.RB_PRIV` + [HW] -- `SRBM_WRITE` (write) and `POLL_REG_WRITE_MEM` (read-into-memory) both + refuse on an unprivileged queue. + +`POLL_REGMEM` (op 8, sub_op 0) is the same shape as REG_REG_WAIT: it polls a register +until `(value & mask) FUNC reference`. Critically it **writes nothing**, so the gating +argument that covers the other two ("don't let an unprivileged queue read registers +into its memory") may simply not have been applied to it. If it is ungated, then the +FINDING-10 technique transplants onto an engine whose queues *are* user-submittable, +and an unprivileged process gets an arbitrary register read oracle at one bit per +packet -- which none of the DMCUB findings can offer, because that inbox is kernel-only. + +Packet (navi10_sdma_pkt_open.h): + dw0 header: op=8, sub_op=0, func<<28 (3 = equal), mem_poll<<31 (0 = register) + dw1 addr_lo = register index << 2 (amdgpu's emit_reg_wait convention) + dw2 addr_hi = 0 + dw3 value = reference + dw4 mask + dw5 interval/retry count + +Oracle: set a scratch register to a known value, then submit a matching poll and a +non-matching poll and compare retire times. A non-match must spin (long); a match must +retire immediately. Run with RB_PRIV=1 (control) and RB_PRIV=0 (the question), using the +live-flip method -- a queue configured unprivileged from scratch does not fetch at all. + +usage: PYTHONPATH=/root/psp/tinygrad sdma_pollreg_oracle.py --apply +""" +import argparse +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from sdma_srbm_probe import Probe, SDMA_OP_NOP # noqa: E402 + +SDMA_OP_POLL_REGMEM = 8 +FUNC_EQUAL = 3 +# DW5 = interval (bits 0..15) | retry_count (bits 16..27). A retry_count of 0 means +# poll forever, which wedges the queue on a mismatch and cost the first run of this +# test its RB_PRIV=0 case. Bound it so a mismatch retires by itself while still taking +# far longer than a match. +INTERVAL = 0x40 +RETRY_COUNT = 0x80 +DW5 = (INTERVAL & 0xffff) | ((RETRY_COUNT & 0xfff) << 16) + + +def poll_pkt(reg_index, reference, mask, func=FUNC_EQUAL, mem_poll=0): + hdr = (SDMA_OP_POLL_REGMEM | (0 << 8) | ((func & 7) << 28) + | ((mem_poll & 1) << 31)) + return [hdr, (reg_index << 2) & 0xffffffff, 0, + reference & 0xffffffff, mask & 0xffffffff, DW5] + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('--apply', action='store_true') + ap.add_argument('--target', default='regGRBM_SCRATCH_REG0') + args = ap.parse_args() + if not args.apply: + print('dry run') + return 0 + + from tinygrad import Device + adev = Device["AMD"].iface.dev_impl + treg = adev.reg(args.target) + tidx = treg.addr[0] + p = Probe(adev) + MAGIC = 0x5AA50FF0 + print('oracle target %s (host idx 0x%x -> SRC 0x%x), magic 0x%08x' % ( + args.target, tidx, tidx << 2, MAGIC)) + + out = {} + for priv in (1, 0): + # re-establish a working privileged queue each round, then flip the single + # bit under test, so a wedge in one round cannot silently void the next + p.setup(rb_priv=1) + if priv == 0: + p.reg('_RB_CNTL').update(rb_priv=0) + treg.write(MAGIC) + ok, _ = p.submit([SDMA_OP_NOP]) + if not ok: + print(' RB_PRIV=%d: queue not running -- inconclusive' % priv) + out[priv] = None + continue + # match: (reg & 0xffffffff) == MAGIC -> should retire at once + okm, tm = p.submit(poll_pkt(tidx, MAGIC, 0xffffffff), timeout_s=3.0) + # mismatch: same register, wrong reference -> must spin + okx, tx = p.submit(poll_pkt(tidx, MAGIC ^ 0xffffffff, 0xffffffff), timeout_s=3.0) + sep = (tx > tm * 3) and (tx - tm) > 0.001 + print(' RB_PRIV=%d match %.2f ms (consumed=%s) | mismatch %.2f ms ' + '(consumed=%s) | separable=%s' % ( + priv, 1000 * tm, okm, 1000 * tx, okx, sep)) + out[priv] = (okm, tm, okx, tx, sep) + + print('\n=== result') + if not out.get(1) or not out.get(0): + print(' inconclusive: a control failed') + elif out[1][4] and out[0][4]: + print(' ORACLE ON AN UNPRIVILEGED QUEUE: POLL_REGMEM timing reveals register') + print(' content with RB_PRIV=0, on the same queue where SRBM_WRITE and') + print(' POLL_REG_WRITE_MEM are both refused. FINDING-10 transplanted to a') + print(' user-submittable engine.') + elif out[1][4] and not out[0][4]: + print(' gated: the timing difference exists only with RB_PRIV=1, so the poll') + print(' is covered by the same gate as the other register accesses.') + else: + print(' no timing separation even with RB_PRIV=1 -- the packet did not poll a') + print(' register as intended; no conclusion.') + print('\nqueue alive: %s' % p.submit([SDMA_OP_NOP])[0]) + return 0 + + +if __name__ == '__main__': + raise SystemExit(main()) |
