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

DC bounds all VBIOS walks by the image's self-declared size byte (bios[2]*512) instead of the allocated buffer length

Summary

bios_parser_construct computes bios_size = bios[2] * 512 - a byte inside the attacker-supplied VBIOS image (ceiling 130,560) - while adev->bios is allocated with the real image length (check_atom_bios never cross-checks). bios_get_image enforces only the claimed size, so every table/record walk in this file can read past the end of the heap allocation whenever the claimed size exceeds the allocation (small-ROM devices, or virtualization: QEMU sizes the ROM BAR from the romfile while the image-size byte is free-form attacker data). Up to ~95KB of OOB heap reads through GET_IMAGE. bp_init_data carries no buffer size - the allocation length is never plumbed into DC. Plumb-and-clamp fix (5 files).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4168 Β· 20 files
FileTypeDescriptionSize
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 9.3 KB ↓ raw
rig/gen_rig.sh β€” 6.5 KB view raw
rig/check_verbatim.sh β€” 5.2 KB view raw
rig/size_slice_verbatim.h β€” 16.7 KB view raw
rig/size_slice_verbatim.c β€” 10.9 KB view raw
rig/rig_shims.h β€” 4.8 KB view raw
rig/size_rig.c β€” 15.7 KB view raw
rig/Makefile β€” 427 B ↓ download
rig/build.sh β€” 425 B view raw
rig/run.sh β€” 737 B view raw
build.log β€” 3.2 KB view raw
run.log β€” 5.8 KB view raw
run.2.log β€” 5.8 KB view raw
fix_validation.log β€” 6.0 KB view raw
code_hashes.txt β€” 347 B view raw
env.txt β€” 640 B view raw
fix.diff β€” 1.8 KB view raw
verdict.json β€” 8.5 KB view raw
manifest.json β€” 1.7 KB view raw

DF-4168 β€” DC bounds all VBIOS walks by the image's self-declared size byte (bios[2]*512), not the allocated buffer length

  • File: sys/dev/drm/amd/display/dc/bios/bios_parser2.c:1930 (bios_parser_construct: bios_size = bios[2] * 512)
  • Bound consumer: sys/dev/drm/amd/display/dc/bios/bios_parser_helper.c:36-44 (bios_get_image: the only bounds check, offset + size < bp->bios_size)
  • Allocation side (never plumbed into DC): sys/dev/drm/amd/amdgpu/amdgpu_bios.c:123-150 (amdgpu_read_bios: kzalloc(size) with the pci_map_rom size; check_atom_bios (:50-81) validates magic + ATOM pointer only, never cross-checks bios[2]*512), igp_read_bios_from_vram (256KB), amdgpu_read_bios_from_rom (AMD_VBIOS_LENGTH), ATRM (256KB)
  • Class: heap out-of-bounds READ, CWE-125. Hardware-gated (AMD ASIC + crafted VBIOS image whose size byte exceeds the true allocation β€” small ROM BARs, vfio/QEMU romfile sizing, or a malicious device).

Verified via the verbatim-code rig (rig/)

The production construction + table-walk code was sed-extracted byte-identical from sys/ (gen_rig.sh), re-verified on the DragonFly guest against /usr/src/sys (check_verbatim.sh: 30 blocks identical to production + the fix-variant equivalence proofs), compiled on the guest, and driven with a crafted image: a fully valid ATOM ROM (header v2.2 @0x40, master data table v2_1 @0x20, displayobjectinfo v1_4 @0x60 β€” everything bios_parser_construct needs, all inside the 4096-byte "allocation") whose bios[2] = 0xFF claims 130,560 bytes β€” 31x the allocation.

Build / run (on the DragonFly guest)

cd rig && sh ./build.sh /usr/src
cd rig && sh ./run.sh

Modes

layout, size-mismatch (the claim itself), oob-guard (allocation ends at a PROT_NONE page; firmwareinfo/dce_info table offsets point past the allocation but inside the claimed size), oob-canary (readable "adjacent heap" region; tables crafted there with dce_refclk_10khz = 0x1234), guard-construct (the ROM-header pointer at image[0x48] points past the allocation β€” construct's OWN walk).

Expected output (see run.log / run.2.log)

Baseline (production code): - size-mismatch: construct SUCCEEDS; bp->base.bios_size = 130560 vs true allocation 4096 β€” every GET_IMAGE walk may read up to offset 130560 (ceiling β‰ˆ 126 KB past this allocation) - oob-guard: SIGSEGV reading at mapping offset 4354 (= the firmwareinfo header byte at 0x1100+2), 258 bytes past the 4096-byte allocation, while the claimed bound (130560) happily admits the offset β†’ OOB READ past the heap allocation - oob-canary: BP_RESULT_OK with crystal_frequency = 46600 β€” exactly 0x1234 Γ— 10 read from offset 0x1300, 768 bytes past the allocation: adjacent-region bytes parsed into DC state (earlier run with plain 0x41 filler parsed 0x4141 β†’ 167050 β€” whatever sits past the allocation becomes the reference clock) - guard-construct: SIGSEGV reading at offset 9474 (0x2500+2) β€” construct's own GET_IMAGE(atom_rom_header_v2_2, *0x48) walk (bios_parser2.c:1942) past the allocation

Fixed (-DAPPLY_DF4168_FIX = the bios_parser2.c + bios_parser_interface.h hunks of fix.diff, checker-proven; the harness plumbs init.bios_size = 4096, the role of fix.diff's amdgpu_dm.c/dc.c plumbing): - size-mismatch: bios_size clamped to 4096 - oob-guard / oob-canary: construct succeeds, the firmware-info walk returns BP_RESULT_BADBIOSTABLE, sentinel info struct untouched, no fault, no adjacent-region read on the identical image - guard-construct: construct rejects cleanly (GET_IMAGE β†’ NULL), no fault

Verdict

Reproduced (verbatim-rig evidence class): the exact production code bounds every VBIOS walk by the attacker-supplied size byte and reads past the true heap allocation β€” guard-page fault proof plus adjacent-region canary proof. fix.diff (plumb the allocated length and clamp) eliminates it β€” A/B validated in the same rig. See VERDICT.md, verdict.json.

VERDICT.md
↓ download raw

DF-4168 β€” VERDICT (verify run, 2026-10-15)

Status: REPRODUCED (verbatim-rig evidence class). The production construction + table-walk code, byte-identical to the running kernel's source, walks past the true heap allocation whenever the image's self- declared size byte exceeds it: guard-page SIGSEGV reads at offsets the claimed bound admits, and adjacent-region bytes are parsed into DC state. In-kernel execution is AMD-ASIC-gated on this guest (no AMD GPU β€” proven, see env.txt). Fix A/B validated in the same rig: fixed.

1. What was verified and how

Same evidence class as DF-4165/DF-4167 (siblings on this exact file): the guest cannot run amdgpu/dc (Bochs SVGA 0x1234:1111 only, sysctl dev.drm unknown, no drm/amdgpu KLDs β€” env.txt), so the actual production code was compiled into a guest-side harness:

  • rig/gen_rig.sh sed-extracts, byte-identical from the read-only sys/ tree: bios_get_image (bios_parser_helper.c:36-44 β€” the only bounds check on the path), get_atom_data_table_revision (bios_parser2.c:133-151), bios_parser_get_firmware_info (:1119-1155) β†’ get_firmware_info_v3_1 (:1157-1202) / get_firmware_info_v3_2 (:1204-1280) as the demonstration walk, bios_parser_construct (:1912-1992, the bios_size = bios[2] * 512 line at :1930) and firmware_parser_create (:1994-2009) β€” plus every struct they touch (atomfirmware.h tables verbatim under the production #pragma pack(1) at :207/:2934, rom header v2_2, master data table v2_1, displayobjectinfo v1_4, bp_init_data, dc_firmware_info).
  • rig/check_verbatim.sh re-proved all 30 blocks byte-identical, run both against the host sys/ tree and on the guest against /usr/src/sys, the tree its running kernel (Jul 2 2026 build) was compiled from: ALL VERBATIM CHECKS PASSED (build.log). The checker additionally applies the bios_parser2.c hunk of fix.diff to the production construct and proves the APPLY_DF4168_FIX variant equals the patch result, and that the bp_init_data.bios_size member insert equals the bios_parser_interface.h hunk's additions. (The dc_types.h / amdgpu_dm.c / dc.c hunks are caller-side plumbing; the harness plays their role by filling init.bios_size with the true allocation length.)
  • Harness shims touch nothing on the bounds path: minimized dc_bios/bios_parser (first-member layout, container_of identity), kzallocβ†’placement, vbios_funcs macro-mapped to a shim table whose single member dispatches to the verbatim bios_parser_get_firmware_info (so construct's bp->base.funcs = &vbios_funcs is a real dispatch); dal_firmware_parser_init_cmd_tbl / dal_bios_parser_init_cmd_tbl_helper2 (command-table pointer init β€” never touch bios_size or image bytes) and bios_parser_create_integrated_info (the integratedsysteminfo walk verified separately as DF-4165; returns NULL, which construct tolerates) are no-op shims, documented.

The crafted image is a fully valid minimal ATOM ROM β€” header v2_2 @0x40 (revision 2.2), master data table v2_1 @0x20 (firmwareinfo β†’ 0x100 or 0x1100, dce_info β†’ 0x200 or 0x1300, displayobjectinfo β†’ 0x60, revision 1.4) β€” everything bios_parser_construct requires, all inside the 4096-byte allocation, with bios[2] = 0xFF.

2. Observed (run.log, stable across run.2.log)

  • size-mismatch (the core claim): firmware_parser_create β†’ bios_parser_construct SUCCEEDS; bp->base.bios_size = 130560 (from the image byte) vs true allocation 4096 β€” a 31x over-trust. Every subsequent GET_IMAGE / table walk is bounded by the claimed value: any offset < 130560 βˆ’ sizeof(table) is admitted. Ceiling with bios[2] = 0xFF: reads up to 126 KB past this allocation (the finding's ~95 KB figure corresponds to a 32 KB true allocation; same mechanism).
  • oob-guard: allocation [0,4096) ends at a PROT_NONE page; firmwareinfo @0x1100, dce_info @0x1300 (both past the allocation, inside the claimed 130560). Construct succeeds; the firmware-info walk (bios_parser_get_firmware_info β†’ GET_IMAGE(header, 0x1100) passes 0x1100+4 < 130560 β†’ get_atom_data_table_revision dereferences) β†’ SIGSEGV READING at mapping offset 4354 = 0x1102 β€” 258 bytes past the 4096-byte allocation: OOB heap read in the production walk (rc=88 from the handler). In-kernel this is a read past the adev->bios kzalloc.
  • oob-canary: the region past the allocation made readable and pre-filled 0x41, with the tables crafted there (dce_refclk_10khz = 0x1234 @0x1300): the walk returns BP_RESULT_OK with crystal_frequency = 46600 = 0x1234 Γ— 10 read from 768 bytes past the allocation β€” adjacent-heap bytes parsed into DC's reference-clock state. (An earlier run with plain 0x41 filler parsed 0x4141 β†’ 167050: whatever bytes follow the allocation become firmware "data".)
  • guard-construct: image[0x48] (the ROM-header pointer) = 0x2500: construct's own GET_IMAGE(struct atom_rom_header_v2_2, *0x48) passes (0x2500+40 < 130560) and `get_atom_data_table_revision(&rom_header-

    table_header,…)(:1947) dereferences β†’ **SIGSEGV` READING at offset 9474 = 0x2502**, past the allocation β€” construct itself walks OOB (bios_parser2.c:1942), not only the consumers.

Allocation-side facts confirmed by source (no rig needed): amdgpu_read_bios allocates kzalloc(size) with the pci_map_rom size (amdgpu_bios.c:130-141); check_atom_bios (:50-81) validates the 0x55AA magic and the ATOM signature only β€” it never compares bios[2]*512 against size; struct bp_init_data (bios_parser_interface.h:33-36) carries only the pointer, so the allocation length is never plumbed into DC β€” exactly the gap fix.diff closes.

3. Primitive and impact

Primitive: attacker-bounded OOB kernel heap reads β€” with a crafted image claiming 0xFF units, every table/record walk in bios_parser2 (and any DC consumer of bp->bios_size) may read up to 130,560 bytes past the base of a heap allocation that can be as small as the true ROM image (small ROM BARs; vfio/QEMU romfile where the BAR is sized independently of the image-size byte). Demonstrated: guard-page fault (crash-class when the past-end memory is unmapped) and silent adjacent-heap disclosure into driver-parsed state (reference clock, engine/memory clocks, table revisions). Gating: AMD ASIC presenting the crafted/oversized-claim ROM β€” the finding's threat model (flashed ROM, vfio-pci romfile=, malicious device); no unprivileged userland trigger.

Impact recorded as leak (CWE-125 OOB read; adjacent kernel heap contents are consumed as parser data). The crash flavor is also demonstrated (oob-guard, guard-construct) but reads are the primary primitive. No write primitive arises from this finding; DF-4165 covers the write side of this parser.

4. Fix validation (A/B, identical crafted image)

rig_fixed = same verbatim slice + exactly the bios_parser2.c clamp and the bp_init_data member of fix.diff (checker-proven), with the harness providing init.bios_size = 4096 (the role of fix.diff's amdgpu_dm.c / dc.c plumbing):

  • size-mismatch: bios_size clamped to 4096;
  • oob-guard: construct succeeds (in-allocation tables pass the clamped bound), the firmware-info walk returns BP_RESULT_BADBIOSTABLE (GET_IMAGE NULL β†’ revision 0 β†’ no dispatch), sentinel info struct untouched, no fault;
  • oob-canary: BP_RESULT_BADBIOSTABLE, "info untouched (sentinel intact: yes), no table bytes parsed" β€” no adjacent-region read;
  • guard-construct: GET_IMAGE rejects 0x2500 β†’ construct returns false cleanly, no fault.

Baseline reads past the allocation, patched slice rejects out-of-allocation tables on the identical image β†’ fix validated (fix_validation.log). Kernel-rebuild validation not performed (fix site executes in-kernel only on AMD ASICs absent from this guest; per the verbatim-rig protocol the fixed slice was A/B-validated in the same rig). fix.diff remains authored against the pristine read-only sys/ tree and was never applied there.

5. Honest limitations

  • Demonstrated with production instructions and production struct layouts, but in a userland mapping standing in for the kernel heap; the allocation/claimed mismatch itself (small ROM BAR, vfio romfile sizing) is established by source reading (amdgpu_bios.c) plus the QEMU sizing argument, not by presenting a real undersized BAR to this guest.
  • The walk demonstrated is firmwareinfo/dce_info and construct's rom-header read; the same claimed-size bound governs every other table walk in the file (object tables, encoder caps, board layout β€” and DF-4165's integratedsysteminfo), but each was not separately re-driven.
  • The exfiltration path of the canary variant lands in DC-internal state (clocks parsed from adjacent heap); no direct copy-to-userspace of the OOB bytes was demonstrated β€” hence impact leak at the parsed-state-corruption/potential-information-influence level rather than a demonstrated userland info leak.

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

fixed
baseline reproduced→ patch + rebuild →patched clean

A/B validated in the verbatim rig: identical crafted image, same slice, only the bios_parser2.c clamp + bp_init_data.bios_size member of fix.diff under -DAPPLY_DF4168_FIX (checker-proven equal to the patch applied to production; the harness fills init.bios_size, the role of the dc.c/amdgpu_dm.c plumbing hunks). Baseline: construct SUCCEEDS claiming 130560 against a 4096 allocation; SIGSEGV reads past the allocation in the firmware-info walk and in construct's own rom-header walk; adjacent-region bytes parsed into DC state. Fixed: bios_size clamped to 4096; out-of-allocation tables rejected (BP_RESULT_BADBIOSTABLE, sentinel untouched, no adjacent reads); construct's rom-header walk rejected cleanly; zero faults. Kernel-rebuild validation not performed: the fix site executes in-kernel only on AMD ASICs absent from this guest; per the verbatim-rig protocol the fixed slice was validated in the same rig. fix.diff authored against the pristine read-only sys/ tree, never applied there.

fix_validation.log (full baseline vs fixed output); run.log FIXED sections; build.log (APPLY_DF4168_FIX construct == fix.diff applied to bios_parser2.c:1912-1992; bp_init_data member == bios_parser_interface.h additions); fix.diff
↓ fix.diffper-fix-DF-4168

Confirmed kernel references

Detail

Exploit chain

Hardware/firmware attacker model only: craft VBIOS image whose bios[2] size byte exceeds the driver's true allocation (small ROM BAR device, vfio-pci romfile=, malicious PCI device) while carrying valid-format tables -> amdgpu attach -> adev->bios = kzalloc(true_size) -> DC bios_parser_construct trusts bios[2]*512 as bios_size -> every bios_get_image walk (construct's own rom-header read at :1942 and all table walks) may read past the kzalloc'd buffer, up to ~126KB with bios[2]=0xFF -> OOB kernel heap reads: crash when the past-end pages are unmapped (demonstrated guard-page SIGSEGVs) and silent consumption of adjacent slab contents as parsed DC state (demonstrated canary: adjacent bytes became the display reference clock). No write primitive; no unprivileged userland trigger (values originate in the GPU ROM).

Evidence (decisive lines)

run.log / run.2.log (all five modes baseline vs fixed, stable across two runs), fix_validation.log (full A/B), build.log (30x 'identical to production' incl. guest /usr/src/sys + fix-variant==patch-application proofs + zero-warning builds), VERDICT.md (full narrative incl. allocation-side source citations and limitations), rig/ (sources), env.txt (hardware gating), code_hashes.txt

PoC changes

Seed pack had no runnable PoC (status untested, hardware-gated). Built the verbatim rig from scratch: gen_rig.sh extracts construct + create + the walk + bios_get_image and every struct byte-identical; check_verbatim.sh proves block identity (host tree AND guest /usr/src/sys) and reconstructs the fixed construct by applying the bios_parser2.c hunk of fix.diff to the production text (proving the APPLY_DF4168_FIX variant equals the patch), plus the bp_init_data member == interface.h additions; the harness plays the role of fix.diff's amdgpu_dm.c/dc.c plumbing by filling init.bios_size with the true allocation. Harness bugs fixed during the run: masterdatatable_offset must be the ABSOLUTE image offset (a relative value wrapped u16 and faulted inside construct before the intended scenario), field offsets now computed via offsetof on the verbatim structs, fflush before child _exit so fixed-build child output is captured.

Verified recommended fix

Plumb the true allocated VBIOS length into DC (adev->bios_size via amdgpu_dm/dc into bp_init_data.bios_size) and clamp bios_size = min(bios[2]*512, bios_size) in bios_parser_construct - fix.diff (5 files), A/B validated in the rig.

Verdict

REPRODUCED via the verbatim-code rig: bios_parser_construct (bios_parser2.c:1912-1992, the bios_size = bios[2]512 computation at :1930), firmware_parser_create (:1994-2009), bios_get_image (bios_parser_helper.c:36-44, the only bounds check: offset+size < bp->bios_size), and the firmware-info walk (bios_parser_get_firmware_info :1119-1155 -> get_firmware_info_v3_2 :1204-1280) were sed-extracted byte-identical from sys/ (30 blocks re-verified identical ON THE GUEST against /usr/src/sys, the tree its running kernel was built from; the checker also proves the APPLY_DF4168_FIX construct equals the bios_parser2.c hunk of fix.diff applied to production, and the bp_init_data member insert equals the bios_parser_interface.h hunk). Crafted image: a fully valid ATOM ROM (header v2.2 @0x40, master data table v2_1 @0x20, displayobjectinfo v1_4 @0x60 -- everything construct needs, all INSIDE the 4096-byte allocation) with bios[2]=0xFF claiming 130,560 bytes. Baseline production code: (a) size-mismatch -- construct SUCCEEDS with bp->base.bios_size=130560 against a 4096-byte allocation (31x over-trust; read ceiling ~126KB past this allocation, the finding's ~95KB figure = 32KB-alloc case); (b) oob-guard -- firmwareinfo @0x1100 / dce_info @0x1300 (past the allocation, admitted by the claimed bound) -> SIGSEGV READING at mapping offset 4354 = 0x1102, 258 bytes PAST the allocation, in the production walk (rc=88); (c) oob-canary -- adjacent region readable, tables crafted there -> BP_RESULT_OK with crystal_frequency = 46600 = 0x1234 x 10 read from offset 0x1300, 768 bytes past the allocation: adjacent-heap bytes parsed into DC state (plain 0x41 filler parsed as 0x4141 -> 167050 in an earlier run); (d) guard-construct -- image[0x48]=0x2500 -> construct's OWN GET_IMAGE(atom_rom_header_v2_2, ...) walk (bios_parser2.c:1942) SIGSEGVs reading at offset 9474 past the allocation. Allocation-side facts confirmed in source: amdgpu_read_bios kzallocs the pci_map_rom size (amdgpu_bios.c:130-141), check_atom_bios (:50-81) never cross-checks bios[2]512 against it, and struct bp_init_data (bios_parser_interface.h:33-36) carries only the pointer -- the allocation length is never plumbed into DC, exactly the gap fix.diff closes (5 files: dc_types.h, amdgpu_dm.c, bios_parser_interface.h, dc.c, bios_parser2.c clamp). Gating: AMD ASIC + crafted/oversized-claim ROM (small ROM BAR, vfio/QEMU romfile sizing, malicious device); no unprivileged userland trigger; guest has no AMD GPU (Bochs 0x1234:1111, no dev.drm, no amdgpu - env.txt), so the guest kernel never executes the path. fix.diff A/B-validated in the same rig on the identical image: bios_size clamped to 4096; out-of-allocation tables rejected (BADBIOSTABLE, sentinel info struct untouched, no adjacent-region reads); construct's own rom-header walk rejected cleanly; zero faults.