PSP firmware rollback finding — AMD Navi 31 (gfx1100), psp_13_0_0
Not the authoritative document for this topic. See
CONFIRMED-FIRMWARE-ROLLBACK-WORKS.md, andINDEX.md§3 for why. Kept for its detail and history.
Summary
Six sOS syscalls expose sized MMIO read/write to User-mode callers (TAs). All funnel through one shared address-validation helper. In firmware 0.49.0.37 through 0.49.0.51 that helper validates against only two hardcoded MMIO windows and performs no caller-identity check. In 0.49.0.54 it was replaced by a centralised validator that adds a third protected window and gates it on the caller's descriptor type.
No anti-rollback prevents loading the older firmware — verified on hardware.
The syscalls
Callers of the shared helper (sub_3D8C in .54, sub_3E44 in .37):
svc_a6_impl svc_7b_9f_impl svc_a9_impl
svc_7c_a0_impl svc_a5_impl svc_aa_impl
They implement sized load/store (1/2/4/8 byte) plus a masked read-modify-write at a caller-supplied address.
Reachability confirmed: the shipped RAS TA (psp_13_0_0_ta.bin, User mode,
caller class 4) issues SVC 0xA0, which is svc_7c_a0_impl — one of the six.
The check, before and after
OLD (.37 .. .51) — inline, two windows, no identity check:
ADD.W R1, R0, #8 ; end = addr + size
CMP R1, R0 / BCC fail ; overflow
CMP.W R0, #0x3A00000 ┐ deny [0x3900000, 0x3A00000)
CMP.W R1, #0x3900000 ┘
CMP.W R0, #0x3800000 ┐ deny [0x3700000, 0x3800000)
CMP.W R1, #0x3700000 ┘
-> proceed
NEW (.54) — sub_6D20, adds a third window + caller gating:
... same two windows + overflow ...
LDR R3, =off_80D8 ; LDR R3,[R3]
LDRB.W R3, [R3,#0x4B] ; caller id from KERNEL task context (not spoofable)
ADD.W R3, R3, R3,LSL#1 ; id*3
ADD.W R3, R4, R3,LSL#5 ; descriptor, stride 96
LDR R4, =0xCB10000
CMP R0, R4 / BCS allow
CMP.W R1, #0xCB00000 / BLS allow
CMP R3, #1 / BEQ allow ; caller type 1 allowed
CMP R3, #2 / BNE deny ; type 2 allowed, all others DENIED
+0x4B is the same task-context offset used by svc_f2_rpc_gate.
What the windows are
MMIO register regions. SYS_DRV .54 carries a table of (address, mask, flags)
triples at 0xE23340, including entries inside the protected ranges:
0x0c810058 0x03a70020 0x0c870020
0x0cb0403c <-- inside the newly protected [0xCB00000, 0xCB10000)
0x03a000c0 0x0c8000c0
Bisection
Literal 0x0CB10000 present only in .54:
.37 .41 .47 .48 .49 .50 .51 -> absent (old inline form, 2 windows)
.54 -> present (centralised validator)
In .51 two functions still carry the inline checks: a 6-caller helper (88 B) and a 3-caller function (166 B). Both were converted in .54.
.51 is the newest publicly tagged linux-firmware release (20250708) and still lacks the fix. .54 comes from a newer commit than any dated tag checked.
Rollback verified on hardware
Booted 0.49.0.37 (Feb 2023) on a live 7900XTX in place of 0.49.0.54:
is_sos_alive: True TMR: 0x1300000 compute: OK
SPL (security patch level) payload is byte-identical across all 21 releases scanned (Jan 2023 - Jul 2025); only its signature changes. The floor was never raised, so nothing blocks the downgrade.
Status / caveats
- CANDIDATE, not a demonstrated exploit.
- Not established what
0xCB00000controls, only that AMD later restricted it. - Older builds may have compensating checks elsewhere in the chain (untraced).
- Cannot distinguish a fuse-based rollback floor from a table-based one, since the floor never moved in the observed range.
Follow-up: what the protected windows are
The register table at 0xE231F8 (46 entries, stride 12, (mmio_addr, mask, value))
shows mirrored register instances:
0x03af000c / 0x0c8f000c 0x03a10058 / 0x0c810058
0x03af0010 / 0x0c8f0010 0x03a000c0 / 0x0c8000c0
0x03a70020 / 0x0c870020 / 0x0cb0403c <- three instances, same mask 0x3f
Buckets: 0x032xxxxx (10), 0x03axxxxx (11), 0x0c8xxxxx (11), 0x0cbxxxxx (1).
Of all 46 entries exactly one — 0x0cb0403c — falls inside any protected
window, and it is the window .54 added. It appears to be a third instance of
the register mirrored at 0x03a70020 / 0x0c870020.
Block identity not determinable from firmware alone: no symbols, and SYS_DRV has zero direct operand references to any of the three windows (verified by scanning all functions). The registers are reached only through this data table.
The legacy windows are likewise referenced nowhere except inside the sOS validator itself — they are pure deny-lists: regions the kernel forbids but never itself touches via these syscalls.
SYS_DRV diff (.37 -> .54): 54 modified, 11 removed, 33 added
Cross-referenced against our 12 annotated functions:
stack_chk_fail unchanged
sysdrv_map_and_copy unchanged
psp_map_host_phys_range unchanged <-- PF-bypass present since .37
gfx_cmd_LOAD_IP_FW_handler unchanged <-- fw_size>0xFF DoS still present
gfx_cmd_PROG_REG_regid_switch unchanged
progreg_table_lookup unchanged
progreg_apply unchanged
vf_phys_range_check unchanged
sysdrv_log_emit unchanged
sysdrv_mailbox2_cmd_handler MODIFIED
stub_ret_FFFF0007 (matcher miss)
sysdrv_big_dispatch_maybe (matcher miss: .37 counterpart is e100e4)
SYS_DRV's host-facing ring surface has been stable for 2.5 years. The
PF-bypass in psp_map_host_phys_range being unchanged across 8 releases
suggests it is intentional design (the PF is trusted for addressing), not an
oversight — this downgrades that earlier audit finding.
The only security-relevant fix located in 2.5 years is the sOS MMIO-window change, on the TA -> kernel path.
Tooling caveat
diff_versions.py uses an absolute pairing threshold (bs < 40), which
wrongly rejects large-function pairs: e100e4(.37) <-> e105a8(.54) scores
~76 despite being obviously the same function (4318/4372 B, 664/671 bb,
224/227 calls). Scoring must be normalised by function size before this is
trustworthy unsupervised.
CORRECTION: who can actually reach the MMIO syscalls
An earlier claim that "the RAS TA issues SVC 0xA0" was WRONG. It came from a capstone linear sweep that misdecoded data as instructions (phantom SVCs 0xA0 and 0xE8). Proper IDA analysis of all four TAs:
TA base funcs SVC census MMIO syscalls
ASD 0xD00100 1551 51 56 57 59 5a 5d 5e 5f 6c 6d 7b(135) YES: 0x7b, 0x7c, 0xa5
7c(98) 7d 8b 8d a5(1) b4 f2
RAS 0x400100 98 f0, f1, f2(12) none
HDCP 0x400100 102 f0, f1, f2(49) none
DTM 0x400100 14 f0, f1, f2(3) none
A sharp privilege boundary exists between ASD and the ordinary TAs.
- Ordinary TAs (RAS/HDCP/DTM) share the 0x400000 slot and issue only three
SVCs — 0xF0, 0xF1 and 0xF2 — i.e. essentially everything goes through the
svc_f2_rpc_gate. They never touch the raw MMIO syscalls. - ASD loads at its own base (0xD00100) via a distinct command (LOAD_ASD, ring cmd 0x04) and has direct access to 18 distinct syscalls, including 234 call sites into the MMIO read/write primitives.
This is consistent with ASD being caller descriptor type 1 or 2 (permitted by
the .54 check) and ordinary TAs being type 4 (denied).
Revised severity
The .54 change does not protect against any shipped TA — none of
RAS/HDCP/DTM can issue those SVCs in their shipped code. It guards against a
malicious or compromised TA issuing the SVCs directly, which nothing in the
kernel prevents (dispatch is purely on SVC number).
So this is a privilege-escalation primitive, not an entry point:
precondition: code execution inside ANY TA (UNSOLVED)
-> issue SVC 0x7b / 0x7c / 0xa5 directly
-> on .37-.51: no caller-identity check, [0xCB00000,0xCB10000) reachable
-> on .54: denied unless caller type in {1,2}
The proposed 0xCB00000 read experiment is BLOCKED
Reading the window via those syscalls is not possible from the host:
- the host cannot issue SVCs at all; they are issued by code on the PSP
- no TA we can invoke from the host issues them with attacker-controlled args
- ASD does issue them, but its arguments are not host-controllable
Identifying 0xCB00000 therefore still requires either hardware documentation
or TA code execution.
ASD analysis (11 versions, Jan 2023 - pinned)
version size tags
33.0.0.154 176,128 20230117..20230515
33.0.0.167 184,320
33.0.0.176 184,320
33.0.0.187 184,320
33.0.0.210 192,512
33.0.0.223 192,512
33.0.0.227 192,512
33.0.0.231 192,512
33.0.0.238 196,608
33.0.0.239 196,608 20250708 (newest public tag)
33.0.1.21 208,896 PINNED (minor-version bump)
ASD grew 19% (176 KB -> 209 KB). Contrast sOS, which is byte-for-byte the same size (98,944) in all 8 versions.
Churn profile — ASD is actively developed, sOS is maintained
transition unchanged modified removed added
33.0.0.154 -> 33.0.0.176 805 230 6 41
33.0.0.176 -> 33.0.0.210 1058 187 2 48
33.0.0.210 -> 33.0.0.239 1203 128 1 21
33.0.0.239 -> 33.0.1.21 1290 127 0 24
154 vs 1.21: 725 unchanged, 309 modified, 6 removed, 163 added
309/1551 = 20% of ASD changed, versus 13/505 = 2.6% for sOS. Raw function diffing does not isolate anything useful at this churn rate — a different filter is required.
Privileged syscall usage shifted substantially
33.0.0.154 33.0.1.21
SVC 0x7b 126 135
SVC 0x7c 341 98 <-- -71%
SVC 0xa5 1 1
distinct SVCs 14 18 (added 0x6c, 0x6d, 0x7d, 0x8b)
ASD cut its use of the raw MMIO read syscall (svc_7c_a0_impl) by 71% and
picked up four new syscalls. That is a real architectural shift away from raw
MMIO access, but it is ASD changing its own behaviour — not, by itself, a
vulnerability.
Status
No specific vulnerability isolated in ASD. The 20% churn rate defeats signature diffing as a bug-finding filter here; ASD needs to be approached as a normal audit target (it is the most privileged non-kernel component: 1551 functions, 18 syscalls, 234 direct MMIO call sites) rather than via version differential.
