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

parse_csr_fw bounds its stepping-search loop with attacker-controlled package_header->num_entries while the walked array is fixed fw_info[20]: unbounded kernel heap OOB read (panic / OOB dmc_offset)

Summary

The stepping search iterates num_entries (u32, never checked) against the fixed 20-entry fw_info array - a DMC file with a big count walks kernel heap past the struct until an unmapped page panics; short of that the OOB u32 becomes dmc_offset steering further reads (to CSR MMIO, not userspace - DoS ceiling). DISTINCT from DF-1895 (missing fw->size checks): a fully size-checked parse still faults (harness variant B: 1MiB file faults at fw_info[87369]). Harness run ON guest (faults at fw_info[115]/[87369]); kernel path untestable (std-VGA only). Upstream v5.2 carries it; v5.10 sanitizes - DF never got that. num_entries<=20 reject fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4814 Β· 11 files
FileTypeDescriptionSize
poc_csr_num_entries.c β€” 5.8 KB view raw
build.sh β€” 120 B view raw
run.sh β€” 106 B view raw
build.log β€” 255 B view raw
run.log β€” 1.3 KB view raw
env.txt β€” 1.5 KB view raw
VERDICT.md β€” 6.3 KB ↓ raw
README.md β€” 1.2 KB ↓ raw
fix.diff β€” 509 B view raw
evidence/upstream_v5.2_intel_csr.c β€” 16.3 KB view raw
evidence/upstream_v5.10_intel_csr.c β€” 21.0 KB view raw

DF-4814 β€” parse_csr_fw walks package_header->fw_info[20] with unbounded attacker num_entries: kernel heap OOB read (-> panic / OOB dmc_offset).

BUILD/RUN (guest-compiled logic harness; kernel path itself needs gen9+ HW):

# on the audit guest (or any POSIX box):
cc -O2 -o poc4814 poc4814.c
./poc4814          # variant A: minimal 1536-byte crafted DMC file
./poc4814 big      # variant B: 1 MiB crafted DMC file

EXPECTED (observed, see run.log): SIGSEGV while reading fw_info[115] (A) / fw_info[87369] (B) β€” i.e. far past the 20-entry fw_info array, and past the END of the firmware buffer, with the loop still bounded only by the crafted num_entries=0x08000000 (1.5 GiB of address space walked). The harness traps the fault and prints the diagnosis; in-kernel the same walk is a page fault in kernel mode (panic) or an OOB-read dmc_offset feeding &fw->data[readcount+dmc_offset*4].

STATUS: status=untested for the kernel path (no gen9+ Intel display device exists in the audit guest β€” see env.txt); the parse-loop logic itself is demonstrated by the harness (run.log). Fix validated statically (see VERDICT.md / fix.diff, mirrors upstream v5.10 num_entries sanitization).

VERDICT.md
↓ download raw

DF-4814 VERDICT β€” intel_csr.c unbounded num_entries walk (OOB heap read)

Bottom line: parse_csr_fw() bounds its stepping-search loop with the attacker-controlled package_header->num_entries (u32 from the DMC firmware file) while the walked array is the fixed struct intel_fw_info fw_info[20] declared inside struct intel_package_header. There is no check of num_entries against ARRAY_SIZE(fw_info) β€” for num_entries > 20 the loop reads kernel heap beyond the array; for large counts it walks off the end of the request_firmware() buffer entirely. Kernel-mode OOB read β†’ page fault panic (reliable DoS), and whatever u32 it reads OOB becomes dmc_offset, which is then used to index fw->data again (compounding DF-1895's unchecked indexing). Root cause is distinct from DF-1895: even a fully fw->size-checked parse (the DF-1895 fix) leaves this bug β€” the missing bound is the array size (20), not the file size; variant B of the harness proves a 1 MiB file (far larger than needed) still gets walked off its end.

The defect (path:line, this tree)

  • sys/dev/drm/i915/intel_csr.c:134-136 β€” num_entries u32 + fw_info[20] inside struct intel_package_header (__packed, total 256 bytes).
  • sys/dev/drm/i915/intel_csr.c:353-365 β€” the loop: for (i = 0; i < package_header->num_entries; i++) reading package_header->fw_info[i].substepping/.stepping/.offset with no ARRAY_SIZE(package_header->fw_info) bound. (Note the '*'/'*' wildcard arm at :362-364 deliberately does NOT break, so the walk continues.)
  • sys/dev/drm/i915/intel_csr.c:372-376 β€” OOB-read offset becomes dmc_offset *= 4; readcount += dmc_offset; dmc_header = &fw->data[readcount].

Trust model / threat

The DMC firmware file is root-placed (loader path /modules/firmware/... via firmware_get(), or i915_modparams.dmc_firmware_path module param). The attacker is therefore "root writes a malicious firmware file" / "compromised firmware distribution" β€” the same trust model under which DF-1894 (High) and DF-1895 (Medium) were filed for this file. The load itself is asynchronous (schedule_work at intel_csr.c:507) from driver load on gen9+ hardware; any user who can (re)boot/reload the driver triggers the parse. Severity kept at Medium to stay consistent with DF-1895's calibration.

Verification performed (honest classification)

  • Guest kernel runtime: NOT possible. The audit guest's only display device is QEMU std-VGA 0x1234:1111 (see env.txt). parse_csr_fw is reachable only when HAS_CSR() (gen9+ Intel display) β€” no such hardware exists in the guest, so the kernel path cannot execute there. Status: untested per the allowed enum (device absent, not a failed attempt).
  • Logic replication: REPRODUCED on the audit guest itself. poc_csr_num_entries.c copies the packed struct definitions (intel_csr.c:111-137) and the exact loop (intel_csr.c:353-365) verbatim, places the crafted firmware flush against a PROT_NONE guard page (modeling the end of the kernel heap allocation), and runs it:
    • variant A (1536-byte file, minimal structurally-valid headers): fault reading fw_info[115] β€” the array holds 20 entries; the walk left the file 3 bytes earlier and kept going (run.log).
    • variant B (1 MiB file): fault reading fw_info[87369] β€” proves that no plausible file size bounds the walk; the loop bound alone is num_entries, here 0x08000000 (1.5 GiB of walk). Compiled and executed on the DragonFly guest (cc -O2, DF 6.5-DEVELOPMENT). In the kernel the identical walk reads past the firmware_get() buffer until an unmapped page β†’ kernel-mode page fault β†’ panic; short of that it harvests heap bytes into dmc_offset.
  • Upstream provenance (verified against tags, saved under evidence/): upstream v5.2 has the same unbounded loop; upstream v5.10 added the sanitization β€” parse_csr_fw_package(): num_entries = package_header->num_entries; if (WARN_ON(package_header->num_entries > max_entries)) return 0; and the walker comment "num_entries is already sanitized". So this is an upstream-inherited bug that upstream later fixed; the DF tree never received that fix.

Why this is NOT a re-report of DF-1895

DF-1895 = "never validates fw->datasize before indexing fw->data" β€” the fix is bounds-checking every &fw->data[...] access against fw->size. That fix does not touch the fw_info[i] loop bound: a 1 MiB file with num_entries=0x20000 passes every conceivable fw->size check at the package-header stage yet still reads fw_info[i] for iβ‰₯21 (intra-file OOB w.r.t. the struct array) and, for larger counts, past the file end (harness variant B). Complementary, independent missing check.

Exploit chain (ceiling)

Root-supplied DMC file β†’ async work csr_load_work_fn β†’ parse_csr_fw β†’ unbounded heap walk β†’ (a) kernel panic (deterministic with unmapped-page landing) = local DoS at driver load; (b) controlled-ish OOB read: heap bytes at fw_info[21..] feed dmc_offset (and via '*'-wildcard entries, the last matching OOB entry wins), steering further reads at &fw->data[384+dmc_offset*4] β€” with DF-1895 also unfixed this gives a heap read primitive whose contents land in dmc_payload and are then written to CSR_PROGRAM(i) MMIO (not readable back by userspace; so leak ceiling is low β€” DoS is the realistic impact). No path to uid=0: reads only, no writes past the dmc_payload allocation.

Fix

Bound the loop (mirrors upstream v5.10 sanitization):

--- a/sys/dev/drm/i915/intel_csr.c
+++ b/sys/dev/drm/i915/intel_csr.c
@@ -348,6 +348,13 @@ static uint32_t *parse_csr_fw(struct drm_i915_private *dev_priv,
        return NULL;
    }

+   if (package_header->num_entries > ARRAY_SIZE(package_header->fw_info)) {
+       DRM_ERROR("DMC firmware has wrong package num_entries %u\n",
+             package_header->num_entries);
+       return NULL;
+   }
+
    readcount += sizeof(struct intel_package_header);

(see fix.diff β€” authored against the read-only sys/ tree, never applied)

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

not_testable
baseline no→ patch + rebuild →patched clean

fix.diff restores the upstream v5.10 num_entries bound and git-apply --check passes against sys/, but no Vega/gen9+ hardware exists in the audit guest, so a patched-kernel run cannot be performed

fix.diff (git apply --check clean); upstream_v5.10_intel_csr.c:525-526 shows the equivalent upstream sanitization
↓ fix.diffper-fix-DF-4814

Confirmed kernel references

Detail

Exploit chain

root-supplied/corrupted DMC fw file -> async csr_load_work_fn at driver load -> parse_csr_fw unbounded fw_info walk -> kernel heap OOB read -> panic (deterministic DoS); OOB-read u32 becomes dmc_offset compounding DF-1895-style unchecked fw->data indexing. Read-only primitive: no uid=0 path (no OOB write).

Evidence (decisive lines)

run.log (harness faults at fw_info[115] / fw_info[87369] past the 20-entry array on the DF guest); VERDICT.md (full trace + upstream provenance); evidence/upstream_v5.2_intel_csr.c:355 vs evidence/upstream_v5.10_intel_csr.c:525-526; env.txt (guest HW absence)

PoC changes

n/a (no orchestrator seed existed for this ID; harness authored during this run; first draft's guard page was not page-aligned for the 1536-byte variant and was fixed)

Verified recommended fix

Reject package headers with num_entries > ARRAY_SIZE(fw_info) before the stepping-search loop (mirrors upstream v5.10 sanitization); see fix.diff

Verdict

parse_csr_fw() bounds its stepping-search loop with the attacker-controlled package_header->num_entries while the walked array is the fixed fw_info[20]; num_entries>20 walks kernel heap beyond the struct, and large counts walk off the request_firmware() allocation entirely (kernel-mode page fault -> panic). Distinct root cause from DF-1895 (missing ARRAY_SIZE bound vs missing fw->size bound): a 1 MiB file still faults at fw_info[87369]. Upstream v5.2 carries the same bug; v5.10 sanitizes num_entries (WARN_ON(num_entries > max_entries)) β€” the DF tree never got that fix. Root-placed firmware trust model, consistent with DF-1894/1895 calibration.