β¬’ DragonFlyBSD Kernel Audit
← triage Β· dashboard
DF-1536

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:

  1. MOVE_PS to PS[0x80] β€” opcode byte for MOVE_PS is 0x02 (atom.c:1042 / atom-names.h:36), attr byte encodes dst=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 byte 0x80, 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.

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).

  • 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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-1536 Β· 9 files
FileTypeDescriptionSize
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
VERDICT.md verdict human-readable narrative with mechanism + fix
↓ download 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 commands
  • fix.diff β€” standalone git-apply-able fix (validated to apply + compile)
  • run.log β€” full unpatched + patched harness output
  • env.txt β€” guest environment

Fix verification

not_testable
baseline reproduced→ patch + rebuild →patched clean

not_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
↓ fix.diffn/a (module-bound bug; guest has no AMD GPU)

Confirmed kernel references

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.