aboutsummaryrefslogtreecommitdiffstats

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=0 does 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 only RB_PRIV on 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 (rptr advances, queue keeps running, CONTEXT_STATUS = 0x804), i.e. the firmware drops it silently rather than wedging — consistent with the _PKT_0xe00_2 error path rather than a hang.

Conclusions.

  1. F32 register index 0x5940 is the current queue's RB_CNTL. This is the first F32 register named in this tree, and it is named semantically rather than by offset arithmetic — the same standard dmcub_regmap.py used for DMCUB.
  2. [HW] An SDMA ring packet can write an arbitrary register — SRBM_WRITE with a host-chosen absolute index landed 0x5100d00d in GRBM_SCRATCH_REG0.
  3. 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_VERIFY with mode=0 wedges 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_VERIFY false 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] in POLL_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, stride 0x160, 8 queues (0x4b80..0x5460). That is independent of, and consistent with, the 0x5940 = RB_CNTL identification from hardware, which lies outside that range — so 0x5940 is 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). 0x5940 is named [HW]; 0x4a14 bit 9, 0x5968 bit 31 (the gate's OR-term) and 0x5a6c 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 bl boundaries 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 r1 reads, 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

  1. ~~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 (0x4a14 bit 9, 0x5968 bit 31) and PTEPDE's 0x5a6c bits 0/8, each of which is now a concrete behavioural experiment of the same shape.
  2. Audit the copy/fill handlers (COPY/*, CONST_FILL, WRITE/*) for length and address handling, which is where an SDMA memory-safety bug would live.
  3. Map TH1's table the same way.