# 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.
