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

pnp_parse_desc reads fixed descriptor offsets without validating declared length causing heap OOB reads up to 17 bytes

Summary

pnp_parse_desc() dispatches on resource tag and unconditionally dereferences fixed offsets inside descriptor body without checking declared length len is large enough to cover those offsets. Only bound checked upstream is len>=l where l is the declared length. A malicious/buggy ISA PnP card can supply descriptor with declared length 0 (or shorter than handler access width) forcing kernel to read 1..17 bytes past descriptor potentially past kmalloc buffer end. Examples: PNP_TAG_MEMORY32_RANGE reads I32(res+13) needs 17 bytes. PNP_TAG_COMPAT_DEVICE does bcopy(res &compat_id 4) needs 4. PNP_TAG_IO_RANGE reads res[6] needs 7. Resource bytes read verbatim from card hardware register. Impact: kernel heap OOB read up to 17 bytes. DoS if crosses page boundary into unmapped page or limited info leak into ISA resource fields. [VERIFIED pass2: reproduced with byte attribution - per-tag read sets at declared len 0 with MEM32_RANGE the 17-byte worst case reading 0x11100f0e, reads cross chunk end and 1024B stream-buffer allocation through the real pnp_parse_resources entry, residue swallowed into stored isa_config and devinfo-visible, bonus same-root-cause 1-byte OOB WRITE at IO_RANGE res 5, page-boundary variant Fatal trap 12 at res+16 panic; boot-time hardware-attacker reach only; 8-hunk per-handler length-guard fix-validated, well-formed streams bit-identical; FreeBSD head carries identical unguarded handlers]

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2316 Β· 18 files
FileTypeDescriptionSize
pnp_harness.c β€” 28.8 KB view raw
pnph.h β€” 2.3 KB view raw
pnphctl.c β€” 1.8 KB view raw
prep.sh β€” 2.2 KB view raw
build.sh β€” 240 B view raw
run.sh β€” 200 B view raw
run2.sh β€” 647 B view raw
fix.diff β€” 2.8 KB view raw
build.log β€” 1.9 KB view raw
fix_build.log β€” 5.6 MB ↓ download
run.log β€” 20.8 KB view raw
fix_run.log β€” 17.1 KB view raw
panic.txt β€” 1.6 KB view raw
leak_sample.txt β€” 2.0 KB view raw
env.txt β€” 2.0 KB view raw
VERDICT.md β€” 9.7 KB ↓ raw
manifest.json β€” 1.9 KB view raw
verdict.json β€” 8.8 KB view raw
VERDICT.md
↓ download raw

DF-2316 VERDICT -- pnp_parse_desc fixed-offset reads without declared-length validation

Status: REPRODUCED (byte-attributed OOB reads up to 17 bytes, heap past-allocation read through the real entry point, heap-residue leak into resource configs, conditional 1-byte OOB write, and a fatal page fault at a page boundary). Fix validated on a rebuilt kernel: bad behavior gone, well-formed streams bit-identical.

Root cause (path:line)

pnp_parse_desc() (sys/bus/isa/pnpparse.c:60-340) switches on the resource tag and dereferences FIXED offsets of the descriptor body res without ever comparing the declared length len against the accessed width:

handler derefs (pnpparse.c) bytes read declared len needed
PNP_TAG_COMPAT_DEVICE bcopy(res,4) :95 4 4
PNP_TAG_IRQ_FORMAT I16(res) :105,:114 2 2
PNP_TAG_DMA_FORMAT res[0] :123,:132 1 1
PNP_TAG_IO_RANGE res[1..6] :141-167 7 7
PNP_TAG_IO_FIXED res[0..2] :178-198 3 3
PNP_TAG_MEMORY_RANGE res[1..8] :242-266 9 9
PNP_TAG_MEMORY32_RANGE res[1..16] :278-299 17 17
PNP_TAG_MEMORY32_FIXED res[1..8] :308-326 9 9

The only length checks are in the callers and compare the REMAINING STREAM against the card-declared l (pnpparse.c:439 if (len < l), :532 if (len < l); pnp_scan_resources :581, :594) -- they guarantee the declared bytes exist, they say nothing about the handler reading beyond them. Real call path: pnp_isolation_protocol (pnp.c:594) -> pnp_read_resources (pnp.c:536, buffer kmalloc'd/realloc'd by pnp_read_bytes pnp.c:503/510 in 1024-byte M_TEMP chunks) -> pnp_create_devices (pnp.c:406) -> pnp_parse_resources (pnp.c:434/:469) -> pnp_parse_desc (pnpparse.c:518/:538). A card sending a final truncated descriptor therefore makes the kernel read whatever follows the descriptor: other stream bytes, then the uninitialized tail of the 1024-byte chunk (kmalloc without M_ZERO = kernel heap residue), and -- when the logical stream ends exactly at the chunk end, fully card-controlled because the card sizes the stream -- bytes PAST the heap allocation, or, at an allocation/page edge, unmapped KVA. FreeBSD head (sys/isa/pnpparse.c, checked 2026-10-18) carries the identical unguarded handlers, so this is inherited upstream behavior, not a DragonFly regression.

Bonus (same root cause, same fix): PNP_TAG_IO_RANGE contains a WRITE, if (res[5] == 0) res[5] = 1; (pnpparse.c:163-166) -- with len < 6 this is a 1-byte OOB write of 0x01 past the descriptor (conditional on the attacker-unknown adjacent bytes: res[6] != 0 and res[5] == 0).

Rig

Extends the PNPH verbatim-interposition kernel harness from the DF-2314/2315 run: this TU #includes the UNMODIFIED pnpparse.c (sha256-gated by prep.sh against the pristine audit tree, 4b13638c...), making the static pnp_parse_desc() callable; the stock pnpparse.c unit is dropped from conf/files; pnp.c stays the STOCK in-build unit. Scenarios driven through /dev/pnph0 (root-only) with real isa0 children as dev.

Evidence (run.log / fix_run.log / panic.txt)

  1. Read set + worst case (scenario 20). Direct calls with marker bytes res[i] = i+1 and declared len 0: every parsed field equals the marker composition of offsets 0..N-1, all past a len-0 descriptor: MEM32_RANGE declen=0 need=17: TRUNCATED -- 17 bytes read past a len-0 descriptor (offsets 0..16) attrib=PASS, f[3]=0x11100f0e = I32(res+13) (markers 0x0e..0x11 at +13..+16). All 8 tags attributed; len=need controls PASS identically (attribution math is sound); a declared-16 MEM32_RANGE case shows the minimal 1-byte over-read.
  2. Past the heap chunk (scenario 21). Descriptor at the end of a 512-byte kmalloc chunk, next adjacent chunk filled with the repeating text "DF2316CANARY!": stored ir_size=0x21595241 == I32(c2+9) ("ARY!"), ir_align=0x4e414336 == I32(c2+5) ("6CAN") -- kernel read canary bytes that live PAST the first chunk, 3/3 runs (leak_sample.txt).
  3. Through the REAL entry point (scenario 23.2). A crafted card stream whose last descriptor (tag+len bytes at c1[1021..1023]) ends exactly at the end of a 1024-byte chunk, next chunk canary-filled, fed to the verbatim pnp_parse_resources(): stored config nmem=1, ir_size=0x33324644 == I32(c2+13) ("DF23") -- 17 canary bytes read fully past the allocation, same path a malicious card drives at boot.
  4. Heap residue -> resource fields (scenario 23.3, the leak). A freed 1024-byte M_TEMP chunk (filled with "DF2316-RESIDUE-HEAPDATA-") is re-allocated as the card stream buffer (same address, same as the real pnp_read_bytes growth pattern); the truncated descriptor at its start makes the parser swallow the prior occupant remains: ir_size=0x44504145 == I32(st+16) (bytes "EAPD" of the residue). These values are stored via ISA_ADD_CONFIG into id_configs -- the data devinfo/bootverbose ("adding memory32 range ...") print for the device. Ceiling: ~17 bytes of adjacent kernel heap per truncated descriptor, attacker-repeatable within one stream (multiple descriptors) and across streams.
  5. 1-byte OOB WRITE (scenario 22). IO_RANGE len 0 at a chunk end with the adjacent canary prepared (res+5 == 0x00, res+6 == 0x7F): canary byte 2 bytes past the chunk end flips 0x00 -> 0x01 (c2[0..7] before: cc cc 00 7f ... after: cc cc 01 7f ...), the pnpparse.c:163-166 res[5]=1 write landing outside its allocation.
  6. Page-boundary fault -> panic (scenario 25, panic.txt). 3 pages kmem_allocd, the top 2 kmem_freed (their vm_map entries REMOVED -- the harness comment documents that a bare pmap_kremove is insufficient because trap_pfault (trap.c:855-878) lets vm_fault(kernel_map) re-materialize a PTE while the entry exists; observed live), descriptor payload in the last 13 bytes of page 1 so res+13..+16 lie on the entry-less page: baseline kernel takes Fatal trap 12: page fault while in kernel mode ... fault virtual address = 0xfffff80118371003 (= page2+3 = res+16, inside the I32(res+13) null-check read), supervisor read data, page not present, backtrace pnp_parse_desc()+0x685 <- run_pagebound(), panic: page fault, 551 MB crash dump written, guest wedged in the SMP reboot path. The control (scenario 26: same mapping, full len-17 descriptor inside page 1) parses normally -- the fault is specifically the truncation.

Fix validation (fix.diff, fix_build.log, fix_run.log)

fix.diff adds one guard per handler (smallest maintainer shape, mirroring the existing per-case "too many ..." guards) rejecting a declared length below the accessed width (4/2/1/7/3/9/17/9) with a single bogus <name> length %d line and skipping the descriptor. Applied to the guest tree (patch -p1, 8/8 hunks), kernel rebuilt and booted:

  • scenario 20: all truncated attributions now FAIL (f[] = 0 -- nothing read or stored), len=need controls still PASS;
  • scenario 21: ir_size=0, no canary bytes; scenario 22: canary byte stays 0x00 (not written); scenarios 23.1/2/3: stored configs nmem=0 -- no markers, no canaries, no residue;
  • scenario 25: NO panic -- console shows bogus memory32 range length 0, parse returns, guest stays up (direct contrast with the baseline panic);
  • scenario 24 (well-formed stream with all 8 tags at proper lengths): the 27-field stored-config vector is BIT-IDENTICAL baseline vs fixed (diff after CR-normalization: 27/27 match) -- no behavior change for conforming cards;
  • scenario 26 control still PASS.

Privesc assessment (team directive: close explicitly)

No uid=0 path exists for this bug class:

  • Reachability is boot-time only: the descriptor stream comes from the ISA PnP serial-isolation protocol (pnp.c:594-671, ports 0x279/0xa79 + READ_DATA) -- an attacker needs a malicious PHYSICAL ISA-PnP card or VM firmware (AV:P). No local unprivileged syscall, /dev node, or network path feeds these parsers; the rig needs root on an already-instrumented kernel.
  • The primitive is a bounded READ (max 17 bytes, fixed offsets, one shot per descriptor) into device resource FIELDS (ir_start/ir_end/ir_size/ ir_align/irqmask/drqmask/compatid), observable as resource data (devinfo/dmesg) -- an info leak of adjacent heap contents, not a controlled copy into attacker-chosen kernel memory.
  • The only WRITE is the fixed 0x01 byte at res+5 (pnpparse.c:165), conditional on unknown adjacent bytes; value and offset are not attacker-chosen, and the attacker cannot place the victim allocation.
  • The panic variant is a crash (DoS), not control flow: the fault is a data-read on not-present, handled by trap_fatal.
  • Escalation would require combining this with a separate heap-layout primitive and a victim consuming resource fields as pointers -- no such consumer exists (isa allocation treats them as integers).

Honest scope note: the demonstrated panic uses a rig-controlled mapping (entry-less page). In the stock pnp.c path the stream buffer is a kmalloc zone chunk whose neighbor is usually mapped, so the realistic boot-time manifestation is the leak/corruption side; the fault occurs when the buffer abuts unmapped KVA (e.g. at slab-region edges), which the card cannot place deterministically but which the kernel reaches under memory fragmentation. Both manifestations were reproduced from the same primitive; neither is a privesc.

Guest disposition

Baseline panic wedged the guest -> vm.sh reset with-src -> fix build/run -> final vm.sh reset with-src; guest returned clean (guest_dirty=0).

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

fix.diff applied to guest /usr/src (8/8 hunks), kernel rebuilt and booted: all truncated-tag OOB behavior gone (no canary reads, no canary write, no residue in stored configs, scenario 25 no panic - guard logs bogus memory32 range length 0 and skips), and the well-formed scenario-24 config vector is bit-identical to baseline (27/27 fields).

fix_run.log: sweep truncated cases f[]=0 MISMATCH vs controls PASS; canrd ir_size=0 nmem=0; canwr not written, canary stays 00; e2e1/2/3 nmem=0 stored configs empty; pgflt: bogus memory32 range length 0 then UNEXPECTED no-fault rc=0, guest up; well field vector diff vs baseline: BIT-IDENTICAL 27/27
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #0: Sun Oct 18 17:35:42 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/PNPH x86_64

Confirmed kernel references

Detail

Exploit chain

malicious ISA-PnP card / VM firmware sends a resource stream whose final descriptor declares a truncated length (e.g. MEMORY32_RANGE tag 0x85 with declared len 0, or 16 for a 1-byte over-read) sized so the descriptor lands at the end of the 1024-byte M_TEMP stream buffer -> pnp_read_resources/pnp_create_devices (pnp.c:644/:649) -> pnp_parse_resources (pnpparse.c:538) -> pnp_parse_desc reads res[0..16] past the descriptor: within the chunk tail = uninitialized heap residue leak into ir_start/ir_end/ir_size/ir_align stored on the device (devinfo/dmesg visible, ~17 heap bytes per descriptor, repeatable); past the chunk end at an allocation edge = adjacent heap disclosure and, when the next page is unmapped, Fatal trap 12 panic (DoS); IO_RANGE variant additionally writes 0x01 one byte past the descriptor when adjacent bytes permit. No further escalation: read-only ceiling plus a fixed-value 1-byte write, boot-time reach only.

Evidence (decisive lines)

run.log PART2: sweep MEM32_RANGE declen=0 need=17 TRUNCATED 17 bytes read past a len-0 descriptor attrib=PASS with f[3]=0x11100f0e; canrd ir_size=0x21595241 == I32(c2+9) 3 runs; canwr c2 before cc cc 00 7f after cc cc 01 7f OOB WRITE PROVEN; e2e2 ir_size=0x33324644 == I32(c2+13) 17 canary bytes past the 1024B allocation through the REAL parse entry; e2e3 ir_size=0x44504145 == I32(st+16) heap residue into resource config. panic.txt: Fatal trap 12 fault virtual address 0xfffff80118371003 (= res+16) supervisor read data page not present, pnp_parse_desc()+0x685, panic: page fault. fix_run.log: bogus memory32 range length 0, no fault, nmem=0 configs, canaries intact, 27/27 well-formed fields bit-identical

PoC changes

no seed PoC existed for this finding; extended the PNPH verbatim-interposition kernel harness from the DF-2314/2315 run to TU-compile the VERBATIM pnpparse.c instead of pnp.c (stock pnp.c restored to the build, stock pnpparse.c unit dropped, same sha256 gate pattern). Rig fixes during bring-up: prep.sh initially omitted installing the harness TU; a /* inside a comment tripped -Werror; the IRQ_FORMAT sweep tag byte was 0x10 (LOGICAL_DEVICE) instead of 0x20; the well-formed reference stream was missing the info byte of the 17/9-byte memory descriptors; and the page-boundary scenario first used pmap_kremove alone, which the kernel survives because trap_pfault re-materializes the PTE while the vm_map entry exists - switched to kmem_free of the tail pages so the entries are gone and the fault is fatal. Two cosmetic rig quirks remain documented: the SC20 userland summary vector mixes the MEM32_RANGE header with MEM32_FIXED field data (kernel dmesg lines are per-tag correct), and o_child prints (null) for unnamed isa children.

Verified recommended fix

add a per-handler declared-length guard before the first fixed-offset dereference in pnp_parse_desc: if (len < N) { log and skip } with N = 4, 2, 1, 7, 3, 9, 17, 9 for COMPAT_DEVICE, IRQ_FORMAT, DMA_FORMAT, IO_RANGE, IO_FIXED, MEMORY_RANGE, MEMORY32_RANGE, MEMORY32_FIXED (fix.diff)

Verdict

Reproduced with byte attribution on the QEMU guest through the verbatim TU-compiled pnpparse.c (prep.sh sha256 gate 4b13638c... = pristine audit tree; pnp.c stays the stock in-build unit). pnp_parse_desc() dereferences fixed descriptor offsets with only the card-declared len >= l checked upstream: scenario 20 proves the exact read set per tag with markers res[i]=i+1 and declared len 0 (COMPAT 4, IRQ 2, DMA 1, IO_RANGE 7, IO_FIXED 3, MEM_RANGE 9, MEM32_RANGE 17, MEM32_FIXED 9 bytes; MEM32_RANGE stored ir_size=0x11100f0e = I32(res+13), offsets 0..16 all past a len-0 descriptor; declared-16 case shows the 1-byte over-read). Scenario 21 proves the read crosses the heap chunk end (MEM32_RANGE len 0 at a 512B kmalloc chunk end, adjacent canary chunk: ir_size=0x21595241 == I32(c2+9), text ARY! from DF2316CANARY!, 3/3 runs). Scenario 23.2 drives the REAL verbatim pnp_parse_resources() with a crafted card stream whose last descriptor ends exactly at a 1024B chunk end: stored config ir_size=0x33324644 == I32(c2+13), i.e. 17 canary bytes read fully past the allocation via the same path a malicious card drives at boot. Scenario 23.3 shows the leak side end to end: a freed 1024B M_TEMP chunk re-allocated as the card stream buffer (pnp_read_bytes growth pattern) delivers its prior contents (kernel heap residue) into the stored isa_config (ir_size=0x44504145 from residue bytes EAPD), reachable via id_configs/devinfo/bootverbose prints. Scenario 22 proves the conditional 1-byte OOB WRITE of the same root cause: IO_RANGE len 0 flips an adjacent-chunk canary byte from 0x00 to 0x01 (pnpparse.c:163-166 res[5]=1). Scenario 25 proves the page-boundary DoS: with the descriptor at a page edge and the following pages kmem_freed (vm_map entries removed; the harness documents why a bare pmap_kremove is survivable - trap_pfault re-materializes the PTE while the entry exists), the baseline kernel takes Fatal trap 12, page fault while in kernel mode, fault virtual address = res+16 inside the I32(res+13) null-check read, supervisor read data, page not present, backtrace pnp_parse_desc()+0x685, panic: page fault, crash dump written, guest wedged. Fix validated on a rebuilt kernel (fix.diff: one per-handler declared-length guard, 8/8 hunks): truncated tags are skipped with a single bogus-length console line, canaries stay untouched, no residue reaches stored configs, scenario 25 no longer panics, and the well-formed 27-field config vector is bit-identical baseline vs fixed. Privesc: none - boot-time hardware attacker only (malicious ISA-PnP card / VM firmware, AV:P), the primitive is a bounded 17-byte read into resource fields plus a fixed-value 1-byte write; no local unprivileged or remote trigger, no uid=0 chain (see VERDICT.md). FreeBSD head sys/isa/pnpparse.c carries the identical unguarded handlers.