diff options
| author | user <user@clank> | 2026-09-28 15:47:22 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-09-28 15:47:22 +0200 |
| commit | cb6f03125e019955912baa45faa9359da1d810db (patch) | |
| tree | 1d7319a884273770ea01677b68c5d87d449c1e2f /smu | |
| parent | Audit MES, PFP and ME; settle the IMU ISA as Xtensa (diff) | |
| download | amd-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.md | 82 | ||||
| -rw-r--r-- | smu/hw/smu_argprobe.py | 107 | ||||
| -rw-r--r-- | smu/hw/smu_tableprobe.py | 131 |
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() |
