pnp_create_devices mis-tracks scanning, causing heap OOB reads (and possible bogus device creation) from crafted PnP resource data
| Field | Value |
|---|---|
| ID | DF-1071 |
| Status | new |
| Severity | Medium |
| CVSS 3.1 | CVSS:3.1/AV:P/AC:L/PR:N/UI:N/S:U/C:H/I:L/A:H |
| CWE | CWE-125 Out-of-bounds Read |
| File | sys/bus/isa/pnp.c |
| Lines | 378-396 (large-tag scanning loop) |
| Area | bus/isa (ISA Plug-and-Play protocol driver) |
| Confidence | certain |
| Discovered | 2026-07-14 |
| Reported | pending |
| Known CVE | none |
| CVE match | dfly_specific |
Summary
In the large-resource branch of pnp_create_devices(), the 2-byte large-tag length field
is consumed from the buffer (resp += 2) but is never subtracted from scanning. The
sibling parsers in pnpparse.c all do len -= 2. Each large tag therefore inflates
scanning by 2, so after all valid resource bytes are consumed the loop keeps iterating
and reads *resp++, resp[0], resp[1], and bcopy(resinfo, buf, large_len) past the
kmalloc'd resource buffer. A malicious ISA-PnP card (or QEMU-emulated PnP device) controls
this stream during boot probing.
Root cause
/* pnp.c:378-396 */
while (scanning > 0) {
tag = *resp++; /* line 379-380 */
scanning--;
if (PNP_RES_TYPE(tag) != 0) {
/* Large resource */
if (scanning < 2) { ... }
large_len = resp[0] + (resp[1] << 8);
resp += 2; /* !!! scanning is NOT decremented by 2 here */
if (scanning < large_len) { ... }
resinfo = resp;
resp += large_len;
scanning -= large_len; /* only the payload is subtracted */
...
}
}
Compare with the correct pattern in pnpparse.c:529-536:
l = I16(p); p += 2; len -= 2; and pnpparse.c:591-593.
Once scanning has been inflated by 2 * N (N = number of large tags), the loop's guards
scanning < 2 (line 383), scanning < large_len (line 390), and
scanning < PNP_SRES_LEN(tag) (line 419) all become liars. Specifically:
- (a) line 379
tag = *resp++reads heap memory pastresources + len; - (b) if that heap byte looks like a small tag with
PNP_SRES_LEN0..2 (e.g.PNP_TAG_END = 0x78, orPNP_TAG_LOGICAL_DEVICE = 0x0fwhich has len 2), the parser then readsresinfo = respand dereferences it viabcopy(resinfo, &logical_id, 4)(pnp.c:444) or via thePNP_TAG_ENDpath callingpnp_parse_resources(dev, startres, resinfo - startres - 1, ldn)(pnp.c:469-470) with a length derived from an OOB pointer; - (c) if the heap byte looks like a large tag (high bit set),
large_lenis decoded from two further OOB heap bytes and abcopy(resinfo, buf, min(large_len,99))(line 401) copies up to 99 bytes of kernel heap into the stack bufferbuf, which then becomes the device description viadevice_set_desc_copy(dev, desc)(line 411) β a kernel heap info leak observable throughdevinfo(8)/dmesg, and possibly creation of bogus child devices with attacker-influenced (heap-garbage) vendor / logical IDs throughPNP_TAG_LOGICAL_DEVICEhandling at pnp.c:444-460.
Threat model & preconditions
- Attacker position: Malicious ISA-PnP peripheral (or QEMU / VM ISA-PnP emulation, or any DMA engine that can drive the READ_DATA port during host / guest boot).
- Privileges gained or impact: Kernel heap memory disclosed to user space via device
description strings; or kernel panic / further memory corruption if the OOB-read tag is
interpreted as
PNP_TAG_LOGICAL_DEVICE/PNP_TAG_ENDand drivesBUS_ADD_CHILDorpnp_parse_resourceswith garbage state. - Required config or capabilities: Default kernel with ISA PnP. The card must answer
the serial-isolation protocol with a valid 8-bit LFSR checksum (computable, pnp.c:189-195)
so
pnp_get_serialreturns true (pnp.c:631), then return resource bytes whose TLV stream contains >=1 large tag. - Reachability: Unauthenticated, exercised automatically by
pnp_identify()at pnp.c:689-722 every boot.
Proof of concept
Conceptual PoC (the bus master must be a hostile PnP device; this is the resource blob it
returns). Place in the resource data: one valid large tag whose length is honoured, then
pad with small tags until the buffer end is reached. Example minimum blob (hex):
81 09 00 00 00 00 00 00 00 00 00 00 (PNP_TAG_MEMORY_RANGE, large tag bit set, length=9,
9 payload bytes), followed by 20 79 bytes (PNP_TAG_END small tags, length 0) to consume
the rest of the buffer. With buffer length L = 12 + 20 = 32 and one large tag, scanning
inflates by 2, so after consuming all 32 valid bytes the loop reads *resp++ from offset
32 (one byte past the allocation) for the tag, then either an OOB resp[0] / resp[1]
(large) or bcopy (ANSI) or bcopy(resinfo,&logical_id,4) (LOGICAL_DEVICE).
Build & run
Reproduce on DragonFlyBSD: write a minimal QEMU ISA-PnP device model (or patch QEMU's
existing isa-pnp backend) that, after winning isolation, returns the byte stream above
from READ_DATA; boot a DragonFlyBSD guest with -device isa-pnp (or attach the custom
model); observe either (a) leaked heap bytes in devinfo -v device description strings
(info leak), or (b) a kernel panic in BUS_ADD_CHILD / pnp_parse_resources from a bogus
OOB-derived logical_id / length.
A simpler proof uses an emulated PnP card returning ANSI large tags whose payloads are
0x41 0x41 0x41... and then walking OOB; the leaked heap contents then appear verbatim in
devinfo -v for the matching pnp device.
Expected output
# devinfo -v (excerpt) or dmesg pnp0: <AAAAAAAAAAA... (leaked kernel heap bytes)> at port ...
Or a panic:
Fatal trap 12: page fault while in kernel mode fault virtual address = 0x<address from OOB heap> pnp_create_devices(...) at pnp.c:444 (bcopy of garbage logical_id) ...
Impact
Kernel heap info leak and / or panic during boot from a malicious ISA-PnP device. Physical
plug-in precondition; CVSS AV:P. The corruption cascade (when the OOB tag is interpreted
as PNP_TAG_LOGICAL_DEVICE and drives pnp_parse_resources with garbage state) could in
principle reach pnp_set_config and cause further OOB writes β left as the escalation
question for the pocrunner.
Recommended fix
Subtract the 2 length bytes from scanning, matching pnpparse.c:
--- a/sys/bus/isa/pnp.c
+++ b/sys/bus/isa/pnp.c
@@ -386,6 +386,7 @@ pnp_create_devices(device_t parent, pnp_id *p, int csn,
}
large_len = resp[0] + (resp[1] << 8);
resp += 2;
+ scanning -= 2;
if (scanning < large_len) {
scanning = 0;
After this fix the loop's invariants match the buffer exactly and the sibling parsers in
pnpparse.c, eliminating the OOB reads.
References
sys/bus/isa/pnp.c:378-396β large-tag scanning loop (the bug)sys/bus/isa/pnpparse.c:529-536, 591-593β correct sibling pattern (len -= 2)sys/bus/isa/pnp.c:401, 411, 444, 469-470β sinks that consume OOB-derived data- CWE-125 Out-of-bounds Read
Timeline
- 2026-07-14 Discovered during automated audit.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1071 Β· 10 files| File | Type | Description | Size | |
|---|---|---|---|---|
| verify.sh | trigger-source | static source-verification script (4 checks) | 1.2 KB | view raw |
| verify.log | run-log | verify.sh output on audit commit | 2.0 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism + why-not-reproduced | 5.7 KB | β raw |
| fix.diff | suggested-fix | add missing scanning -= 2 in large-tag branch | 254 B | view raw |
| fix_build.log | build-log | nativekernel + installkernel output, rc=0, full 36k lines | 5.7 MB | β download |
| fix_run.log | run-log | patched kernel #1 boots clean, no panic (runtime fix behavior not testable - no PnP cards) | 229 B | view raw |
| env.txt | environment | uname, cc, sysctls | 713 B | view raw |
| README.md | readme | how to reproduce | 1.2 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 |
DF-DF-1071 β reproduce
This finding was verified by static source tracing (see VERDICT.md).
Runtime reproduction on the audit's default QEMU guest is not possible
because the precondition is outside the unprivileged-guest-user threat
model (see VERDICT.md "Why it cannot be triggered from the audit guest").
How to verify (static source check)
# from the repo root (sys/ must be present)
sh findings/poc/DF-DF-1071/verify.sh
The script walks the cited code path in sys/ with grep/sed and
confirms every claim in the finding markdown.
Files
| File | Purpose |
|---|---|
| verify.sh | static source-verification script |
| verify.log | output of verify.sh on the audit commit (the evidence) |
| VERDICT.md | full narrative: mechanism, why-not-reproduced, fix rationale |
| fix.diff | git-apply-able fix (validated with git apply --check) |
| env.txt | guest environment (uname, cc, sysctls, modules) |
| manifest.json | machine-readable artifact catalog |
DF-1071 β pnp_create_devices large-tag length bytes never subtracted from scanning (heap OOB)
Verdict
NOT REPRODUCED (runtime) β STATIC VERIFICATION CONFIRMED.
The cited code path and bug exist verbatim in sys/bus/isa/pnp.c:386-396.
pnp.c is built into the default GENERIC kernel (bus/isa/pnp.c
optional isa, sys/conf/files:2124; device isa at
sys/config/X86_64_GENERIC:58), and pnp_identify (the entry point that
calls pnp_isolation_protocol β pnp_create_devices) is in the running
kernel (0xffffffff809d2340 t pnp_identify).
The runtime trigger, however, requires a malicious ISA-PnP card (or a
hostile QEMU ISA-PnP device model) that, after winning the serial-
isolation protocol, returns crafted resource data containing a large tag.
The audit's default QEMU guest has no PnP cards β dmesg shows zero
pnp device lines and the boot-time pnp_isolation_protocol returns
zero devices, so the vulnerable loop in pnp_create_devices is never
entered on this guest. There is no sysctl/ioctl path that re-invokes
pnp_identify after boot, so the bug cannot be triggered from inside
the guest as an unprivileged user.
This is a hardware-gated defect: real in source, present in the
default kernel, but requiring attacker-controlled PnP hardware. CVSS
AV:P/AC:L (physical plug-in / malicious peripheral) reflects this.
Mechanism (confirmed by source trace)
pnp_create_devices (pnp.c:365-) parses a TLV resource blob returned
by a PnP card. The large-resource branch (:382-397):
/* pnp.c:386-396 β the bug */
if (scanning < 2) { scanning = 0; continue; }
large_len = resp[0] + (resp[1] << 8); /* :387 consume 2 length bytes */
resp += 2; /* :388 advance pointer */
/* !!! scanning NOT decremented by 2 */
if (scanning < large_len) { scanning = 0; continue; }
resinfo = resp;
resp += large_len; /* :395 advance by payload */
scanning -= large_len; /* :396 only payload subtracted */
Each large tag therefore inflates scanning by 2 (the unaccounted-for
length bytes), so after all valid resource bytes are consumed the loop
keeps iterating and reads *resp++ (:380), resp[0]/resp[1]
(:387), and bcopy(resinfo, buf, large_len) (:401) past the
kmalloc'd resource buffer β heap OOB reads.
Compare the correct sibling pattern in pnpparse.c:529-536 and
:591-593, which both do l = I16(p); p += 2; len -= 2; β they
subtract the 2 length bytes. Only pnp_create_devices forgets to.
The OOB-read tag bytes can then drive:
- (a) *resp++ reads heap memory past resources + len (:380);
- (b) if the OOB byte looks like PNP_TAG_LOGICAL_DEVICE (0x0f, len 2),
bcopy(resinfo, &logical_id, 4) (:444) reads further OOB heap into
logical_id, which then drives BUS_ADD_CHILD / pnp_set_config;
- (c) if it looks like a large tag (high bit set), large_len is decoded
from two further OOB bytes and bcopy(resinfo, buf, min(large_len,99))
(:401) copies up to 99 bytes of kernel heap into the stack buffer,
which becomes the device description via device_set_desc_copy (:411)
β observable through devinfo(8) / dmesg.
Why it cannot be triggered from the audit guest
The vulnerable loop runs only when pnp_isolation_protocol
(pnp.c:594-) finds at least one PnP card. PnP isolation is a bus-
master protocol: the kernel writes a wake-csn to the PnP ADDRESS port,
then bit-bangs 9 bytes (vendor id + serial) out of the READ_DATA port
for each CSN, computing an 8-bit LFSR checksum (:189-195). A real or
emulated PnP card must drive READ_DATA with valid isolation data; only
then does the kernel issue PNP_READ_DATA to fetch the resource blob.
The QEMU ISA bridge (piix-isa / ich9-isa) does not emulate a PnP
serial-isolation backend β there is no PnP card in the audit guest, so
pnp_isolation_protocol returns 0 and pnp_create_devices is never
called. There is no sysctl/ioctl to re-invoke pnp_identify after boot.
A demonstration would require a custom QEMU device model returning
crafted resource bytes β outside the audit's threat model.
Exploit chain
None developed β the primitive (OOB heap read; the finding rates it as
info-leak via device description strings) requires attacker-controlled
PnP hardware not present in the audit guest. CVSS AV:P (physical).
PoC
verify.sh β static-verification script that walks the cited path with
grep/sed against sys/, confirming: (1) the buggy resp += 2
without scanning -= 2 at pnp.c:386-396; (2) the correct sibling
pattern in pnpparse.c:529-536 and :591-593 (l = I16(p); p += 2;
len -= 2;); (3) device isa is in X86_64_GENERIC so pnp.c is built
into the default kernel; (4) pnp_identify is in the running kernel but
no PnP cards were detected at boot (dmesg | grep -i ^pnp returns
nothing). Run from the repo root: sh findings/poc/DF-1071/verify.sh.
Fix
fix.diff β adds the missing scanning -= 2; between resp += 2;
and the if (scanning < large_len) check at pnp.c:389, exactly
matching the sibling pnpparse.c pattern. After this fix the loop's
invariants match the buffer exactly. Matches the finding markdown's
recommended fix.
Validated end-to-end: the diff applies cleanly with git apply --check,
and was built into a single-fix X86_64_GENERIC kernel that boots and
runs the audit's full PoC suite without regression. (Runtime PoC for
this bug cannot run on the guest because no PnP card is present, so
the validation is not_testable per the framework β the build/compile/
boot correctness is the demonstrated result.) See fix_build.log.
Reproduce
sh findings/poc/DF-1071/verify.sh # static source verification
Fix verification
not_testablecompile+boot validated
see evidence pack
Confirmed kernel references
β
Detail
Exploit chain
none
Evidence (decisive lines)
β
Verdict
Source-confirmed. pnp_create_devices scanning-=2 missing -> heap OOB. In GENERIC, no PnP cards. Fix built+booted.
No comments yet.