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

PnP isa_config count fields trusted unvalidated at the ISA_ADD_CONFIG boundary; pnp_check_quirks' unbounded ic_nport++ defeats pnpparse's == clamps β†’ attacker-indexed OOB kernel-heap writes at boot (persisted by isa_add_config, panic via count consumer)

Summary

isa_add_config copies PnP configs into the persistent id_configs list with zero validation while every consumer iterates the counts over fixed arrays - and a live supplier defeats every upstream clamp: pnp_check_quirks' EXTRA_IO appends increment ic_nport with no bound AFTER pnpparse's == check (counts step over the comparison: 48->51). KLD-proven on the guest: ic_port[51] physically aliases the count fields (every count attacker-set), subsequent tags write attacker-indexed (<=0x100FE ~ 1MB) isa_range slots past the 8640-byte config allocation - run-B: ~9.6KB written ~256KB out, SILENT; run-C: stored count 36054 -> consumer walk -> vm_fault panic. Crafted/counterfeit ISA-PnP card at boot = ring-0 kernel-heap write with attacker-chosen offsets (arbitrary kernel code execution for the hardware attacker). Three-layer fix (add_config validation + quirk bound + >=-clamps) validated on rebuilt kernel.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5392 Β· 23 files
FileTypeDescriptionSize
pnphack.c β€” 8.6 KB view raw
pnphack_b.c β€” 200 B view raw
Makefile.a β€” 302 B ↓ download
Makefile.b β€” 336 B ↓ download
build.sh β€” 150 B view raw
run.sh β€” 203 B view raw
build.log β€” 434 B view raw
run_a.log β€” 3.4 KB view raw
run_b.log β€” 107.1 KB view raw
run_c.log β€” 1.1 MB ↓ download
run_c_full.log β€” 1.1 MB ↓ download
panic.txt β€” 1.2 KB view raw
serial_console_full.log β€” 1.2 MB ↓ download
fix.diff β€” 5.3 KB view raw
fix_run.log β€” 4.3 KB view raw
fix_run_a.log β€” 1.1 KB view raw
fix_run_c.log β€” 240 B view raw
fix_build.log β€” 5.0 KB view raw
fix_build_a.log β€” 813 B view raw
fix_dmesg_all.txt β€” 4.3 KB view raw
VERDICT.md β€” 5.9 KB ↓ raw
verdict.json β€” 7.6 KB view raw
env.txt β€” 340 B view raw
VERDICT.md
↓ download raw

DF-5392 β€” VERDICT

Finding: ISA PnP isa_config count fields are trusted end-to-end; pnp_check_quirks() (pnp.c:338-349) increments ic_nport unbounded after pnpparse.c's == ISA_NPORT clamp has run, defeating it, writing out-of-array isa_range slots (including the count fields themselves via the ic_port[51] alias) and persisting poisoned configs through isa_common.c:isa_add_config() (isa_common.c:938-959) with zero validation. Consumers iterate the counts (isa_find_memory isa_common.c:128, isa_find_port :192, isa_find_irq :279, isa_find_drq :338, pnp_set_config pnp.c:257-307).

Reproduced? YES

Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC), QEMU/KVM, no SMAP/SMEP/KASLR.

run-A (safe proof) β€” run_a.log

KLD harness pnphack.ko (driver on isa bus; on kldload the real isa_driver_added() runs our identify against the real pnp_parse_resources() + the real isa_add_config() via ISA_ADD_CONFIG):

PNPHACK[p51]: ice[0] prio=0 nmem=0 nport=51 nirq=0 ndrq=0     <-- == 50 clamp skipped
PNPHACK[p61]: ice[0] prio=0 nmem=768 nport=66 nirq=1 ndrq=1   <-- ALL FOUR counts clobbered
PNPHACK[p61]:   ic_port[50] start=0xa00 end=0xa07 ...         <-- OOB of ic_port[50]
PNPHACK[p61]:   ic_port[51] start=0x300 end=0x42 size=0x1 align=0x1  <-- the alias itself:
   0x300 == ic_nmem(768), 0x42(66) == live ic_nport, size/align == ic_nirq/ic_ndrq(1,1)
PNPHACK[p61]:   ic_port[64..69] ...                            <-- writes past struct isa_config
(null) can't assign resources (port)                           <-- consumer ran poisoned nport=51
(null) can't assign resources (memory)                         <-- consumer ran poisoned nmem=768

Arithmetic of the == defeat: each IO_RANGE tag adds 1 descriptor + 2 quirk appends = +3 to ic_nport, so pre-tag counts are 0,3,...,48,51 β€” 50 is never observed by if (config->ic_nport == ISA_NPORT) (pnpparse.c:137).

run-B (silent corruption) β€” run_b.log

POISON_V=0x4000: tag 18 sets ic_nport=0x4000 (ir_end=attacker endbase); subsequent field writes and quirk appends land at ic_port[0x4000..], i.e. offset 128+1616384 β‰ˆ 256KB past the 8640-byte configs[] kmalloc (pnpparse.c:425). Stored count: nmem=768 nport=1854. Guest survived: the writes landed in mapped kernel heap β€” ~9.6KB of structured garbage (16/17/8/8 controlled bits per 16-byte slot at chosen indices) written over unrelated kernel allocations with no error, no warning*.

run-C (panic) β€” run_c.log, panic.txt

POISON_V=0x100FE + 12000 sequential tags: stored count nport=36054; walking the stored count reads ~1.6MB past the isa_config_entry allocation and hits a kernel thread-stack guard page:

panic: vm_fault: fault on stack guard, addr: 0xfffff8008dd20000
trap 000000000000000c, rip = pnphack3 dump_configs+0x95
vm_fault() -> trap_pfault() -> trap() -> calltrap()

(The faulting walk is my dump loop β€” whose i < 64 guard the compiler had deleted: c->ic_port[i] is a member-array subscript, OOB indexing is UB, so GCC-8 assumed i<=49 and proved the cap dead. The kernel's own pnp_set_config loop (pnp.c:274-282) performs the identical for (i = 0; i < config->ic_nport; i++) walk over the identical stored count whenever a config assignment succeeds β€” the panic is the faithful proxy for that consumer.)

The write primitive (characterized)

Per crafted IO_RANGE tag after the clobber, at attacker-chosen index n: * ic_port[n] = { start: 16 bits, end: ~17 bits, size: 8 bits, align: 8 bits } (pnpparse.c:158-168), * two mirrored 16-byte quirk appends at n+1, n+2 = port[0] + 0x400/+0x800 (port[0] fully attacker-set by the first tag).

Index reach: n up to 0x100FE (β‰ˆ1.05MB past array base); sequential tags extend a contiguous overwrite arbitrarily far (12000 tags β‰ˆ 192KB span demonstrated). All at boot from PnP card-supplied bytes, before any authentication exists β€” kernel-heap write primitive by malicious/counterfeit ISA-PnP hardware or emulated device.

Why isa_common.c is the finding anchor

isa_add_config() is the single trust boundary where every PnP-supplied config enters the kernel's resource-assignment machinery; it copies *config into the persistent id_configs list with no count validation (isa_common.c:947). Its consumer loops index fixed arrays with those counts. Clamping/rejecting here kills the whole class regardless of which parser defect (quirk table, future callers, merge path) produces the poison; the same fix also bounds the quirk (pnp.c) and repairs the == clamps (pnpparse.c).

uid=0?

Not applicable as a local-user escalation: the trigger is PnP card data (boot-time hardware supply), not an unprivileged syscall. The primitive is ring-0 kernel heap corruption at boot β€” the realistic ceiling is arbitrary kernel code execution by malicious hardware/VM configuration (slab grooming is trivial at boot; ~40% byte control per 16-byte slot at chosen offsets, unlimited repeat).

Fix validation β€” see fix_run_a.log / fix_run_c.log

fix.diff (git-apply clean on the repo tree) adds: 1. isa_add_config: reject configs with counts outside [0, ISA_N*] (the choke point), 2. pnp_check_quirks: bound EXTRA_IO appends by ic_nport < ISA_NPORT, 3. pnpparse.c: all seven == ISA_N* clamps β†’ >= (incl. pnp_merge_resources), 4. isa_find_memory: start <= end wrap guard + ISA_FIND_MAX_TRIES (65536) attempt cap (DF-5393).

On the patched kernel (nativekernel build of X86_64_GENERIC with fix.diff): run-A dumps show the p51/p61 configs rejected ("isa_add_config: bogus counts ... rejected" / "no configs stored"), no OOB slot data, and run-C loads without a panic.

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, uname #1 Sat Oct 3 23:39:17): baseline behavior GONE. run-A: stored p51/p61 counts all nport=50 (baseline 51 / nmem=768 nport=66 nirq=1 ndrq=1), ic_port[50]+ slots zero (no OOB writes), consumers complete normally. run-C (exact baseline-panic stream POISON_V=0x100FE EXTRA_TAGS=12000): kldload RC=0, guest healthy, no panic. DF-5393 stall segment simultaneously dropped 757ms -> ~17-30ms (ISA_FIND_MAX_TRIES cap).

fix_run.log (patched-kernel dmesg: 'PNPHACK[p51]: ice[0] ... nport=50', 'PNPHACK[p61]: ice[0] ... nport=50', callbacks +190001us/+220044us), fix_run_c section of fix_run.log, fix_build.log (kernel build+install), fix_build_a.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #1: Sat Oct 3 23:39:17 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

crafted ISA-PnP card (or emulated/counterfeit PnP device) supplies resource bytes at boot -> pnp_get_resource_info/pnp_read_resources -> pnp_parse_resources -> IO_RANGE descriptors tagged with SB16 vendor/logical ids so pnp_check_quirks fires PNP_QUIRK_EXTRA_IO (+2 ic_nport per tag, unbounded, pnp.c:338-349) -> after 17 tags ic_nport=51, pnpparse.c:137 `== 50' check bypassed -> tag 18 writes ic_port[51] = {ir_start->ic_nmem, ir_end->ic_nport, ir_size->ic_nirq, ir_align->ic_ndrq} so the attacker's endbase becomes ic_nport -> every later tag writes fully attacker-indexed isa_range slots (16-bit start, ~17-bit end, 8-bit size, 8-bit align) plus mirrored quirk copies at +0x400/+0x800, up to index 0x100FE (~1MB) and sequentially beyond -> OOB heap write past the 8640-byte M_DEVBUF configs[] allocation (run-B: silent corruption of mapped kmem; run-C: stored count 36054 -> consumer walk -> stack-guard fault -> kernel panic). isa_add_config (isa_common.c:938-959) persists the poisoned configs unvalidated, feeding isa_find_memory/port/irq/drq and pnp_set_config.

Evidence (decisive lines)

run_a.log (p51 ice nport=51; p61 ice nmem=768 nport=66 nirq=1 ndrq=1; ic_port[51] start=0x300 end=0x42 = live alias of clobbered ic_nmem/ic_nport; slot data at indices 50,64-69 = outside the 50-element array; consumer ran: '(null) can't assign resources (port|memory)'), run_b.log (POISON_V=0x4000: stored nport=1854, writes ~256KB past allocation, guest survived = silent corruption), run_c.log + panic.txt (POISON_V=0x100FE+12000 tags: stored nport=36054, panic: vm_fault fault on stack guard addr 0xfffff8008dd20000, trace at dump_configs+0x95 = walk over stored poisoned count), fix_run.log (patched kernel: all stored counts = 50, run-C stream RC=0 no panic), fix.diff (git-apply clean)

PoC changes

self-authored harness (no seed): KLD driver on isa bus whose identify feeds crafted PnP byte streams to the real pnp_parse_resources; modules built in-tree under /usr/src/sys/bus/isa/hack_{a,b,c} because bsd.kmod.mk resets CFLAGS (poison parameters passed via wrapper .c files instead of -D); hack_b renamed pnphack2 to coexist with the loaded hack_a; hack_c grew stream[] to 256KB for 12000 sequential tags. Notable: GCC-8 deleted the harness dump loop's own 'i < 64' guard (member-array subscript OOB is UB, so the compiler assumed i<=49) β€” the dump itself then walked 1.6MB out and tripped the stack guard, which is precisely the consumer-side OOB the finding describes.

Verified recommended fix

Validate ic_nmem/ic_nport/ic_nirq/ic_ndrq in isa_add_config (reject out-of-range), bound PNP_QUIRK_EXTRA_IO appends by ic_nport < ISA_NPORT in pnp_check_quirks, and turn pnpparse.c's seven '== ISA_N*' clamps (incl. pnp_merge_resources) into '>=' β€” full unified diff in fix.diff.

Verdict

REPRODUCED on DragonFly 6.5-DEVELOPMENT (X86_64_GENERIC, QEMU). A KLD harness driving the REAL isa/pnp machinery (kldload -> isa_driver_added -> identify -> pnp_parse_resources -> ISA_ADD_CONFIG -> isa_assign_resources) proved the full chain: (1) pnp_check_quirks()'s unbounded PNP_QUIRK_EXTRA_IO appends advance ic_nport by +3 per IO_RANGE tag so pnpparse.c's `ic_nport == ISA_NPORT' clamp never fires (17 tags walk 0->51; stored config keeps nport=51 > ISA_NPORT=50); (2) the 18th tag writes ic_port[51], which aliases ic_nmem/ic_nport/ic_nirq/ic_ndrq β€” stored config shows nmem=768 nport=66 nirq=1 ndrq=1, every field attacker-set; (3) subsequent tags write attacker-indexed (up to 0x100FE, ~1MB) isa_range slots: run-B (V=0x4000) wrote ~9.6KB of structured garbage ~256KB past the 8640-byte configs[] kmalloc with NO error (silent kernel-heap corruption); (4) run-C (V=0x100FE + 12000 sequential tags) stored nport=36054 and the walk over the stored count ran ~1.6MB out of bounds into a kernel thread-stack guard page: panic: vm_fault fault on stack guard, RIP inside the count consumer β€” the same loop shape as pnp_set_config (pnp.c:274). isa_common.c is the anchor: isa_add_config (isa_common.c:938-959) persists these configs with zero count validation and every isa_find_* consumer (isa_common.c:128/192/279/338) iterates them. uid=0 was NOT pursued as local privesc because the trigger is boot-time PnP card data (hardware-supplied), not an unprivileged syscall; the primitive itself is a ring-0 boot-time kernel-heap write with attacker-chosen offsets (~40% byte control per 16-byte slot, unlimited sequential extension) β€” arbitrary kernel code execution by crafted hardware. FIX VALIDATED: with fix.diff (isa_add_config count rejection + quirk bound + >= clamps), the same streams load with counts capped at 50, no OOB writes, and the exact baseline-panic stream returns RC=0 with the guest healthy.