SDMA (gfx11) — first audit pass over the host-packet surface
Evidence class [ST]. Image psp_out/sdma_6_0_0.bin, thread TH0. Tool:
sdma_audit.py (table / screen / gates / hand), on top of the F32 disassembler
made usable in AUDIT-F32-01.md.
Why SDMA: it executes packets the driver writes into rings in host-visible memory, so
it has the same surface shape as the DMCUB inbox (FINDING-1/2) and the DCN4 LSDMA
command (FINDING-9) — and it is loadable on this card (SDMA_UCODE_TH0/TH1 are 2
of the 37 accepted LOAD_IP_FW types), so anything found here is testable on silicon
we actually have, unlike FINDING-9.
The packet dispatch table, recovered and validated
The last 0x300 bytes of TH0 are the dispatch table: entries are
(key << 16) | target with key = (op << 8) | (sub_op << 4). That decoding is not
a guess — it is checked against navi10_sdma_pkt_open.h:
0x0100 COPY/LINEAR 0x0140 COPY/LINEAR_SUB_WIND 0x0400 INDIRECT
0x0110 COPY/TILED 0x0170 COPY/DIRTY_PAGE 0x0500 FENCE
0x0130 COPY/SOA 0x0180 COPY/LINEAR_PHY 0x0600 TRAP
0x0c00 PTEPDE/GEN 0x0e00 SRBM_WRITE 0x1000 GPUVM_INV
Every documented opcode and sub-opcode lands where the enum says it should, which is
what makes the table trustworthy. 52 distinct packets are implemented; entries
beyond 0x1110 point at the common stub 0x0e0a and are padding.
The taint source is exact. Upstream's README: "r1 is a magic register that pulls
dwords off of the command queue when read". So mov rX, r1 is literally "consume the
next host-supplied dword", which gives a precise, non-heuristic marker for host data —
better than the pointer-tainting model used for DMCUB.
SRBM_WRITE is gated, and the gate is legible
SRBM_WRITE (0x0e00) is the packet that writes a host-named register — the exact
shape of DMCUB's by-design register interface. It is not unguarded:
0b00 ldw r5, reg[r0, #0x4a14] ; and r5, r5, #0x200 ; lsr r5, r5, #9 ; bit 9
0b04 ldw r5, reg[r0, #0x5968] ; lsr r5, r5, #31 ; OR bit 31
0b07 ldw r5, reg[r0, #0x5940] ; lsr r5, r5, #23 ; and r5, r5, #0x1 ; AND bit 23
0b0a and r5, r6, r5
0b0e mov r11, r1 ; host dword: register address
0b0f mov r6, r1 ; host dword: value
0b10 cbz r5, _PKT_0xe00_2 ; gate fails -> reject
0b11 lsr r5, r11, #20 ; aperture bits of the address
0b16 bl _DMA_DATA_39 ; perform the write
The reject path raises an error rather than falling through:
_PKT_0xe00_2: mov r13, #0x3c ; mov r14, #0x32 ; bl _PKT_0xf00_26
SRBM_WRITE/sub1 (0x0e10) carries the same gate. So the obvious "arbitrary register
write from a ring packet" is closed on this firmware.
Gate map — and a wrong measurement I had to throw away
The first cut asked a binary question: "does the handler contain the SRBM_WRITE gate
idiom (reg 0x4a14 bit 9)?" It produced a headline-looking result — PTEPDE, the
packet that writes page-table entries, is not gated — which is false. PTEPDE gates
on a different register:
09c4 ldw r5, reg[r0, #0x5a6c] ; and r5, r5, #0x101 ; seteq r5, r5, #0x101
09c7 cbz r5, _PKT_0xc00_0
So different packet classes consult different control registers, and a single-idiom
test cannot answer the question. sdma_audit.py gates now reports the map instead
of a verdict:
| register | handlers | note |
|---|---|---|
0x4a14, 0x4af8, 0x4b58 |
49 | shared epilogue — reachable from nearly everything, not a per-packet gate |
0x49f4 |
15 | |
0x5940 |
14 | |
0x5968 |
7 | part of the SRBM_WRITE gate |
0x5a6c |
5 | PTEPDE's gate |
0x5980, 0x5970, 0x49f0 |
2–3 |
The 49-handler entries are a confound from the analysis, not a property of the firmware: the worklist walk runs into the common exit path. Only registers consulted by a subset of packets carry information.
Unpublished opcodes — checked, benign
op3 with sub-ops 1..6 (0x0310..0x0360) is absent from the kernel enum, which made it
the most promising lead by analogy with DMCUB's unpublished types 1/3/4 (those became
FINDING-10). It is not interesting: each handler just drains host dwords and rejoins the
main loop.
PKT_0x310: mov r3, r1 ; mov r3, r1 ; ... (8x) ; b _PKT_0xf00_2
PKT_0x320: b _PKT_0x310_0
These are "swallow N dwords" stubs for reserved opcodes. op35/sub2, op119 and the
op126 block decode as padding entries pointing at the invalid-packet stub.
[HW] The gate is RB_PRIV — named by behaviour, confirmed on this card
Script hw/sdma_srbm_probe.py, evidence hw/EVIDENCE-sdma-srbm-gate.txt.
Naming it statically failed, and that is recorded as a negative: fitting the F32
index space against gc_11_0_0_offset.h gives a best constant base scoring 25/54
observed indices against a null 95th percentile of 24 — indistinguishable from
chance over a table of 848 dense SDMA registers. The earlier "0x5968 = SDMA0_F32_CNTL"
style matches were curve-fitting, and are withdrawn.
What identified it was the bit position. SDMA*_QUEUE*_RB_CNTL__RB_PRIV is mask
0x00800000 = bit 23, and the gate's AND-term is exactly
ldw r5, reg[r0, #0x5940] ; lsr r5, #23 ; and r5, #1. That is a testable prediction,
so it was tested:
=== RB_PRIV = 1
control: NOP packet consumed=True (0.1 ms)
SRBM_WRITE consumed=True -> regGRBM_SCRATCH_REG0 = 0x5100d00d WROTE
=== RB_PRIV = 0
control: NOP packet consumed=True (0.1 ms)
SRBM_WRITE consumed=True -> regGRBM_SCRATCH_REG0 = 0x00000000 not written
Two things make this clean rather than suggestive:
- The NOP control still retires with RB_PRIV=0. A queue configured from scratch
with
RB_PRIV=0does not fetch at all — the ring fetch is itself privileged — so "nothing happened" would have proved nothing. The experiment therefore brings the queue up privileged and then clears onlyRB_PRIVon the live queue. The ring keeps running (NOP retires in 0.1 ms) while SRBM_WRITE stops taking effect, which isolates the difference to that one bit. - The rejected packet is still consumed (
rptradvances, queue keeps running,CONTEXT_STATUS = 0x804), i.e. the firmware drops it silently rather than wedging — consistent with the_PKT_0xe00_2error path rather than a hang.
Conclusions.
- F32 register index
0x5940is the current queue'sRB_CNTL. This is the first F32 register named in this tree, and it is named semantically rather than by offset arithmetic — the same standarddmcub_regmap.pyused for DMCUB. - [HW] An SDMA ring packet can write an arbitrary register —
SRBM_WRITEwith a host-chosen absolute index landed0x5100d00dinGRBM_SCRATCH_REG0. - The confinement holds. That power is available only to queues the driver marks privileged. This is a confirmed negative, and a valuable one: had the gate been missing, an unprivileged user queue writing arbitrary registers would have been a genuine user→kernel escalation, which is a stronger boundary than anything DMCUB offers (its inbox is driver-only by construction).
The method generalises: any F32 register whose bits gate observable behaviour can be named this way, which is the practical route to the register namespace that offset matching could not deliver.
[HW] PTEPDE is not privilege-gated — and why that is correct, not a bug
Scripts hw/sdma_ptepde_probe.py, evidence hw/EVIDENCE-sdma-ptepde.txt.
First, a correction to this document's own earlier text. It said "PTEPDE gates on reg
0x5a6c bits 0x101". That is wrong. The branch rejects nothing: with the bits set it
applies an extra address/value fixup (two _DMA_DATA_6 calls), and both paths fall
into the same PTE write at _PKT_0xc00_0. It selects an implementation, not a
permission. So PTEPDE has no privilege check at all — a testable prediction.
Tested with the same live-flip method, on the queue where SRBM_WRITE is refused:
RB_PRIV = 1 NOP ok PTEPDE -> buffer WRITTEN entry[0]=0xaaaa0000bbbb0000
entry[1]=0xaaaa0000bbbb1000 (+incr)
RB_PRIV = 0 NOP ok PTEPDE -> buffer WRITTEN entry[0]=0xcccc0000dddd0000
entry[1]=0xcccc0000dddd1000
The entries increment by incr exactly as the ABI specifies, so the packet genuinely
executed. PTEPDE writes page-table entries from an unprivileged queue.
That sounds alarming, so the follow-up decides whether it matters: is the destination
physical, or translated? On this card mc = paddr + 0x8000000000, so the same buffer
can be addressed both ways:
pe = MC address 0x8001603000 -> buf[0] = 0x1111000022220000 HIT
pe = raw physical 0x0001603000 -> buf[0] = 0x0000000000000000 -
GCVM_L2_PROTECTION_FAULT_STATUS: walker_error=1, mapping_error=1, rw=1, vmid=0, addr 0x1603000
The raw physical address raises a GCVM L2 protection fault and writes nothing. So PTEPDE's destination goes through the queue's VMID page tables like any other SDMA memory access, and the queue survived both (a NOP still retires afterwards).
The trust model is therefore coherent, and this is a confirmed negative:
| packet class | confined by | gate needed? |
|---|---|---|
SRBM_WRITE (register access) |
nothing — the VM cannot confine an MMIO write | yes, and it has one (RB_PRIV) [HW] |
PTEPDE, COPY, WRITE (memory access) |
the queue's VMID page tables [HW] | no, and it has none |
A user queue issuing PTEPDE can only write pages already mapped into its own VM, which
is the same reach its ordinary WRITE/COPY packets have. The asymmetry between the
two packets is exactly what a correct design looks like, not an oversight.
A lead that looked like a finding, and died on hardware
Worth recording in full, because the static evidence was persuasive.
Screening for host data reaching a register index (rather than a DMA address) found
exactly one candidate outside the gated SRBM_WRITE path, in
POLL_REGMEM/POLL_MEM_VERIFY (key 0x0830):
0785 mov r5, r1 ; 12th host dword
0787 cbz r7, ... ; r7 = header bit 31 = the ABI's `mode` field
0792 ldw r11, mem[r0, #0]
0794 bl _PKT_0xf00_44
079c bl _DMA_DATA_36
079e stw r11, reg[r5, #0] ; register index from a host dword?
And SDMA_PKT_POLL_MEM_VERIFY_RESERVED_reserved_offset is 12 — the field the
published ABI calls reserved appeared to be consumed as a register number, in a
handler with no privilege check. That would have been an ungated arbitrary register
write from an unprivileged queue, i.e. a real user→kernel primitive.
Hardware says no. Aimed at F32 index 0x5940 (the queue's RB_CNTL — the one index
we have named [HW], and one the host can read back), with mode=1 so the path is
taken:
RB_PRIV=1 mode=1 consumed=True -> RB_CNTL 0x00841015 -> 0x00841015 unchanged
RB_PRIV=0 mode=1 consumed=True -> RB_CNTL 0x00041015 -> 0x00041015 unchanged
Root cause of the bad lead: the dataflow was not call-clobber aware. There are two
bl calls between mov r5, r1 and the store, and F32 helpers clobber scratch
registers, so r5 at 0x079e is not the host dword — it is whatever the callee left
behind. This is precisely the flaw called out in ../mes/AUDIT-MES-02-API.md
("absence of evidence is weak because the pass is not call-clobber aware"), reproduced
here in the other direction: it manufactured a positive that was not real.
Two process notes worth keeping:
- The first version of this test aimed at
regGRBM_SCRATCH_REG0's host index (0x2040).reg[r5]indexes the F32 namespace, which does not align with the host's, so that run's null result was uninformative — a miss would have been indistinguishable from a wrong target. Re-aiming at the one F32 index we have actually identified is what made the negative meaningful. POLL_MEM_VERIFYwithmode=0wedges the queue (consumed=False, and the queue stays dead): the packet polls until its comparison succeeds, by design. That is a self-inflicted stall by the queue's own owner, and SDMA's watchdog (SDMA_WATCHDOG_CNTL.queue_hang_count, which the driver sets to ~10 s) is the intended backstop — noted as behaviour, not claimed as a finding.
Call-clobber-aware taint: one sink in all of TH0, and it is gated [HW]
The screening pass was rebuilt to be call-clobber aware (sdma_audit.py taint):
per-callee clobber sets are computed over the call graph to a fixpoint, and a host
taint is killed across any bl that may define the register. Re-running it over all 52
packets:
- the
POLL_MEM_VERIFYfalse lead disappears, which is the control that the new pass actually fixes the flaw that produced it; - exactly one sink remains in the whole of TH0 —
0x0657 ldw r10, unk[r5]inPOLL_REGMEM/POLL_REG_WRITE_MEM, where the host dword is the address of a register to read.
That handler is the read counterpart of SRBM_WRITE: it reads the register the packet
names and DMAs the value to a host-supplied address. The address convention is known
rather than guessed — amdgpu's sdma_v6_0_ring_emit_reg_wait passes reg << 2 — so
the test can predict the exact expected value:
RB_PRIV=1 host wrote 0xfeedface -> memory got 0xfeedface LEAKED
RB_PRIV=0 host wrote 0xfeed0531 -> memory got 0x00000000 nothing
It is gated. Register reads require RB_PRIV just as register writes do, so the
trust model is uniform and there is no read/write asymmetry to exploit.
That result invalidates my own gate map as a security indicator
cmd_gates said this packet does not contain the 0x4a14-bit-9 idiom, yet hardware
shows it is privilege-gated anyway — the enforcement lives somewhere the static screen
does not see (a shared helper such as _PKT_0xf00_50, or the unk[] aperture access
being checked in hardware). So the gate map under-reports gating and must not be read
as "these packets are unprivileged". Only the hardware test is authoritative. The
same caveat now sits on the map itself.
Standing scoreboard for TH0's host surface
| capability | confinement | evidence |
|---|---|---|
register write (SRBM_WRITE) |
RB_PRIV |
[HW] sdma_srbm_probe.py |
register read (POLL_REG_WRITE_MEM) |
RB_PRIV |
[HW] sdma_regread_probe.py |
memory write (PTEPDE, COPY, WRITE) |
queue VMID page tables | [HW] sdma_ptepde_probe.py |
| host data → register index / memory base | 1 site, and it is the gated read above | [ST] call-clobber-aware taint |
No primitive was found in TH0. Each capability that escapes VM confinement is privilege-gated, and the one remaining taint sink is one of them.
TH1 (the "ctl" thread) — examined, no host-controlled register index
TH1 is a different animal from TH0 and the difference shows up immediately:
| TH0 | TH1 | |
|---|---|---|
reads of r1 (host command queue) |
370 | 11 |
| packet dispatch table | 52 packets, validated | none |
computed register accesses (reg[rN], N≠0) |
5 | 52 |
The tail of TH1 is not a dispatch table. f32dis assumes the trailing page is a CP
packet jump table and duly prints 64 entries, but the keys are garbage (0xd411,
0x6967, 0x86f7…) rather than (op<<8)|(sub_op<<4) values — an artefact of the
CP-oriented tool, not a structure in the firmware. So TH1 does not process ring
packets; it is the queue/context control thread.
Its interesting feature is those 52 computed register accesses (40 reads, 12 writes). Root-cause tracing of every index:
const 48 firmware-chosen register blocks
from caller 2
stw r7, [r0, #0x6b] 1
stw r3, reg[r0, #0x5978] 1
The two "from caller" sites are a register-programming helper (_PKT_0xc3a5_33):
0521 mov r5, r1 ; value = host dword
0522 seteq r6, r3, #0x4ba8 ; five indices special-cased first ...
052a seteq r6, r3, #0x4bb0 ; (0x4ba8, 0x4bb0, 0x4bb4, 0x4bb8, 0x4bbc)
052c stw r5, reg[r3, #0x0] ; ... default: program the register
Host data supplies the value, so the question is whether it also supplies the index. It does not. Every caller computes it:
mov r3, #0x4b80 ; add r3, r3, r10 ; r10 = loop variable
mov r5, #0x4b80 ; mov r6, #0x4ce0 ; sub r6, r6, r5 ; stride = 0x160
mul r5, r6, #0x8 ; setge r6, r10, r5 ; bounded to 8 iterations
i.e. r3 = 0x4b80 + queue * 0x160, bounded by an 8-iteration loop. This is queue
context restore: the firmware walks the eight queues' register blocks and reloads their
saved state. The index is firmware-computed and bounded; only the values are
host-supplied, which is the whole point of restoring a queue's context. No primitive.
Two useful by-products:
- Structural fact for the register namespace: the per-queue register block in F32
index space is base
0x4b80, stride0x160, 8 queues (0x4b80..0x5460). That is independent of, and consistent with, the0x5940 = RB_CNTLidentification from hardware, which lies outside that range — so0x5940is a current-queue alias window rather than a per-queue slot. - Five queue registers (
0x4ba8,0x4bb0,0x4bb4,0x4bb8,0x4bbc) are not restored directly; they are diverted into internal shadow slots. Worth naming later — a register the firmware refuses to restore verbatim is exactly where a restore-stream attack would aim, if the stream were ever attacker-controlled.
The F32 register namespace, solved
Naming registers by fitting a constant delta against gc_11_0_0_offset.h failed
earlier at chance level (25/54 vs a null 95th percentile of 24). The namespace fell out
of structure instead, from two independent anchors:
- TH1 walks the eight per-queue register blocks with F32 base
0x4b80, stride 0x160 (recovered above from its bounded 8-iteration loop); - the host per-queue stride is 0x58 dwords (
regSDMA0_QUEUE{n}_RB_CNTL= 0x0080, 0x00d8, 0x0130, …).
0x160 / 0x58 = 4 exactly, so F32 addresses registers by byte offset:
F32_index = 4 * host_dword_offset + 0x4980
Validation, against a null model: 31 of the 54 observed indices resolve to a named
regSDMA0_* register, where randomly drawn indices over the same range score a median
of 5, a 95th percentile of 9 and a maximum of 14 over 400 trials. The 4-alignment
requirement alone rejects 3 in 4 candidates, and the surviving names are semantically
right for a DMA engine's inner loop:
| F32 index | register | uses |
|---|---|---|
0x4a14 |
regSDMA0_STATUS_REG |
45 |
0x49f4 |
regSDMA0_CHICKEN_BITS |
16 |
0x4a60 |
regSDMA0_STATUS2_REG |
12 |
0x4b58 |
regSDMA0_STATUS4_REG |
common path |
0x49f0 |
regSDMA0_CNTL |
6 |
0x4a70 |
regSDMA0_UTCL1_CNTL |
4 |
sdma_audit.py gates now prints names instead of bare indices.
The gate registers are unpublished
The indices that stayed unnamed cluster tightly: 0x5940, 0x594c, 0x5950, 0x5968,
0x5970, 0x5980, 0x5a6c map to host dword offsets 0x3f0–0x43b, which lie inside
the SDMA0 aperture but have no definition anywhere in AMD's header. So the
SRBM_WRITE gate (0x5940 bit 23, 0x5968 bit 31) and PTEPDE's implementation switch
(0x5a6c bits 0/8) live in registers AMD does not publish.
That also resolves an apparent inconsistency. Hardware proved 0x5940's bit 23 behaves
exactly as RB_CNTL.RB_PRIV, yet under the formula 0x5940 is not any queue's
RB_CNTL (those are 0x4b80 + q*0x160). Both facts hold if 0x3f0 is a
current-queue context shadow: when SDMA switches to a queue it copies that queue's
RB_CNTL into an unpublished context register, and the firmware gates on the shadow.
That is a hypothesis consistent with all the evidence, not a measurement — the
behavioural result (flipping RB_PRIV flips SRBM_WRITE) stands on its own either way.
What this pass does not establish
- No finding. This is reconnaissance: the table, the taint source, and the gating structure. No memory-safety bug has been demonstrated in any handler.
- Most of the register namespace is still unmapped (
AUDIT-F32-01.md).0x5940is named [HW];0x4a14 bit 9,0x5968 bit 31(the gate's OR-term) and0x5a6c bits 0/8(PTEPDE's implementation switch) are not. - Handler analysis is a bounded worklist walk (250 instructions, follows branch
targets), an under-approximation, not a closure — and it is not call-clobber
aware, which already produced one false lead (above). Any future "host data reaches
X" claim from this tooling must be checked across
blboundaries before being believed. - TH1 is now examined at the same level as TH0 (above), but its callers were traced
only for the register-programming helper; the rest of its control flow, and what
drives its 11
r1reads, are not mapped. - 2.6% of TH0 words still decode as unknown (CI-era opcode table), so a handler could do something the listing does not show.
Next steps, in order of value
- ~~Name the gate registers~~ / ~~test the gate on hardware~~ — both done above
for the AND-term (
0x5940=RB_CNTL, bit 23 =RB_PRIV). Still unnamed: the OR-term (0x4a14bit 9,0x5968bit 31) and PTEPDE's0x5a6cbits 0/8, each of which is now a concrete behavioural experiment of the same shape. - Audit the copy/fill handlers (
COPY/*,CONST_FILL,WRITE/*) for length and address handling, which is where an SDMA memory-safety bug would live. - Map TH1's table the same way.
