aboutsummaryrefslogtreecommitdiffstats
path: root/smu
diff options
context:
space:
mode:
authoruser <user@clank>2026-09-28 15:47:22 +0200
committeruser <user@clank>2026-09-28 15:47:22 +0200
commitcb6f03125e019955912baa45faa9359da1d810db (patch)
tree1d7319a884273770ea01677b68c5d87d449c1e2f /smu
parentAudit MES, PFP and ME; settle the IMU ISA as Xtensa (diff)
downloadamd-psp-re-cb6f03125e019955912baa45faa9359da1d810db.tar.gz
amd-psp-re-cb6f03125e019955912baa45faa9359da1d810db.tar.xz
amd-psp-re-cb6f03125e019955912baa45faa9359da1d810db.zip
Probe the SMU host message ABI on silicon; all index args are bounded
SMU is the other big host-facing microcontroller, and several PPSMC_MSG_* arguments are indices -- the shape that produced DMCUB FINDING-2. Probed directly on the card, read-direction only. TransferTableSmu2Dram rejects every table_id >= TABLE_COUNT (positive control: six in-range ids returned data into the driver DRAM buffer). GetDpmFreqByIndex bounds both the clock id and the level. GetVoltageByDpm is a deterministic quadratic curve evaluation, not an indexed load. Two near-misses are recorded rather than banked: GetDpmFreqByIndex accepting 0x100/0xffff is 8-bit masking aliasing, and GetVoltageByDpm's alarming 0xffff -> 0x1e3f2a is arithmetic. TransferTableDram2Smu was deliberately not probed; an out-of-range id in the write direction risks wedging the SMU.
Diffstat (limited to 'smu')
-rw-r--r--smu/AUDIT-SMU-01-HOSTABI.md82
-rw-r--r--smu/hw/smu_argprobe.py107
-rw-r--r--smu/hw/smu_tableprobe.py131
3 files changed, 320 insertions, 0 deletions
diff --git a/smu/AUDIT-SMU-01-HOSTABI.md b/smu/AUDIT-SMU-01-HOSTABI.md
new file mode 100644
index 0000000..a476a8d
--- /dev/null
+++ b/smu/AUDIT-SMU-01-HOSTABI.md
@@ -0,0 +1,82 @@
+# SMU (MP1) host message ABI — probed on silicon, bounded
+
+Evidence class **[HW]**. Card: Navi 31 / RX 7900 XTX, live SMU (`smu_v13_0_0`,
+`C2PMSG_58 = 0x00310036`). Scripts: `hw/smu_tableprobe.py`, `hw/smu_argprobe.py`.
+
+The SMU is the other big host-facing microcontroller on this die: the driver drives it
+with `PPSMC_MSG_*` messages carrying a single 32-bit argument through
+`MP1_SMN_C2PMSG_66/82`, and gets a 32-bit reply back in the same register. Several of
+those arguments are **indices**, which makes them read-oracle candidates — the SMU
+equivalent of DMCUB's `panel_inst` (FINDING-2). All were probed directly on hardware.
+
+## Result: three surfaces, all bounded
+
+### 1. `TransferTableSmu2Dram` (0x12) — table_id is range-checked
+
+The strongest candidate: `table_id` is a raw message argument, the firmware-side enum
+ends at `TABLE_COUNT = 13`, and the transfer lands in a DRAM buffer the host owns and
+reads (AM maps `driver_table_paddr`, 0x4000 at `paddr=0x9a000`). An unchecked id would
+DMA out-of-range SMU memory into host-readable DRAM.
+
+```
+id 0 PPTABLE OK 1156 non-zero bytes sha 0892dc2abb2f0da9
+id 1 COMBO_PPTABLE OK 1180 sha 8ec6b10aec793d08
+id 5 SMU_METRICS OK 86 sha 7b7359971d832e2a
+id 7 ACTIVITY_MONITOR OK 30 sha 9454d5d4f3bc4bfd
+id 8 OVERDRIVE OK 24 sha ff6323cf752b50cb
+id 10 DRIVER_INFO OK 81 sha 07a8ce474387e2f1
+id 13..19, 0x100, 0x1000, 0xdead, 0xffff -> FAIL, 0 bytes written
+```
+
+**Positive control passed** (6 in-range ids returned OK *with* data, proving the path
+and the buffer read are correct), and every out-of-range id returned `FAIL` with the
+buffer still all zero. `table_id` is bounded.
+
+### 2. `GetDpmFreqByIndex` (0x1F) — both fields bounded
+
+Argument is `(PPCLK_e << 16) | level`.
+
+* **clock id**: ids 0..12 return OK; **13 and above all FAIL** — exactly `PPCLK_COUNT = 13`.
+* **level**: for GFXCLK only levels 0..1 return OK (the count query `0xff` replies
+ `0x80000002`, i.e. 2 levels); levels 2..0xfe FAIL.
+* The two "accepted" high args are an **aliasing artefact, not an OOB**: the level
+ field is masked to 8 bits, so `0x100 & 0xff = 0` returns the identical value to
+ level 0 (`0x800001f4`), and `0xffff & 0xff = 0xff` is the count query
+ (`0x80000002`). This is the kind of result that looks like a finding in a sweep and
+ is not one.
+
+### 3. `GetVoltageByDpm` (0x25) — arithmetic, not a memory read
+
+This one accepted every argument, including `0xffff` → `0x001e3f2a`, which looks
+alarming in a table. It is a curve evaluation:
+
+```
+arg 0x0400 -> 2741 arg 0x2000 -> 32097
+arg 0x0800 -> 3995 arg 0x4000 -> 124335
+arg 0x1000 -> 9273 arg 0x8000 -> 495175 arg 0xffff -> 1982250
+```
+
+The reply grows ~quadratically in the argument (2× arg → ~4× reply) and is
+bit-identical on repeat, interleaved with other args. That is the AVFS voltage/frequency
+polynomial, not an indexed load. **Not a disclosure primitive.**
+
+`GetDebugData` (0x37) answers `UNKNOWN_CMD` for every argument — not implemented on
+this ASIC.
+
+## Safety notes
+
+* Read direction only. **`TransferTableDram2Smu` (0x13) was deliberately not probed**:
+ an out-of-range id in the write direction risks corrupting SMU state, and per
+ `../psp_out/CONSTRAINTS.md` recovering a wedged SMU needs a cold ATX cycle.
+* The probes use a bounded 1.5–2 s poll and an `alive()` check (`GetSmuVersion`) after
+ every step, aborting the sweep if the SMU stops answering. It never did; the SMU was
+ alive at the start and end of both runs.
+
+## What this does *not* cover
+
+Only 4 of the ~120 `PPSMC_MSG_*` messages were probed — the ones whose argument is
+index-shaped *and* whose reply is a value. Not examined: the `Set*` messages (skipped
+on purpose, they change device state), the I2C command table (`TABLE_I2C_COMMANDS`
+returned FAIL here, but it carries host-authored transactions and is the most
+interesting remaining SMU surface), `SetToolsDramAddr`, and the MCA/ACA bank dump
+messages (`QueryValidMcaCount` is absent on this ASIC).
diff --git a/smu/hw/smu_argprobe.py b/smu/hw/smu_argprobe.py
new file mode 100644
index 0000000..62228a9
--- /dev/null
+++ b/smu/hw/smu_argprobe.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+"""Probe SMU messages whose argument is index-shaped and whose reply is a value.
+
+These are read oracles by construction: the host puts an index in MP1_C2PMSG_82, the
+firmware answers with a 32-bit value in the same register. If an index is not range
+checked, the reply is a 32-bit read of whatever lies past the array -- an SMU memory
+disclosure primitive, 4 bytes per message, with no ring and no driver cooperation.
+
+Messages probed (smu_v13_0_0_ppsmc.h):
+ 0x1F GetDpmFreqByIndex arg = (PPCLK_e << 16) | level PPCLK_COUNT = 13
+ 0x25 GetVoltageByDpm arg = implementation defined
+ 0x37 GetDebugData arg = index
+
+Decision rule: an out-of-range index is only interesting if it is ACCEPTED (resp OK)
+*and* the returned value varies with the index. A constant reply (0, -1, a clamped
+value) means the firmware clamped or ignored it -- that is not a primitive, and this
+script must say so rather than dress it up.
+
+Read-only: no Set* message is sent, so the device state is not modified.
+
+usage: PYTHONPATH=/root/psp/tinygrad smu_argprobe.py
+"""
+import time
+
+from tinygrad import Device
+
+MSGS = {0x1F: 'GetDpmFreqByIndex', 0x25: 'GetVoltageByDpm', 0x37: 'GetDebugData',
+ 0x02: 'GetSmuVersion'}
+PPCLK = ['GFXCLK', 'SOCCLK', 'UCLK', 'FCLK', 'DCLK_0', 'VCLK_0', 'DCLK_1', 'VCLK_1',
+ 'DISPCLK', 'DPPCLK', 'DPREFCLK', 'DCFCLK', 'DTBCLK']
+PPCLK_COUNT = 13
+RESP = {0: 'TIMEOUT', 1: 'OK', 0xFF: 'FAIL', 0xFE: 'UNKNOWN', 0xFD: 'BAD_PREREQ',
+ 0xFC: 'BUSY'}
+
+
+class Probe:
+ def __init__(self):
+ self.adev = Device["AMD"].iface.dev_impl
+
+ def msg(self, msg, param, timeout_ms=1500):
+ self.adev.mmMP1_SMN_C2PMSG_90.write(0)
+ self.adev.mmMP1_SMN_C2PMSG_82.write(param)
+ self.adev.mmMP1_SMN_C2PMSG_66.write(msg)
+ deadline = time.time() + timeout_ms / 1000.0
+ resp = 0
+ while time.time() < deadline:
+ resp = self.adev.mmMP1_SMN_C2PMSG_90.read()
+ if resp:
+ break
+ time.sleep(0.001)
+ return resp, self.adev.mmMP1_SMN_C2PMSG_82.read()
+
+ def alive(self):
+ return self.msg(0x02, 0, 500)[0] == 1
+
+
+def sweep(p, msg, args, label):
+ print("\n=== 0x%02x %s -- %s" % (msg, MSGS[msg], label))
+ out = []
+ for a in args:
+ resp, val = p.msg(msg, a)
+ out.append((a, resp, val))
+ print(" arg=0x%08x resp=%-8s val=0x%08x (%d)" % (
+ a, RESP.get(resp, hex(resp)), val, val))
+ if resp == 0 and not p.alive():
+ print(" !! SMU unresponsive, aborting sweep")
+ break
+ return out
+
+
+def main():
+ p = Probe()
+ print("smu alive: %s" % p.alive())
+
+ # level index sweep on a known-good clock (GFXCLK): in-range levels are the control
+ gfx = sweep(p, 0x1F, [i for i in range(12)] + [0x20, 0x40, 0x7f, 0xfe, 0x100, 0xffff],
+ "PPCLK_GFXCLK(0), level index sweep (0xff = count query)")
+ # clock-id sweep at level 0: ids >= PPCLK_COUNT(13) are out of range
+ clk = sweep(p, 0x1F, [(c << 16) for c in list(range(20)) + [0x40, 0xff, 0xfff]],
+ "level 0, PPCLK id sweep (>=13 out of range)")
+ volt = sweep(p, 0x25, list(range(8)) + [0x20, 0xff, 0xffff], "arg sweep")
+ dbg = sweep(p, 0x37, list(range(8)) + [0x20, 0xff, 0xffff], "index sweep")
+
+ print("\n--- analysis")
+ for name, rows, inrange in (("GetDpmFreqByIndex level", gfx, lambda a: a < 0x20),
+ ("GetDpmFreqByIndex clkid", clk,
+ lambda a: (a >> 16) < PPCLK_COUNT),
+ ("GetVoltageByDpm", volt, lambda a: a < 8),
+ ("GetDebugData", dbg, lambda a: a < 8)):
+ ok_in = [(a, v) for a, r, v in rows if r == 1 and inrange(a)]
+ ok_out = [(a, v) for a, r, v in rows if r == 1 and not inrange(a)]
+ vals_out = {v for _, v in ok_out}
+ verdict = "no out-of-range arg accepted"
+ if ok_out and len(vals_out) > 1:
+ verdict = ("PRIMITIVE CANDIDATE: %d out-of-range args accepted with %d "
+ "DISTINCT values" % (len(ok_out), len(vals_out)))
+ elif ok_out:
+ verdict = ("accepted %d out-of-range args but all returned the same value "
+ "%s -> clamped/ignored, not a read oracle"
+ % (len(ok_out), [hex(v) for v in vals_out]))
+ print(" %-26s in-range OK=%-3d out-of-range OK=%-3d %s" % (
+ name, len(ok_in), len(ok_out), verdict))
+ print("\nsmu alive at end: %s" % p.alive())
+
+
+if __name__ == '__main__':
+ main()
diff --git a/smu/hw/smu_tableprobe.py b/smu/hw/smu_tableprobe.py
new file mode 100644
index 0000000..a5f8e60
--- /dev/null
+++ b/smu/hw/smu_tableprobe.py
@@ -0,0 +1,131 @@
+#!/usr/bin/env python3
+"""Probe SMU (MP1) PPSMC_MSG_TransferTableSmu2Dram for an unbounded table_id.
+
+Hypothesis: `table_id` is a raw message argument. The firmware-side enum in
+smu13_driver_if_v13_0_0.h ends at TABLE_COUNT = 13, so ids 0..12 are valid. If the
+firmware does not range-check the id, it will index its table-descriptor array out of
+bounds and DMA whatever that yields into the driver DRAM buffer -- which the host owns
+and can read. That is an SMU-internal-memory disclosure primitive.
+
+Controls (required before believing anything):
+ positive: TABLE_SMU_METRICS (5) must return OK and produce non-zero bytes, proving
+ the transfer path works and that we are reading the right buffer.
+ negative: a nonsense id (0xdead) must be handled; if it returns OK with data, the
+ bound is missing for arbitrary ids, not just adjacent ones.
+
+Read direction only. TransferTableDram2Smu (the write direction) is deliberately NOT
+exercised here: an OOB write into SMU memory risks wedging the SMU, which per
+psp_out/CONSTRAINTS.md needs a cold ATX cycle to recover.
+
+Response codes (smu_cmn.h): 1 = OK, 0xFF = FAIL, 0xFE = UNKNOWN_CMD,
+0xFD = BAD_PREREQ, 0xFC = BUSY_OTHER, 0 = no response yet (timeout).
+
+usage: PYTHONPATH=/root/psp/tinygrad smu_tableprobe.py [max_id]
+"""
+import hashlib
+import sys
+import time
+
+from tinygrad import Device
+
+MSG_TransferTableSmu2Dram = 0x12
+MSG_GetSmuVersion = 0x2
+TABLE_COUNT = 13
+TABLE_NAMES = {0: 'PPTABLE', 1: 'COMBO_PPTABLE', 2: 'WATERMARKS', 3: 'AVFS_PSM_DEBUG',
+ 4: 'PMSTATUSLOG', 5: 'SMU_METRICS', 6: 'DRIVER_SMU_CONFIG',
+ 7: 'ACTIVITY_MONITOR_COEFF', 8: 'OVERDRIVE', 9: 'I2C_COMMANDS',
+ 10: 'DRIVER_INFO', 11: 'ECCINFO', 12: 'WIFIBAND'}
+RESP = {0: 'TIMEOUT', 1: 'OK', 0xFF: 'FAIL', 0xFE: 'UNKNOWN_CMD', 0xFD: 'BAD_PREREQ',
+ 0xFC: 'BUSY_OTHER'}
+
+BUF_LEN = 0x1000
+
+
+class Probe:
+ def __init__(self):
+ self.adev = Device["AMD"].iface.dev_impl
+ self.smu = self.adev.smu
+ self.buf_paddr = self.smu.driver_table_paddr
+
+ def raw_msg(self, msg, param, timeout_ms=2000):
+ """Send a message and return (response_code, read_back_arg) without asserting OK."""
+ self.adev.mmMP1_SMN_C2PMSG_90.write(0)
+ self.adev.mmMP1_SMN_C2PMSG_82.write(param)
+ self.adev.mmMP1_SMN_C2PMSG_66.write(msg)
+ deadline = time.time() + timeout_ms / 1000.0
+ resp = 0
+ while time.time() < deadline:
+ resp = self.adev.mmMP1_SMN_C2PMSG_90.read()
+ if resp != 0:
+ break
+ time.sleep(0.001)
+ return resp, self.adev.mmMP1_SMN_C2PMSG_82.read()
+
+ def zero_buf(self):
+ # VRAM over the USB4 bridge needs 4-byte aligned offset AND size (pcie_mem_read
+ # asserts otherwise) -- BUF_LEN is 0x1000 so that holds.
+ self.adev.vram.view(self.buf_paddr, BUF_LEN)[:] = bytes(BUF_LEN)
+
+ def read_buf(self):
+ return bytes(self.adev.vram.view(self.buf_paddr, BUF_LEN)[:])
+
+ def alive(self):
+ resp, _ = self.raw_msg(MSG_GetSmuVersion, 0, timeout_ms=500)
+ return resp == 1
+
+ def try_table(self, tid):
+ self.zero_buf()
+ before = self.read_buf()
+ assert before == bytes(BUF_LEN), "buffer did not zero -- host write to VRAM failed"
+ resp, arg = self.raw_msg(MSG_TransferTableSmu2Dram, tid)
+ after = self.read_buf()
+ nz = sum(1 for b in after if b)
+ return {'id': tid, 'resp': resp, 'resp_s': RESP.get(resp, hex(resp)), 'arg': arg,
+ 'nonzero': nz, 'sha': hashlib.sha256(after).hexdigest()[:16],
+ 'head': after[:32].hex()}
+
+
+def main():
+ max_id = int(sys.argv[1], 0) if len(sys.argv) > 1 else 32
+ p = Probe()
+ print("driver table buffer paddr=0x%x mc=0x%x len=0x%x" % (
+ p.buf_paddr, p.adev.paddr2mc(p.buf_paddr), BUF_LEN))
+ print("smu alive (GetSmuVersion): %s\n" % p.alive())
+
+ print("%-5s %-22s %-11s %-8s %-8s %s" % ("id", "name", "resp", "nonzero", "sha16", "head"))
+ rows = []
+ for tid in list(range(max_id)) + [0x100, 0x1000, 0xdead, 0xffff]:
+ r = p.try_table(tid)
+ rows.append(r)
+ print("%-5d %-22s %-11s %-8d %-8s %s" % (
+ tid, TABLE_NAMES.get(tid, '' if tid < TABLE_COUNT else '<out of range>'),
+ r['resp_s'], r['nonzero'], r['sha'], r['head'][:32]))
+ if not p.alive():
+ print("\n!! SMU stopped responding after id %d -- aborting. Recover with "
+ "work/atx_power.py cycle 25" % tid)
+ break
+
+ ok_inrange = [r for r in rows if r['id'] < TABLE_COUNT and r['resp'] == 1 and r['nonzero']]
+ ok_oob = [r for r in rows if r['id'] >= TABLE_COUNT and r['resp'] == 1]
+ oob_data = [r for r in ok_oob if r['nonzero']]
+ print("\n--- controls")
+ print(" positive (in-range ids returning OK with data): %d %s" % (
+ len(ok_inrange), [r['id'] for r in ok_inrange]))
+ print(" out-of-range ids accepted (resp OK): %d %s" % (
+ len(ok_oob), [r['id'] for r in ok_oob]))
+ print(" out-of-range ids that WROTE data to host DRAM: %d %s" % (
+ len(oob_data), [r['id'] for r in oob_data]))
+ if not ok_inrange:
+ print("\nINCONCLUSIVE: positive control failed, so a negative result means nothing.")
+ elif oob_data:
+ uniq = {r['sha'] for r in oob_data}
+ print("\nPRIMITIVE: table_id is NOT bounded; %d out-of-range ids transferred data "
+ "(%d distinct contents) into host-readable DRAM." % (len(oob_data), len(uniq)))
+ else:
+ print("\nNEGATIVE: every out-of-range id was rejected or produced no data; "
+ "table_id appears bounded.")
+ print("\nsmu alive at end: %s" % p.alive())
+
+
+if __name__ == '__main__':
+ main()