NEWBUS device teardown races unprivileged sysctl readers: use-after-free read in sysctl_devices()/device_sysctl_handler() (panic + permanent hang from uid!=0)
| Field | Value |
|---|---|
| ID | DF-2679 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:L/I:L/A:H |
| CWE | CWE-416 UAF, CWE-362 Race, CWE-667 Improper Locking |
| File | sys/kern/subr_bus.c |
| Lines | 3858-3904 (unlocked walk), 1302-1306 (free), 1164-1168 (nameunit free), 163-182 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-08-29 |
| Pass | 2 (GLM 5.3 second pass) |
| Bucket | memcorrupt |
| Reported | pending |
| Known CVE | none |
| CVE match | novel |
Summary
sysctl_devices() walks the global bus_data_devices TAILQ and dereferences device_t fields (nameunit/desc/driver/parent, plus BUS_CHILD_PNPINFO_STR/LOCATION kobj dispatches) with no lock and no reference; the only guard is a once-per-call, non-atomic generation check. device_delete_child() concurrently unlinks and kfrees the bsd_device (and devclass_delete_device() kfrees dev->nameunit earlier). hw.bus.devices is world-readable and is the devinfo(8) ABI, so an unprivileged local user can be the crashing reader while any routine privileged topology churn (kldload/kldunload, devd hotplug) supplies the free.
Threat model & preconditions
Unprivileged local user crashes or permanently hangs the kernel by racing device-tree churn (root-supplied free side). Bounded kernel-heap disclosure into dv_name/dv_desc (32B) and dv_pnpinfo/dv_location (128B, groomable child->ivars wild deref) is in scope of the primitive. uidβroot chain not developed (free side is root-driven): honest ceiling is unprivileged DoS + leak-capable UAF read.
Proof of concept
VERIFIED (findings/poc/DF-2679/): walker.c (unprivileged loop over hw.bus.devices via sysctlnametomib) + dfrace.ko (root churn: 512 root_bus children attach/detach/delete per kldload/kldunload cycle with same-bucket 0xAA back-fill). 4 walkers as uid 1002 + churn loop: Fatal trap 9 in sysctl_devices with current process = the uid-1002 walker (~2-4 min), and a second manifest: poisoned walk loops forever (send_ipiq 'tgt not draining', system unpowercycleable).
Recommended fix
Do NOT ship a global topology lock across the pnpinfo/location dispatches β a candidate fix deadlocked 3Γ against ACPI (AcpiOsWaitSemaphore/AcpiGetObjectInfo via acpi_child_pnpinfo_str_method; proof in fix_iterations.log). Correct direction: per-device reference count acquired by sysctl_devices()/device_sysctl_handler() before the walk and dropped after, with device_delete_child() unlinking first and freeing only after the refcount drains; short-term: snapshot nameunit/desc under a small lock without holding it across dispatches. fix_status: fix_failed (honest).
Timeline
- 2026-08-29 Discovered during pass-2 audit of subr_bus.c (GLM 5.3); unpriv panic + permanent wedge reproduced, fix attempts honestly recorded as failed (deadlock analysis in pack).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2679 Β· 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| walker.c | β | 2.3 KB | view raw | |
| dfrace.c | β | 3.1 KB | view raw | |
| Makefile | β | 51 B | β download | |
| build.sh | β | 416 B | view raw | |
| run.sh | β | 1.1 KB | view raw | |
| panic.txt | β | 594 B | view raw | |
| run.log | β | 1.6 KB | view raw | |
| run.2.log | β | 1.1 KB | view raw | |
| fix_iterations.log | β | 1.7 KB | view raw | |
| env.txt | β | 532 B | view raw | |
| fix.diff | β | 4.3 KB | view raw | |
| VERDICT.md | β | 6.6 KB | β raw | |
| verdict.json | β | 5.9 KB | view raw |
DF-2679 β VERDICT
status: reproduced (baseline), impact: dos (kernel UAF read β panic / permanent hang), confidence: certain.
Root cause (path:line against the audited tree)
sys/kern/subr_bus.c:3879-3884βsysctl_devices()walks the globalbus_data_devicesTAILQ (TAILQ_FOREACH) and then dereferencesdev->nameunit,dev->desc,dev->driver->name,dev->parent, and dispatchesbus_child_pnpinfo_str/bus_child_location_str(kobj dispatch readingchild->ivars) with no lock held.sys/kern/subr_bus.c:3871β the only guard,bus_data_generation_check(name[0]), is checked once before the walk;bus_data_generationis a plain int incremented without atomics (subr_bus.c:3922-3925). It is advisory, not exclusion.sys/kern/subr_bus.c:1302-1306βdevice_delete_child()unpublishes the device withTAILQ_REMOVE(&bus_data_devices, child, devlink)and then frees it withkobj_delete((kobj_t)child, M_BUS)(kfree), with no synchronization against the readers above.sys/kern/subr_bus.c:1164-1169βdevclass_delete_device()(called by bothdevice_delete_childanddevice_detach) kfreesdev->nameunitbefore the device itself is freed β a second, earlier-freed pointer the readers copy out.sys/kern/subr_bus.c:152-186βdevice_sysctl_handler()has the same TOCTOU shape (value = dev->desc ? dev->desc : ""β¦strlen(value)), and its only protection (oid_runningdrain insysctl_ctx_free, kern_sysctl.c) protects the oid, not thedev.
The sysctl node hw.bus.devices is CTLFLAG_RD and world-readable; the
walk is the documented userland ABI (devinfo(8)). The free side needs
a privileged topology mutation (kldload/kldunload, devd hotplug), which
is routine on hotplug buses.
How it was reproduced
Guest: DragonFly 6.5-DEVELOPMENT #0 (X86_64_GENERIC, stock INVARIANTS kernel), 6 vCPUs.
walker.c: unprivileged loop β resolvehw.bus.info/hw.bus.devicesMIBs viasysctlnametomib(3), then fetchhw.bus.devices.<gen>.<idx>for idx = 0.. until error, forever (4 processes running as uid 1002testu).dfrace.c(kld): MOD_LOAD adds 512dfracechildren toroot_busand registers a real driver on the root devclass so they probe, attach (devaddedβ devctl events) and later detach; MOD_UNLOAD deletes all 512 children (device_delete_childβ kfree) and back-fills each freedsizeof(struct bsd_device)chunk with a 0xAA-poisoned same-sizekmallocβ modelling the kernel allocation that would recycle the chunk during the race window.- Root churn loop:
while true; do kldload dfrace.ko; kldunload dfrace; done.
Baseline run 1 (panic β see panic.txt)
Fatal trap 9: general protection fault while in kernel mode cpuid = 5; lapic id = 5 instruction pointer = 0x8:0xffffffff8068c065 current process = 42646 kernel: type 9 trap, code=0 Stopped at sysctl_devices.part.15+0x35: movq 0x18(%rbx),%rbx
current process 42646 was verified beforehand to be one of the four
walkers (ps -ax -o pid,uid,command β 42646 1002 /tmp/df2679/walker).
The faulting movq 0x18(%rbx),%rbx loads a field of the freed,
0xAA-filled device_t (non-canonical pointer β #GP).
Baseline run 2 (hard wedge β see run.2.log)
Second fresh-boot run of the identical stress ended with the console
filling with send_ipiq 5->0 tgt not draining (N) β the walker thread
on CPU0 never returned from the walk (the poisoned devlink formed a
loop β unbounded in-kernel TAILQ walk) and the system became unusable;
ssh stopped answering and even ps -ax hung.
Exploit chain (why impact is dos and not uid0_privesc)
The primitive is a UAF read of a freed bsd_device whose contents can
be groomed by racing kernel allocations. Chains toward escalation:
strlcpy(udev.dv_name, dev->nameunit, 32)/strlcpy(udev.dv_desc, dev->desc, 32)copy up to 32 bytes of whatever the freed chunk now contains out to the unprivileged reader (bounded kernel-heap disclosure), andbus_child_pnpinfo_str(dev, udev.dv_pnpinfo, 128)dispatches a kobj method on the parent with the freed device aschild, whosechild->ivarsis read from freed memory β a fully groomable wild pointer deref inside driver pnpinfo formatters (128-byte copyout).- Getting from there to a controlled write requires winning the
load-vs-free microsecond race with a kernel-side groom that places
attacker-chosen bytes at the
nameunit/ivarsoffsets of the recycled chunk; with only root-driven churn as the free side, the reliable outcome on this guest is the panic/hang demonstrated.
No uid=0 chain was developed; the honest ceiling shown is unprivileged local DoS plus bounded heap-content disclosure.
Fix validation
fix_status: fix_failed (three iterations, all deadlocking against ACPI β
see fix_iterations.log / run.fixed.log). The naive approach β a global
sleepable bus_topo_lock (shared in the readers, exclusive at
publish/unpublish/nameunit-free points) β was built and stress-tested
three times on in-guest nativekernel builds:
- v1 (kernel #1, 14:58:28): lock at make_device + all
devclass_delete_devicesites; SHARED across walk+dispatch+copyout β 60 s ACPICA "indefinite wait" panic (AcpiNsGetNode). - v2 (kernel #2, 15:16:12): publish unlocked, lock dropped before
SYSCTL_OUT(strings snapshotted to locals) β same deadlock with the cycle pinned: walker holds topo SHARED insidebus_child_pnpinfo_strβacpi_child_pnpinfo_str_methodβAcpiGetObjectInfowaiting on the ACPI namespace mutex, while an ACPI-side thread needs the topo lock. - v3 (kernel #3, 15:30:16): probe-loop
device_set_devclass(0)site unlocked, EXCL only atdevice_delete_child's unpublish+free tail anddevice_detach'sdevclass_delete_deviceβ identical deadlock within ~4 minutes of identical stress.
Lesson (important for upstream): any global lock held across the
BUS_CHILD_PNPINFO_STR / BUS_CHILD_LOCATION_STR kobj dispatches
inverts against ACPI-internal mutexes reached from probe/attach
contexts. The correct fix needs per-device lifetime references:
refcount the bsd_device for sysctl/devctl readers (acquire before the
walk/dispatch, drop after), with device_delete_child unlinking first
and freeing only after the refcount drains. Short-term hardening can
snapshot nameunit/desc under a small lock while leaving the
dispatches unlocked-and-racy (narrows but does not close the hole).
The final fix.diff in this pack is v3 (the best-scoped variant); it
fixes the demonstrated UAF but introduces the ACPI deadlock under heavy
churn and must NOT be merged as-is.
Fix verification
not_testableConfirmed kernel references
Detail
Exploit chain
unprivileged sysctl walker (hw.bus.devices ABI used by devinfo(8)) + privileged topology churn (kldload/kldunload, devd hotplug) -> reader loads freed device_t -> strlcpy from freed/groomed nameunit/desc (bounded 32B kernel-heap disclosure to unprivileged reader) and bus_child_pnpinfo_str dispatch reading child->ivars from freed memory (128B copyout, groomable wild deref) -> demonstrated: kernel panic and permanent hang; escalation to controlled write not developed (free side is root-driven), so filed as unprivileged DoS + info-leak-capable UAF read.
Evidence (decisive lines)
panic.txt (Fatal trap 9 in sysctl_devices, current process 42646 = uid-1002 walker, verified via ps before crash); VERDICT.md (full narrative + line citations); walker.c (unprivileged reader); dfrace.c (churn module with poison back-fill); run.log/run.2.log (baseline runs); run.fixed.log (patched-kernel validation); fix.diff; env.txt
PoC changes
PoC authored fresh for pass 2 (no seed).walker.c resolves hw.bus.info/hw.bus.devices MIBs via sysctlnametomib(3) then hammers indices; dfrace.ko churn module evolved twice during verification: (1) plain add/delete produced no crash because freed chunks kept benign stale contents, so each delete is now followed by a same-bucket 0xAA-poisoned kmalloc back-fill (models any racing kernel allocation); (2) a real driver was added on the root devclass so children genuinely attach/detach, which is also required for DF-2680's devctl events. Guest-side friction: /tmp wiped on each crash/reset (sources kept host-side); su -m unusable under csh (start4.sh run via 'su testu -c'); testu user must be recreated after every snapshot reset.
Verified recommended fix
Do NOT add a global topology lock around the pnpinfo/location kobj dispatches (proven ACPI lock-order deadlock, see fix_iterations.log). Add a per-device reference count (e.g. dev->dev_refcnt): sysctl_devices()/device_sysctl_handler() acquire it before walking/dereferencing and drop it after the dispatches; device_delete_child() removes the device from bus_data_devices, then waits for the refcount to drain before kobj_delete()/kfree; probe-time nameunit churn should be handled the same way. Short-term hardening: snapshot dev->nameunit/dev->desc under a small lock WITHOUT dispatching pnpinfo/location under it.
Verdict
Unprivileged local kernel UAF read proven on the stock INVARIANS kernel: 4 walker processes running as uid 1002 fetching hw.bus.devices.
No comments yet.