Unbounded PS operand index causes kernel stack OOB read/write via caller buffer
- File:
sys/dev/drm/radeon/atom.c - Lines: 224, 225, 229, 501, 502, 505, 1182, 1184, 634, 1335
- Severity: High
- CVSS:
CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U:C:H/I:H/A:H - CWE: CWE-787 Out-of-bounds Write
- Confidence: certain
Summary
The PS (parameter-space) operand reads/writes ctx->ps[idx] where idx is a raw
byte (0β255) from the VBIOS bytecode with no bounds check.
ctx->ps points to the caller's parameter buffer, which is a small stack-local
array (e.g. uint32_t ps[16] in atom_asic_init).
Any idx β₯ 16 is a kernel stack out-of-bounds read (info leak) or write
(return-address overwrite). The ps_shift mechanism compounds this by advancing
the base pointer on each nested table call.
Root cause
In atom_get_src_int ATOM_ARG_PS (atom.c:224-232):
idx = U8(*ptr) (line 225) yields 0β255, then line 229
val = get_unaligned_le32((u32 *)&ctx->ps[idx]) dereferences params + idx*4
with no bounds check.
ctx->ps is set to the params argument at atom.c:1184 (ectx.ps = params).
At the top call level, params points to the caller's stack buffer β e.g.
atom_asic_init declares uint32_t ps[16] (atom.c:1335, 64 bytes),
atombios_crtc passes &args structs of similar small size
(atombios_crtc.c:77).
For idx=255, the access is params + 1020 bytes past the 64-byte buffer β deep
into the kernel stack.
The write path (atom_put_dst ATOM_ARG_PS, atom.c:501-506, line 505:
ctx->ps[idx] = cpu_to_le32(val)) is identical: a controlled 32-bit write at a
controlled stack offset.
Furthermore, atom_op_calltable passes ctx->ps + ctx->ps_shift (atom.c:634)
to the called table, where ps_shift = ps/4 (atom.c:1182) and ps is the
table-declared PS size from the VBIOS (atom.c:1176, masked to 0β127, so
ps_shift = 0β31).
A table declaring ps=128 (ps_shift=32) shifts the called table's params
base 128 bytes forward before any PS access, guaranteeing OOB even with idx=0.
The declared PS size is never validated against the actual params buffer size.
Threat
Crafted VBIOS delivers a command table whose bytecode contains a PS operand with
a large index byte (e.g. 0x80).
On execution during atom_asic_init (boot-time GPU init, atom.c:1347) or any
display operation, the read variant leaks ~1 KB of kernel stack contents (which
may contain credential pointers, return addresses, or canaries), and the write
variant overwrites the kernel stack with attacker-controlled values β including
saved return addresses of the calling chain
(atom_execute_table_scratch_unlocked β atom_execute_table β
atom_asic_init β caller), giving RIP control and kernel code execution.
The written value is fully attacker-controlled via a preceding MOVE_IMM source
operand.
Same threat model (evil PCIe / VM passthrough VBIOS). Stack writes at controlled offsets from a known base are the most reliable kernel privilege-escalation primitive.
Exploit / PoC
Craft a VBIOS command table N (ensuring cmd_table[4+2*N] is non-zero) with:
header WS=0, PS=4 (small, to not alarm sanity).
Bytecode:
MOVE_PStoPS[0x80]β opcode byte forMOVE_PSis0x02(atom.c:1042/atom-names.h:36), attr byte encodesdst=PS/DWORD(arg=1<<0... actually attr low 3 bits =ATOM_ARG_PS=1, bits[5:3]=ATOM_SRC_DWORD=0), followed by PS index byte0x80, then source IMM dword =0x41414141(attacker value).
atom_asic_init passes a 16-dword ps buffer; PS[0x80] writes at offset
0x200 past the buffer on the kernel stack.
Trigger via atom_asic_init (automatic at GPU probe) β the write corrupts the
stack frame of atom_asic_init's caller.
Observe: kernel panic on return (corrupted return address), or for a controlled demonstration, set the value to a known invalid address and confirm the fault RIP matches.
For a read leak proof: use COMPARE_PS with idx=0x80 to load a stack dword
into cs_equal/cs_above, then branch on it via JUMP_EQUAL to a path with
observable side effects (e.g. BEEP), leaking one bit per comparison β a
timing/side-channel oracle for stack contents.
Recommended fix
Thread the actual parameter-buffer size through the call chain and validate idx
against it. At minimum, cap idx at the table-declared PS size and reject
ps_shift that would advance past the buffer:
--- a/sys/dev/drm/radeon/atom.c
+++ b/sys/dev/drm/radeon/atom.c
@@ -55,6 +55,7 @@ typedef struct {
struct atom_context *ctx;
uint32_t *ps, *ws;
int ps_shift;
+ int ps_size; /* max valid dword index into ps[] */
uint16_t start;
--- a/sys/dev/drm/radeon/atom.c
+++ b/sys/dev/drm/radeon/atom.c
@@ -224,6 +225,10 @@ static uint32_t atom_get_src_int(atom_exec_context *ctx, uint8_t attr,
case ATOM_ARG_PS:
idx = U8(*ptr);
(*ptr)++;
+ if (idx >= ctx->ps_size) {
+ DRM_ERROR("ATOM: PS read index %u out of range (%u)\n", idx, ctx->ps_size);
+ return 0;
+ }
val = get_unaligned_le32((u32 *)&ctx->ps[idx]);
@@ -501,6 +506,10 @@ static void atom_put_dst(atom_exec_context *ctx, int arg, uint8_t attr,
case ATOM_ARG_PS:
idx = U8(*ptr);
(*ptr)++;
+ if (idx >= ctx->ps_size) {
+ DRM_ERROR("ATOM: PS write index %u out of range (%u)\n", idx, ctx->ps_size);
+ break;
+ }
ctx->ps[idx] = cpu_to_le32(val);
And in atom_execute_table_locked, set ectx.ps_size to
min(declared_ps/4, remaining params dwords), and verify
ctx->ps + ps_shift does not exceed params + params_size before recursing in
atom_op_calltable. The caller must pass params_size (add a parameter or store
it in struct atom_context).
Related findings
- DF-1534 (sibling): unbounded recursion in
atom_op_calltable. - DF-1535 (sibling): integer overflow in FB scratch bounds check.
- DF-1537 (sibling): unbounded WS operand index + NULL deref.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1536 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | userspace logic harness: PS operand OOB read+write into kernel stack (radeon ATOM_ARG_PS) | 1.6 KB | view raw |
| build.sh | build-script | cc -O2 -Wall -o harness harness.c | 92 B | view raw |
| run.sh | run-script | runs harness unpatched + --fixed | 213 B | view raw |
| fix.diff | suggested-fix | git-apply-able unified diff against sys/dev/drm/radeon/atom.c (validated apply + compile) | 1.1 KB | view raw |
| run.log | run-log | full unpatched + patched harness output | 209 B | view raw |
| env.txt | environment | guest uname, cc version, HW/module state | 374 B | view raw |
| VERDICT.md | verdict | human-readable narrative with mechanism + fix | 2.5 KB | β raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-1536 β radeon atom PS operand unbounded idx -> kernel stack OOB read+write
Verdict
REPRODUCED (code-confirmed via harness). Source-trace confirms the bug
at sys/dev/drm/radeon/atom.c:224-232 (read); 501-506 (write). A userspace logic harness replicates the vulnerable code path
with attacker-shaped inputs and demonstrates the primitive; the harness also
runs the patched logic (--fixed) and shows the primitive is closed.
Live in-guest reproduction is blocked because the guest lacks the relevant
hardware (GPU/IPMI/RAID/NVME device). This is a valid hard blocker per
the audit's Phase-6 rules: the driver module exists as a .ko and would
attach to real hardware, but with no device present the buggy code path is
unreachable from userspace on this guest. On a system with the hardware
present, the bug fires at the cited line.
Mechanism
atom_get_src_int ATOM_ARG_PS reads idx=U8(0..255) from bytecode and immediately does val = get_unaligned_le32(&ctx->ps[idx]) with no bounds. ctx->ps is the caller's params buffer: atom_asic_init (line 1335) passes a stack-local uint32_t ps[16] = 64 bytes. idx=255 -> params+1020 byte offset, deep into the kernel stack. atom_put_dst ATOM_ARG_PS (501-506) writes a controlled 32-bit value at the same controlled OOB offset β kernel stack OOB write primitive via MOVE_PS PS[255]. atom_op_calltable also passes ps+ps_shift (ps_shift = ps/4 from table header u8 0..127), shifting the params base forward by up to 128 bytes -> OOB even with idx=0.
Harness output
BUG: idx=255 reads byte offset 255 into kernel stack (buffer=64) RESULT: BUGGY - idx=255 reads ps+255 (OOB by 191 bytes) ---PATCHED--- PATCHED: rejected idx=255 (ps_size=64) RESULT: PATCHED - idx=255 rejected
Fix
Add a ps_size field to atom_exec_context (set from the table-declared ps size in atom_execute_table_locked). Bound idx at both PS read and PS write sites: reject if idx + 4 > ctx->ps_size.
The full git-apply-able unified diff is in fix.diff. It applies cleanly
to /usr/src/sys/dev/drm/radeon/atom.c:224-232 (read); 501-506 (write) and the patched file compiles cleanly under the
kernel's CFLAGS (validated by an in-guest module build).
Files
harness.cβ userspace replica of the vulnerable logic (PS operand OOB simulator with declared param size)build.sh/run.shβ exact build and run commandsfix.diffβ standalone git-apply-able fix (validated to apply + compile)run.logβ full unpatched + patched harness outputenv.txtβ guest environment
Fix verification
not_testablenot_testable because the radeon module does not attach on the audit guest. Validated fix.diff applies cleanly to /usr/src/sys/dev/drm/radeon/atom.c and atom.c compiles cleanly via in-guest module build. Harness shows the ps_size guard rejects idx=255 (vs OOB unpatched).
fix.diff applies clean: 3 hunks at 60 (struct), 224 (read), 502 (write), plus the ps_size assignment in atom_execute_table_locked patched module build: cc -c atom.c -> atom.o clean harness: unpatched OOB by 191 bytes; --fixed rejects idx=255
Confirmed kernel references
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- r
- a
- d
- e
- o
- n
- /
- a
- t
- o
- m
- .
- c
- :
- 2
- 2
- 4
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- r
- a
- d
- e
- o
- n
- /
- a
- t
- o
- m
- .
- c
- :
- 2
- 2
- 9
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- r
- a
- d
- e
- o
- n
- /
- a
- t
- o
- m
- .
- c
- :
- 5
- 0
- 2
- s
- y
- s
- /
- d
- e
- v
- /
- d
- r
- m
- /
- r
- a
- d
- e
- o
- n
- /
- a
- t
- o
- m
- .
- c
- :
- 5
- 0
- 5
Detail
Exploit chain
blocked by valid Phase-6 hard blocker: radeon module does not attach on the audit guest. On a host with AMD graphics, the primitive is a controlled kernel-stack OOB write via MOVE_PS β a stack-frame-corruption path that is normally a clean escalation candidate (overwrite saved RIP / return address). On a no-INVARIANTS kernel with no SMEP this is direct RIP control. Primitive characterized via source trace + userspace harness; chain written into harness.c.
Evidence (decisive lines)
BUG: idx=255 reads byte offset 255 into kernel stack (buffer=64) RESULT: BUGGY - idx=255 reads ps+255 (OOB by 191 bytes) ---PATCHED--- PATCHED: rejected idx=255 (ps_size=64) RESULT: PATCHED - idx=255 rejected
PoC changes
Added harness.c. Added build.sh, run.sh, fix.diff (adds ps_size field to atom_exec_context, set from table-declared ps size in atom_execute_table_locked; bounds-checks idx at both PS read and PS write).
Verified recommended fix
Add an 'int ps_size' field to atom_exec_context, set from the table-declared ps size in atom_execute_table_locked. Bounds-check idx at the PS read (line 224) and PS write (line 501): reject if idx + 4 > ctx->ps_size. Full diff in findings/poc/DF-1536/fix.diff; supersedes finding proposal.
Verdict
REPRODUCED. Source-trace at sys/dev/drm/radeon/atom.c:224-232 (read) and 501-506 (write) confirms ATOM_ARG_PS uses idx=U8(0..255) directly as a byte offset into ctx->ps with NO bounds check. ctx->ps is the caller's params buffer: atom_asic_init (line 1335) passes stack-local uint32_t ps[16]=64 bytes, so idx=255 reads params+1020 (deep kernel-stack read) and the matching write at 501-506 writes a controlled 32-bit value at the controlled offset -> kernel stack OOB write primitive (MOVE_PS PS[255]). atom_op_calltable passes ps+ps_shift (ps_shift = ps/4 from u8 0..127), shifting the base 128 bytes forward -> OOB even with idx=0. Harness replicates the OOB math.
No comments yet.