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)
PoC verification
Evidence pack
findings/poc/DF-5392 Β· 23 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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
fixedSingle-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
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.
No comments yet.