aboutsummaryrefslogtreecommitdiffstats

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, and INDEX.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 0xCB00000 controls, 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.