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

Negative unit number in devclass_alloc_unit causes heap OOB write via dc->devices[]

Field Value
ID DF-0003
Status new
Severity Medium
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H
CWE CWE-787 Out-of-bounds Write
File sys/kern/subr_bus.c
Lines 1064-1125, 1144, 2166-2184
Area kern
Confidence likely
Discovered 2026-06-29
Reported pending

Summary

devclass_alloc_unit() only treats -1 as a wildcard unit. Any other negative unit (e.g. -2) bypasses the existing-device check (guarded by unit >= 0) and the table-extension check (guarded by unit >= dc->maxunit, which is false for negatives) and returns success with the negative value. devclass_add_device() then executes dc->devices[dev->unit] = dev β€” a heap OOB write at a negative index into the dc->devices array, corrupting heap memory immediately before the allocation. device_set_unit() has a matching OOB read in its bounds check.

Root cause

In devclass_alloc_unit (sys/kern/subr_bus.c:1064-1125):

  • int unit = *unitp; at line 1067.
  • if (unit != -1) at line 1072 β€” only -1 is the wildcard. A value like -2 enters the "wired unit" branch.
  • if (unit >= 0 && unit < dc->maxunit && dc->devices[unit] != NULL) at line 1073 β€” unit >= 0 is FALSE for negatives, so the existing-device check is skipped entirely.
  • if (unit >= dc->maxunit) at line 1094 β€” maxunit is non-negative, so a negative unit makes this FALSE; the table is not extended and the negative unit is not caught.
  • The function falls through to *unitp = unit; return(0); at lines 1123-1124, returning success with the negative unit.

Back in devclass_add_device (sys/kern/subr_bus.c:1144):

dc->devices[dev->unit] = dev;   /* writes 8 bytes at a negative array index */

dev->unit is the negative value returned above, so this writes a device_t pointer before the start of the kmalloc'd dc->devices array, corrupting adjacent heap metadata or objects.

device_set_unit (sys/kern/subr_bus.c:2166-2184) has a related OOB read at its bounds check (sys/kern/subr_bus.c:2172):

if (unit < dc->maxunit && dc->devices[unit])   /* dc->devices[negative] read */
    return(EBUSY);

A negative unit makes unit < dc->maxunit TRUE, so dc->devices[unit] is an OOB read before the array; if it reads NULL, execution proceeds to dev->unit = unit (:2177) and devclass_add_device, hitting the OOB write. make_device passes caller-supplied unit straight through devclass_add_device to the same sink.

Threat model & preconditions

  • Attacker position: No demonstrated unprivileged-userspace trigger. The unit parameter originates from bus driver code (device_add_child, device_add_child_ordered) or from the loader hints (root-controlled). The finding is a latent memory-corruption defect: any driver that computes a unit which underflows below zero β€” e.g. unit = a - b with b > a, a signed parse of a device-reported/HW field, or an arithmetic slip in an attacker-influenced (USB/thunderbolt/NFS-over-PCIe/etc.) path β€” reaches this sink and corrupts the kernel heap.
  • Privileges gained or impact: if reached, kernel heap corruption β€” an attacker-influenced 8-byte pointer write at a selectable negative offset from a kmalloc array. Potentially exploitable for arbitrary kernel R/W (via corrupted slab/malloc metadata) and thus privilege escalation.
  • Required config or capabilities: default kernel. Reachability depends on a calling driver passing a negative unit.
  • Reachability: device_add_child(bus, drv, <negative>) β†’ make_device β†’ devclass_add_device β†’ devclass_alloc_unit; or device_set_unit(dev, <negative>). The huge driver tree under sys/dev/ and sys/bus/ is the realistic source of a miscomputed unit.

Proof of concept

PoC source: findings/poc/DF-0003/poc_negunit.c

A small kernel module that calls device_add_child(root_bus, "fakehack", -2) to drive the OOB write directly. It requires root to kldload but proves both that the write occurs and that nothing in devclass_alloc_unit rejects the value.

Build & run

cc -I/sys -DKERNEL -c findings/poc/DF-0003/poc_negunit.c
ld -r poc_negunit.o -o poc_negunit.ko
kldload ./poc_negunit.ko      # as root; INVARIANTS kernel recommended

Expected output

poc: OOB write occurred (child=0x...)

On an INVARIANTS kernel, subsequent heap operations typically panic with slab/malloc assertions ("freed pointer ... modified", "use after free"), proving the out-of-bounds write landed on heap metadata / an adjacent object. The negative index is selectable (-2 .. -N) so a specific pre-array offset can be targeted with heap grooming.

Impact

Latent kernel heap corruption reachable by any driver that computes a negative unit number. The bug is in foundational (newbus) code used by every device, so a single underflowed unit anywhere in the driver tree is a potential local privilege-escalation / kernel-R/W primitive. No unprivileged trigger was identified in this file; the fix is cheap and removes a real memory-corruption footgun.

Validate the unit at the entry of devclass_alloc_unit (reject < -1) and mirror the guard in device_set_unit (reject < 0).

--- a/sys/kern/subr_bus.c
+++ b/sys/kern/subr_bus.c
@@ -1064,6 +1064,9 @@ static int
 devclass_alloc_unit(devclass_t dc, int *unitp)
 {
    int unit = *unitp;
+
+   if (unit < -1)
+       return (EINVAL);

    PDEBUG(("unit %d in devclass %s", unit, DEVCLANAME(dc)));
@@ -2165,6 +2168,9 @@ int
 device_set_unit(device_t dev, int unit)
 {
    devclass_t dc;
    int err;
+
+   if (unit < 0)
+       return (EINVAL);

    dc = device_get_devclass(dev);
    if (unit < dc->maxunit && dc->devices[unit])

References

Timeline

  • 2026-06-29 Discovered during automated file-by-file audit of sys/kern/subr_bus.c.
  • pending Reported to DragonFlyBSD security contact.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-0003 Β· 18 files
FileTypeDescriptionSize
poc_negunit.c trigger-source kld module: device_add_child(root_bus,"df3neg",-2) -- drives the negative-unit OOB-write sink 2.8 KB view raw
poc_ctrl.c control-source kld module: device_add_child(root_bus,"df3ctrl",0) -- valid unit; must load cleanly (the control) 1.1 KB view raw
Makefile build-makefile bsd.kmod.mk build for the trigger (correct kernel CFLAGS) 302 B ↓ download
Makefile.ctrl build-makefile bsd.kmod.mk build for the control 180 B ↓ download
setup_env.sh build-setup install machine forwarders on a guest that lacks them 976 B view raw
build.sh build-script build both .ko modules via make 480 B view raw
run.sh run-script load control (clean) then trigger (panic on #0) as root 922 B view raw
build.log build-log full successful bsd.kmod.mk build output (trigger + control) 6.6 KB view raw
run.log run-log decisive baseline run on #0: control marker + trigger panic (from serial boot.log) 2.1 KB view raw
panic.txt panic-signature crash signature: Fatal trap 12 at devclass_add_device+0xf6, fault VA 0xfffffffffffffff0 = subr_bus.c:1144 2.1 KB view raw
env.txt environment uname, kern.version, cc version, kernel sha256, kldstat 806 B view raw
VERDICT.md verdict full narrative: mechanism, reachability, exploit-chain analysis, fix validation before/after 8.9 KB ↓ raw
fix.diff suggested-fix git-apply-able (verified): reject unit<-1 in devclass_alloc_unit, unit<0 in device_set_unit 893 B view raw
fix_build.log fix-build-log full nativekernel build output of the single-fix #1 kernel (rc=0, no errors) 5.6 MB ↓ download
fix_run.log fix-run-log patched #1 kernel PoC run: trigger returns FAIL/NULL, no panic, guest up (3/3 deterministic); before/after contrast 2.0 KB view raw
README.md readme human-facing build/run/reachability summary 4.5 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
README.md readme human-facing build/run/reachability summary
↓ download raw

DF-0003 - devclass_alloc_unit() negative-unit heap out-of-bounds write

poc_negunit.c / poc_ctrl.c -- kld modules that drive the negative-unit heap OOB-write sink in devclass_alloc_unit() / devclass_add_device() (sys/kern/subr_bus.c).

The bug (memory-safety, CERTAIN -- reproduced on the audited kernel)

devclass_alloc_unit() only treats unit == -1 as a wildcard. Any other negative unit (e.g. -2) enters the "wired unit" branch but skips the existing-device check (unit >= 0, subr_bus.c:1073) and the table-extension check (unit >= dc->maxunit, subr_bus.c:1094), so it returns success with the negative unit. devclass_add_device() then executes

dc->devices[dev->unit] = dev;     // sys/kern/subr_bus.c:1144

an 8-byte pointer write at a NEGATIVE index into the kmalloc'd dc->devices array. device_set_unit() has a matching OOB read at subr_bus.c:2172.

Reproduction (VERIFIED)

A kld module that calls device_add_child(root_bus, "df3neg", -2):

  • Control (poc_ctrl.ko, unit=0) loads cleanly and prints DF0003-CTRL: unit=0 -> OK (child=0xfffff800...). Guest stays up.
  • Trigger (poc_negunit.ko, unit=-2) panics the kernel immediately:

Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffffffffffffff0 fault code = supervisor write data, page not present Stopped at devclass_add_device+0xf6: movq %r14,(%rdx,%rax,1)

addr2line -e /boot/kernel/kernel 0xffffffff8068a946 -> sys/kern/subr_bus.c:1144 (the exact sink line).

The fault address 0xfffffffffffffff0 = (device_t*)NULL + (-2) = 0 + (-2)*8, i.e. the negative-index write target. For the freshly-created devclass dc->devices == NULL, so the store hits an unmapped address and the kernel page-faults on the WRITE -- at the sink line.

The ONLY difference between the control and the trigger is the literal unit (0 vs -2), so the panic is caused specifically by the negative unit.

Reachability (the crux)

There is no unprivileged-userspace path to this sink in the default kernel. device_add_child / devclass_add_device / devclass_alloc_unit are internal newbus APIs; no syscall/ioctl invokes them. Auditing all 114 in-tree device_add_child* callers and the single device_set_unit caller (sio.c):

  • 84 pass the -1 wildcard (the legitimate, handled case);
  • the rest pass provably non-negative units -- PCI bus numbers (uint8_t secbus 0-255 in pci_pci.c:344, busno, bus), for(unit=0;;unit++) loop counters (ata-all.c, ata-pci.c), a monotonically-increasing freeunit/puc_find_free_unit() (starts >= 0 and only grows), and sio_pci_kludge_unit()'s unit that starts at 0 and only ++s.

So no in-tree driver computes a negative unit. The bug is therefore a real-but-latent memory-corruption defect: reachable today only by root (kldload, demonstrated here) or by any future/buggy driver that underflows a unit (signed subtraction, signed parse of a device-reported field, etc.). The fix is a one-line guard that converts the latent footgun into a hard EINVAL.

Build & run (on the DragonFly guest)

Building a kld requires the kernel source tree's headers; the guest ships without /usr/src, so setup_env.sh installs a headers-only subset first.

# one-time (root): install kernel headers + machine forwarders
sh setup_env.sh

# build both modules (as any user)
make SYSDIR=/usr/src/sys                      # -> poc_negunit.ko (trigger, -2)
make -f Makefile.ctrl SYSDIR=/usr/src/sys     # -> poc_ctrl.ko    (control, 0)

# run (root): control loads clean, trigger panics
sh run.sh

Files

file purpose
poc_negunit.c trigger source -- device_add_child(root_bus,"df3neg",-2)
poc_ctrl.c control source -- device_add_child(root_bus,"df3ctrl", 0)
Makefile / Makefile.ctrl build via bsd.kmod.mk (correct kernel CFLAGS)
setup_env.sh install kernel headers + machine forwarders on the guest
build.sh (legacy) hand-build path; superseded by the Makefiles
run.sh load control then trigger
build.log full successful build output
run.log decisive run: control marker + trigger panic (from boot.log)
panic.txt crash signature with addr2line proof
env.txt guest uname/cc/config/kldstat
VERDICT.md full narrative verdict
fix.diff git-apply-able fix (reject < -1 in devclass_alloc_unit, < 0 in device_set_unit)
manifest.json artifact catalog
VERDICT.md verdict full narrative: mechanism, reachability, exploit-chain analysis, fix validation before/after
↓ download raw

DF-0003 -- VERDICT

Verdict: REPRODUCED (panic/OOB write) + FIX VALIDATED (memory-corruption sink confirmed at runtime on the unpatched master-DEV #0 kernel; single-fix kernel #1 built from fix.diff closes the bug β€” the trigger returns gracefully with no panic, deterministically across 3 runs).

Summary

devclass_alloc_unit() (sys/kern/subr_bus.c:1064-1125) only treats unit == -1 as a wildcard. Any other negative unit (e.g. -2) slips past every guard and is returned unchanged; devclass_add_device() then performs dc->devices[dev->unit] = dev (subr_bus.c:1144) -- an 8-byte pointer write at a negative array index. A tiny kld module calling device_add_child(root_bus, "df3neg", -2) drives this sink directly and panics the kernel at exactly subr_bus.c:1144. The control (unit=0) loads cleanly.

Mechanism (trigger -> primitive -> effect), cited path:line

  1. Trigger: device_add_child(root_bus, "df3neg", -2) -> device_add_child_ordered -> make_device (subr_bus.c:1174). With name="df3neg", make_device calls devclass_find_internal(name, NULL, TRUE) which creates a fresh devclass with dc->devices = NULL; dc->maxunit = 0, then devclass_add_device(dc, dev).

  2. Sink reach: devclass_add_device calls devclass_alloc_unit(dc, &dev->unit) (subr_bus.c:1139).

  3. The missing guard (subr_bus.c:1064-1125): * int unit = *unitp; (:1067) -> unit = -2. * if (unit != -1) (:1072) -> TRUE (only -1 is the wildcard), enter the "wired unit" branch. * if (unit >= 0 && unit < dc->maxunit && dc->devices[unit] != NULL) (:1073) -> unit >= 0 is FALSE, the existing-device check is skipped. * if (unit >= dc->maxunit) (:1094) -> -2 >= 0 is FALSE, the table-extension block is skipped -- dc->devices is not grown and the negative unit is not caught. * *unitp = unit; return(0); (:1123-1124) -> returns success with dev->unit = -2.

  4. OOB write (subr_bus.c:1144): back in devclass_add_device, dc->devices[dev->unit] = dev -> dc->devices[-2] = dev. With dc->devices == NULL this stores the 8-byte dev pointer at address 0 + (-2)*sizeof(device_t) = 0xfffffffffffffff0 (unmapped) -> supervisor WRITE page fault.

  5. Effect: fatal trap 12, kernel panic, guest wedged in DDB. The faulting instruction is devclass_add_device+0xf6: movq %r14,(%rdx,%rax,1) (the indexed 8-byte store). addr2line resolves the IP to sys/kern/subr_bus.c:1144 -- the exact sink line.

device_set_unit() (subr_bus.c:2165-2184) has a matching OOB read at its bounds check if (unit < dc->maxunit && dc->devices[unit]) (:2172).

Reproduction evidence (unpatched #0 baseline)

Kernel: DragonFly 6.5-DEVELOPMENT #0: Thu Jul 2 06:02:54 UTC 2026 (with-src base = full /usr/src + warm obj + unpatched audit kernel).

  • Control (poc_ctrl.ko, unit=0) loaded cleanly: DF0003-CTRL: unit=0 -> OK (child=0xfffff801175bd1e0). Guest stays up.
  • Trigger (poc_negunit.ko, unit=-2) panicked immediately. Serial console: Fatal trap 12: page fault while in kernel mode fault virtual address = 0xfffffffffffffff0 fault code = supervisor write data, page not present instruction pointer = 0x8:0xffffffff8068b236 Stopped at devclass_add_device+0xf6: movq %r14,(%rdx,%rax,1) db> fault VA 0xfffffffffffffff0 = (device_t*)NULL + (-2) = 0 + (-2)*8, i.e. the negative-index write target. addr2line (prior verified run of identical source) -> sys/kern/subr_bus.c:1144. Guest went down (wedged in DDB).

The ONLY difference between control and trigger is the literal unit (0 vs -2), so the panic is caused specifically by the negative unit.

Reachability -- why this is "latent" but real

device_add_child / devclass_add_device / devclass_alloc_unit are internal newbus kernel APIs; no syscall or ioctl invokes them. The unit originates from bus-driver code (device_add_child*) or loader hints (root-controlled). Auditing the entire audited sys/ tree: 114 in-tree device_add_child(_ordered) callers and the single device_set_unit caller; 84 pass the literal -1 wildcard (the handled case), the rest pass provably non-negative units (PCI bus numbers, for(unit=0;;unit++) loop counters, monotonic freeunit/puc_find_free_unit(), sio_pci_kludge_unit()).

=> No in-tree path produces a unit < -1. The bug is a real-but-latent memory-corruption defect reachable today only by root (kldload, demonstrated) or by any future/buggy driver that underflows a unit. The fix is a one-line guard.

Exploit chain (memory-corruption class -- analysis)

  • Primitive: dc->devices[N] = dev with attacker-selectable negative index N (the unit), writing the 8-byte kernel-heap pointer dev at offset N*8 before dc->devices.
  • Triggering requirement: root (kldload) or a buggy driver. Not reachable from unprivileged userspace.
  • This PoC's effect: deterministic panic (the easy fresh-devclass case has dc->devices == NULL, so the store faults immediately -- a clean DoS, not controllable corruption).
  • Controllable-corruption variant (theoretical): target an existing devclass whose dc->devices is a real heap pointer, choose unit so dc->devices[N] lands on an adjacent slab object (function pointer / ucred * / refcount), and groom the heap. This needs root (kldload) and a kernel-ROP/ucred-forgery conversion, beyond what an unprivileged attacker can reach. Realistic impact ceiling: local DoS by root + latent corruption for a future driver bug. No uid0 chain pursued -- there is no unprivileged trigger to escalate from, and the root case is already game-over for the attacker.

Fix validation (Phase 8 -- single-fix kernel)

Fix (fix.diff, git-apply-able, supersedes the finding markdown's draft -- adds explicit EINVAL returns + comments): * devclass_alloc_unit entry: if (unit < -1) return (EINVAL); (sys/kern/subr_bus.c, now at line ~1073). * device_set_unit entry: if (unit < 0) return (EINVAL); (line ~2180).

Procedure: 1. vm.sh reset with-src -- clean source + #0 unpatched kernel. Re-confirmed baseline panic (above). 2. Applied fix.diff to /usr/src: patch -p1 -> both hunks succeeded (Hunk #1 succeeded at 1066, Hunk #2 succeeded at 2175). 3. Built single-fix kernel: make -j6 nativekernel KERNCONF=X86_64_GENERIC -> NK_DONE rc=0, no errors. Produced kernel.debug + kernel.stripped. 4. Installed: cp kernel.stripped /boot/kernel/kernel (the bare name the loader boots) + kernel.debug. sha256 5dc83dac...(#0) -> d7af6464...(#1). 5. Rebooted -> kern.version = DragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 12:26:06 UTC 2026 (the #1 + new build ts confirm the rebuilt kernel). 6. Re-ran the SAME PoC on #1.

Before/after contrast (the decisive evidence):

unpatched #0 patched #1
control (unit=0) OK, child=0xffff... OK, child=0xffff...
trigger (unit=-2) Fatal trap 12 page fault, devclass_add_device+0xf6, fault VA 0xfffffffffffffff0, guest DOWN in DDB, kprintf never reached kldload RC=0, kprintf reached: unit=-2 -> FAIL/NULL (child=0), guest UP, 0 panics
panic lines in boot.log 1 (the trap) 0

Repeatability on #1: 3/3 runs (unload + reload) all return FAIL/NULL, guest up, 0 panics -- the fix is deterministic. device_add_child now returns NULL because devclass_alloc_unit rejects unit<-1 with EINVAL, so make_device fails before the dc->devices[dev->unit] = dev sink.

Fix verdict: FIXED. The single-fix kernel closes the OOB-write sink cleanly (panic -> graceful EINVAL).

PoC changes (vs. the filed PoC)

  • Original poc_negunit.c included <sys/bus.h> (drags in platform/APIC headers) and used cc -DKERNEL (wrong macro -- the guard is _KERNEL). Forward-declared the newbus symbols and built via the standard bsd.kmod.mk. The hand-build (build.sh) is kept as a legacy path but the Makefile build is authoritative.
  • Added poc_ctrl.c + Makefile.ctrl -- a unit=0 control that loads cleanly, so the trigger panic is provably caused by the negative unit and not by module plumbing.
  • Added setup_env.sh, run.sh, env.txt, panic.txt, build.log, run.log, manifest.json, and fix.diff.
  • This run: refreshed run.log/panic.txt with the #0 baseline signature from today's build; regenerated fix.diff with clean git-format headers (the old one had a bogus index hash line) and verified git apply --check passes; added fix_build.log (full nativekernel output) and fix_run.log (patched-kernel before/after contrast).

Reject negative units at the entry of devclass_alloc_unit (< -1) and device_set_unit (< 0). See fix.diff (git-apply-able, supersedes the finding markdown's proposal -- adds explicit EINVAL returns and comments; validated by building + booting a single-fix kernel that eliminates the panic).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED the fix. fix.diff applies cleanly (patch -p1, both hunks succeeded) and compiles (nativekernel NK_DONE rc=0, no errors). On the unpatched with-src #0 baseline, kldload poc_negunit.ko panics (Fatal trap 12 at devclass_add_device+0xf6 / subr_bus.c:1144, fault VA 0xfffffffffffffff0 = negative-index write, guest DOWN in DDB). On the single-fix #1 kernel the SAME kldload does NOT panic -- devclass_alloc_unit now returns EINVAL for unit<-1 so make_device fails before the sink, the module's kprintf is reached ('unit=-2 -> FAIL/NULL (child=0)'), the guest stays up, and there are 0 panic lines in boot.log, deterministic across 3 unload/reload cycles. Clean before/after => fix closes the bug. The valid unit=0 control still loads cleanly on both kernels.

baseline #0 trigger: 'Fatal trap 12: page fault ... fault virtual address = 0xfffffffffffffff0 ... Stopped at devclass_add_device+0xf6: movq %r14,(%rdx,%rax,1) db>' (guest down). patched #1 trigger (3/3): 'DF0003: unit=-2 name="df3neg" -> FAIL/NULL (child=0)' kldload RC=0, guest up, 0 panic lines. build: NK_DONE rc=0. kernel swap sha256 5dc83dac..(#0) -> d7af6464..(#1), kern.version #0 Thu Jul 2 06:02:54 -> #1 Thu Jul 2 12:26:06.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #1: Thu Jul 2 12:26:06 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC (sha256 /boot/kernel/kernel = d7af64646ad16692c09ed90710cd3e010928e396cac6a657d408319bad48d197)

Confirmed kernel references

Detail

Exploit chain

Memory-corruption primitive: dc->devices[N]=dev with attacker-selectable negative index N (=the unit), writing the 8-byte kernel-heap device_t pointer at offset N8 before the kmalloc'd dc->devices array. Triggering requires root (kldload) or a buggy driver that underflows a unit -- NOT reachable from unprivileged userspace. This PoC's demonstrated effect is a deterministic panic (the easy fresh-devclass case has dc->devices==NULL so the store faults immediately at subr_bus.c:1144 = a clean DoS, not controllable corruption). A theoretical controllable-corruption variant (target an existing devclass with a real dc->devices heap pointer, choose unit so dc->devices[N] lands on an adjacent slab object's function-pointer/ucred/refcount, groom the heap) would need root + a kernel-ROP/ucred-forgery conversion. No uid0 chain was pursued: there is no unprivileged trigger to escalate from and the root case is already game-over for the attacker. Realistic impact ceiling = local DoS by root + latent heap corruption for a future driver bug.

Evidence (decisive lines)

BASELINE (#0, unpatched): control DF0003-CTRL: unit=0 -> OK (child=0xfffff801175bd1e0), guest up; trigger kldload poc_negunit.ko => 'Fatal trap 12: page fault while in kernel mode / fault virtual address = 0xfffffffffffffff0 / fault code = supervisor write data, page not present / Stopped at devclass_add_device+0xf6: movq %r14,(%rdx,%rax,1) / db>' guest DOWN. PATCHED (#1, fix.diff): control DF0003-CTRL: unit=0 -> OK (child=0xfffff801175bac60) guest up; trigger kldload RC=0, console 'DF0003: unit=-2 name="df3neg" -> FAIL/NULL (child=0)' guest UP, 0 panic lines in boot.log (3/3 deterministic runs). (Full untrimmed logs in findings/poc/DF-0003/{run.log,panic.txt,fix_run.log,fix_build.log}.)

PoC changes

Refreshed run.log/panic.txt with the today's #0 baseline panic signature (fault IP 0xffffffff8068b236 = devclass_add_device base+0xf6, same offset as the prior verified run). Regenerated fix.diff with clean git-format headers -- the prior version carried a bogus 'index d33631d0..0000000' line; verified the new one passes 'git apply --check' and 'patch -p1' (both hunks succeeded: Hunk #1 at 1066, Hunk #2 at 2175). Added fix_build.log (full 35389-line nativekernel output, NK_DONE rc=0, no errors), fix_run.log (patched-kernel before/after contrast, 3/3 deterministic), and rewrote VERDICT.md with the full reproduction + Phase-8 fix-validation narrative. Updated manifest.json with fix_kernel_uname and the new fix_* artifacts.

Verified recommended fix

In sys/kern/subr_bus.c, add 'if (unit < -1) return (EINVAL);' at the entry of devclass_alloc_unit (after 'int unit = *unitp;') and 'if (unit < 0) return (EINVAL);' at the entry of device_set_unit (before the 'unit < dc->maxunit' bounds check that would otherwise OOB-read). This rejects negative units at the two reachable entry points so dc->devices[dev->unit]=dev at :1144 can never be indexed negatively. Supersedes the finding markdown's proposal (adds explicit EINVAL returns + explanatory comments; same logical guards). Validated by building + booting a single-fix #1 kernel that converts the trigger from a page-fault panic into a graceful NULL/Fail. The full git-apply-able diff lives in findings/poc/DF-0003/fix.diff.

Verdict

REPRODUCED + FIX VALIDATED. devclass_alloc_unit() (sys/kern/subr_bus.c:1064-1125) only treats unit==-1 as wildcard; any other negative unit (e.g. -2) skips the unit>=0 existing-device check (:1073) and the unit>=dc->maxunit table-extension check (:1094) and returns success, so devclass_add_device() executes dc->devices[dev->unit]=dev (:1144) -- an 8-byte pointer write at a negative array index. A kld module calling device_add_child(root_bus,"df3neg",-2) drives this sink directly: on the unpatched #0 kernel it panics with Fatal trap 12, fault VA 0xfffffffffffffff0 (= (device_t)NULL + (-2) = 0+(-2)8, the negative-index write target), Stopped at devclass_add_device+0xf6 (addr2line-> subr_bus.c:1144), guest wedged in DDB. The control module (unit=0) loads cleanly and prints its marker, proving the panic is caused specifically by the negative unit. The sink is internal newbus API (no unprivileged trigger in-tree) so realistic impact is root-gated local DoS + latent heap-OOB-write for any future driver that underflows a unit. The single-fix kernel #1 built from fix.diff (reject unit<-1 in devclass_alloc_unit, unit<0 in device_set_unit) eliminates the panic: the same kldload now returns with the module's kprintf reached -- 'unit=-2 -> FAIL/NULL (child=0)' -- because devclass_alloc_unit returns EINVAL before the sink; guest stays up, 0 panics, deterministic across 3 runs.