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

pnp_read_bytes() error path abandons the grown buffer β€” caller frees the stale pre-growth pointer: kernel heap double free + leak of the replacement

Summary

On a short resource read after buffer growth the function returns EINVAL without updating *resourcesp, so pnp_read_resources propagates the already-freed pre-growth pointer and the isolation-protocol cleanup frees it AGAIN (grown block leaks): the ordinary 'failed to report resource data' condition = boot-time ring-0 double free of a 1024B M_TEMP block + guaranteed leak. pnprig-verified with VERBATIM pnp.c: 'panic: memory chunk ... is already free!'; publish-buffer-before-read fix validated on a rebuilt kernel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5443 Β· 24 files
FileTypeDescriptionSize
README.md β€” 2.9 KB ↓ raw
VERDICT.md β€” 4.7 KB ↓ raw
verdict.json β€” 6.7 KB view raw
manifest.json β€” 1.2 KB view raw
rig/pnprig.c β€” 13.1 KB view raw
rig/rigmod.h β€” 1.1 KB view raw
rig/hack_dfa/wrap.c β€” 138 B view raw
rig/hack_dfa/Makefile β€” 110 B ↓ download
rig/hack_dfb/wrap.c β€” 135 B view raw
rig/hack_dfb/Makefile β€” 110 B ↓ download
rig/hack_dfc/wrap.c β€” 133 B view raw
rig/hack_dfc/Makefile β€” 110 B ↓ download
rig/hack_dfd/wrap.c β€” 143 B view raw
rig/hack_dfd/Makefile β€” 110 B ↓ download
rig/build.sh β€” 403 B view raw
rig/run_A.sh β€” 327 B view raw
rig/run_BCD.sh β€” 419 B view raw
run.log β€” 1.0 KB view raw
serial_runA_full.log β€” 2.2 KB view raw
panic.txt β€” 488 B view raw
env.txt β€” 382 B view raw
fix.diff β€” 3.6 KB view raw
fix_run.log β€” 1.0 KB view raw
fix_build.log β€” 5.6 MB ↓ download

DF-5443 β€” pnp_read_bytes() error path abandons the grown buffer β†’ double free + leak (sys/bus/isa/pnp.c)

What

pnp_read_bytes() (sys/bus/isa/pnp.c:493-526) grows the resource buffer with kmalloc/bcopy/kfree, but on a short read (pnp_get_resource_info(...) != amount, pnp.c:517-518) returns EINVAL without updating *resourcesp. The caller pnp_read_resources() then propagates the caller-local pointer β€” which still references the already-freed pre-growth buffer β€” through its own out: block (pnp.c:576-580), and pnp_isolation_protocol() frees that stale pointer at cleanup (pnp.c:673-674). Result: kernel heap double free of the old buffer plus a leak of the newly-grown buffer.

Trigger model

Any ISA-PnP card (or emulated/counterfeit PnP device in a VM) that delivers more than 1024 bytes of resource data (forcing the growth path) and then stops asserting the resource-data-ready bit mid-descriptor. This is not limited to malicious hardware: the "PnP device failed to report resource data" path (pnp.c:218-221) exists precisely for flaky cards.

Rig

rig/ contains a KLD that compiles the real, unmodified sys/bus/isa/pnp.c (included verbatim; only inb/outb/kmalloc/kfree and three global symbol names are macro-substituted, and its DRIVER_MODULE neutered) and drives it with a virtual PnP card implementing the serial-isolation state machine (checksum-valid 72-bit ID, PNP_STATUS/PNP_RESOURCE_DATA byte service). Module hack_dfa (script 1):

  • card passes serial isolation (checksum-valid),
  • serves a large vendor tag header (0x84, len 2048) β€” 3 bytes,
  • then stalls (status never ready).

pnp_read_bytes(2048) grows 1024β†’3072 (freeing the 1024 block), the read fails short, the stale pointer is freed again at isolation cleanup.

Build

# on the guest (root), sources pushed to /usr/src/sys/bus/isa/
(cd /usr/src/sys/bus/isa/hack_dfa && make obj && make depend && make)

Run

kldload /usr/obj/usr/src/sys/bus/isa/hack_dfa/rigdfa.ko

Expected (unfixed baseline kernel)

PNPRIG: kmalloc(1024, temp) = 0xfffff8008db3b400     <- buffer A
PNPRIG: kmalloc(3072, temp) = 0xfffff8008dc05500     <- grown B
PNPRIG: kfree(0xfffff8008db3b400, temp)              <- growth free of A (pnp.c:512)
PnP device failed to report resource data            <- pnp.c:219 short read
PNPRIG: kfree(0xfffff8008db3b400, temp)              <- cleanup free of STALE A (pnp.c:674)
panic: memory chunk 0xfffff8008db3b400 is already free!
_kfree() <- rig_identify <- isa_driver_added <- devclass_add_driver
  • baseline: kernel panic (allocator double-free check), B leaked.
  • fixed kernel: the error path publishes the live buffer; the cleanup kfree targets the grown buffer exactly once, kldload returns 0.

Fix

fix.diff (also fixes DF-5444/DF-5445 in the same files β€” see those packs). Validated on a rebuilt kernel: see VERDICT.md.

VERDICT.md
↓ download raw

DF-5443 VERDICT β€” REPRODUCED (kernel panic; primitive = heap double-free)

Bottom line

The bug is real, attacker-reachable through card data, and was reproduced end-to-end on the guest with the real, unmodified sys/bus/isa/pnp.c engine: a card that delivers >1 KiB of resource data and then stalls mid-descriptor makes pnp_isolation_protocol() free an already-freed buffer β†’ panic: memory chunk 0xfffff8008db3b400 is already free! A single-fix kernel (fix.diff) makes the identical fault injection complete cleanly (RC=0, exactly one free of the live grown buffer).

Root cause (path:line)

  • pnp_read_bytes() grows the buffer: kmalloc β†’ bcopy β†’ kfree(old) at sys/bus/isa/pnp.c:506-515.
  • The short-read error path returns EINVAL without updating *resourcesp (pnp.c:517-518) β€” the out-params are only written on success (pnp.c:521-523).
  • pnp_read_resources() propagates its (now stale) local pointer at out: (pnp.c:576-580).
  • pnp_isolation_protocol() breaks on the error and runs if (resources) kfree(resources, M_TEMP); (pnp.c:673-674) β†’ second free of the pre-growth buffer; the grown replacement is leaked.

Trigger precondition is exactly the "PnP device failed to report resource data" condition the code already anticipates for flaky cards (pnp.c:218-221) β€” no exotic state required, only a card (malicious OR merely misbehaving) that stalls after >1024 delivered bytes.

Reproduction (baseline kernel #0, Thu Jul 2 06:02:54)

Rig: KLD compiling pnp.c verbatim (only inb/outb/kmalloc/kfree and three global names macro-substituted; DRIVER_MODULE neutered) + a virtual PnP card implementing the serial-isolation protocol (checksum-valid 72-bit ID served LSB-first per pnp.c:192, PNP_STATUS/PNP_RESOURCE_DATA service). Script 1: large vendor tag header (0x84, len 2048) served, body stalled.

Observed (serial console, run.log / serial_runA_full.log / panic.txt):

PNPRIG: kmalloc(1024, temp) = 0xfffff8008db3b400     <- buffer A (pnp.c:503)
PNPRIG: kmalloc(3072, temp) = 0xfffff8008dc05500     <- grown B (pnp.c:510)
PNPRIG: kfree(0xfffff8008db3b400, temp)              <- growth free (pnp.c:512)
PnP device failed to report resource data            <- short read (pnp.c:219)
PNPRIG: kfree(0xfffff8008db3b400, temp)              <- STALE free (pnp.c:674)
panic: memory chunk 0xfffff8008db3b400 is already free!
_kfree() <- rig_identify <- isa_driver_added <- devclass_add_driver

The stack is the genuine in-vivo kldload β†’ isa_driver_added (isa_common.c:816-831) β†’ identify β†’ engine path. DragonFly's slab allocator (chunk_mark_free) converts the double free into a deterministic panic; on the same evidence the free-list is poisoned twice β€” the classic two-owners primitive (the witness probes in the rig demonstrate re-handout mechanics when the allocator does not abort).

Why not uid0

There is no unprivileged-local-user input path: pnp.c runs only during ISA bus autoconfiguration (boot) or isa_driver_added on driver kldload. The attacker model is crafted/counterfeit PnP hardware or a VM-emulated device β€” same class as DF-5392/5393/5394 (pccard CIS family analogy). For that attacker the primitive is ring-0 boot-time heap corruption with a fully-controlled stall point (attacker chooses which 1024-byte-aligned growth boundary and when), i.e., arbitrary-code-execution-grade; severity kept at Medium consistent with the hardware-trigger model of the sibling findings.

Fix validation (kernel #1, Mon Oct 5 16:47:13, fix.diff applied)

Identical rig, identical card script, patched engine compiled in:

PNPRIG: kmalloc(1024, temp) = 0xfffff8008db3b800
PNPRIG: kmalloc(3072, temp) = 0xfffff8008dc05500
PNPRIG: kfree(0xfffff8008db3b800, temp)     <- growth free (unchanged)
PnP device failed to report resource data
PNPRIG: kfree(0xfffff8008dc05500, temp)     <- cleanup frees the LIVE buffer
RUN_A_RC=0 ; witness: no double-freed address recorded ; guest healthy

The leak is fixed simultaneously (the grown buffer is now reachable by the caller and freed exactly once). Baseline behavior GONE.

Fix

fix.diff hunk in pnp.c:514-533 publishes *resourcesp/*spacep before the read attempt (and drops the redundant success-path copies). The same diff also carries the DF-5444/DF-5445 fixes (see those packs); full kernel build log in fix_build.log (4299 compile steps, clean).

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

Single-fix kernel (nativekernel X86_64_GENERIC + fix.diff, #1 Mon Oct 5 16:47:13): the exact baseline-panic fault injection now completes with kfree of the LIVE grown buffer (0xfffff8008dc05500) exactly once at cleanup, no DOUBLE FREE detected, no panic, kldload RC=0, witness clean, guest healthy. Baseline bad behavior GONE; leak of the grown buffer also eliminated.

fix_run.log (patched-kernel dmesg sequence + RUN_A_RC=0), fix_build.log (full kernel build), fix.diff (git apply --check clean against the read-only sys/ tree)
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Mon Oct 5 16:47:13 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted/misbehaving ISA-PnP card (or emulated PnP device in a VM) -> pnp_identify read-port loop -> pnp_isolation_protocol -> pnp_get_serial (valid checksum) -> pnp_read_resources -> pnp_read_bytes growth (kmalloc B, kfree A at pnp.c:512) -> card stalls -> pnp_get_resource_info returns short -> EINVAL without out-param update (pnp.c:517-518) -> pnp_read_resources out: propagates stale A (pnp.c:577-579) -> pnp_isolation_protocol cleanup kfree(A) again (pnp.c:674) -> heap double free -> panic on this kernel (chunk_mark_free) / free-list poisoning (two live owners of one 1024-byte block) on kernels without the check -> boot-time ring-0 memory corruption under attacker control.

Evidence (decisive lines)

run.log + serial_runA_full.log (baseline: kmalloc(1024)=0xfffff8008db3b400, kmalloc(3072)=0xfffff8008dc05500, kfree(A) at growth, short-read message, kfree(A) again, panic); panic.txt (panic: memory chunk ... is already free!, full trace); fix_run.log (patched kernel: same stream, kfree of 0xfffff8008dc05500 at cleanup, RUN_A_RC=0, witness clean); fix.diff (out-param propagation hunk); fix_build.log (full 4299-step kernel build of the fixed tree); rig/ (pnprig.c harness + hack_dfa wrapper + build.sh + run_A.sh)

PoC changes

Self-authored rig (no seed). (1) The virtual card must serve serial bits LSB-first per byte β€” the kernel assembles data[i/8] by shifting right and ORing into 0x80 (pnp.c:192), so MSB-first serving yields bit-reversed IDs and checksum failure; fixed after attempt 1 (simulated the LFSR offline). (2) The kfree-witness initially counted per-address frees and false-positived on legal allocator address recycling during buffer growth; replaced with liveness tracking (alloc marks live, free of non-live chunk = double free). (3) pnp_identify() refuses to run on a DS_ATTACHED bus (pnp.c:698-701 rescan guard), so the rig drives the identical read-port loop (pnp.c:713-720) calling pnp_isolation_protocol() directly β€” the engine itself is untouched.

Verified recommended fix

In pnp_read_bytes(), publish resourcesp/spacep (and *lenp semantics) BEFORE the pnp_get_resource_info() attempt so the caller always tracks the live buffer β€” full unified diff in fix.diff (also fixes DF-5444/DF-5445).

Verdict

REPRODUCED on DragonFly 6.5-DEVELOPMENT (X86_64_GENERIC, QEMU). A KLD compiling the REAL unmodified sys/bus/isa/pnp.c (verbatim #include; only inb/outb/kmalloc/kfree and three global names macro-substituted, DRIVER_MODULE neutered) was driven by a virtual PnP card implementing the serial-isolation protocol (checksum-valid 72-bit ID served LSB-first, PNP_STATUS/PNP_RESOURCE_DATA service). A card that streams a large-tag header (0x84, len 2048) and then stalls mid-descriptor β€” i.e. takes the ordinary 'PnP device failed to report resource data' path (pnp.c:218-221) after >1024 delivered bytes β€” forces pnp_read_bytes() through its growth path (kmalloc 3072, kfree of the 1024 buffer at pnp.c:512) and then into the short-read EINVAL return that does NOT update *resourcesp (pnp.c:517-518). pnp_read_resources() propagates the stale freed pointer at out: (pnp.c:576-580) and pnp_isolation_protocol() frees it again at cleanup (pnp.c:673-674): observed 'kfree(A) [growth]; kfree(A) [cleanup]' then 'panic: memory chunk 0xfffff8008db3b400 is already free!' with stack _kfree <- engine identify <- isa_driver_added <- devclass_add_driver β€” the genuine kldload path. Primitive = kernel-heap double free (CWE-415) of a 1024-byte M_TEMP block with attacker-chosen timing, plus a leak of the grown buffer; terminal outcome on this kernel is a deterministic panic (allocator chunk_mark_free check). uid0 not pursued: no unprivileged-user input path β€” trigger is PnP card data at boot/driver-load (crafted/counterfeit hardware or VM device), same attacker model as DF-5392/5393/5394; for that attacker the primitive is boot-time ring-0 heap corruption. FIX VALIDATED on rebuilt kernel #1 (fix.diff): identical fault injection now frees the LIVE grown buffer exactly once at cleanup (kfree(3072-buffer)), no DOUBLE FREE, no panic, kldload RC=0, witness clean β€” leak also fixed.