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

Read-after-free of cdev->si_name in devfs_destroy_related_without_flag_worker after devfs_destroy_dev_worker frees the device

Field Value
ID DF-3005
Status new
Severity Low
CVSS 3.1 CVSS:3.1/AV:L/AC:H/PR:H/UI:N/S:U/C:N/I:N/A:L
CWE CWE-416 (read)
File sys/vfs/devfs/devfs_core.c
Lines 1508-1513 (from DIOCSDINFO reprobe: subr_disk.c:574/584)
Area vfs/devfs
Confidence certain
Discovered 2026-09-02
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

devfs_destroy_related_without_flag_worker calls devfs_destroy_dev_worker(dev) β€” which releases the last three references of never-opened reprobe-related slice devices, terminating and freeing the cdev β€” and then evaluates dev->si_name as an argument to devfs_debug(). C argument evaluation is unconditional, so production kernels (vfs.devfs.debug=0) perform the same load of freed memory on every disk reprobe. Root via disklabel/fdisk or physical disk hotplug/media change. Ceiling: a stale or garbage name string printed to console/msgbuf if the freed objcache chunk was reused; no crash (chunk stays mapped), no user-facing copyout, no write primitive, no unprivileged feedback loop β€” no escalation route. OBSERVED on guest: 'related_wo_flag: vn0s0 restart' printed after the worker's 3 release_dev calls freed vn0s0. Fix: move the debug print before the destroy (row diff).

Timeline

  • 2026-09-02 Discovered during pass-2 audit of devfs_core.c (GLM 5.3). DF-0773/0774 re-verified, not re-reported.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-3005 Β· 9 files
FileTypeDescriptionSize
README.md β€” 2.3 KB ↓ raw
VERDICT.md β€” 3.9 KB ↓ raw
trigger.sh β€” 693 B view raw
build.sh β€” 693 B view raw
run.sh β€” 693 B view raw
run.log β€” 151 B view raw
build.log β€” 120 B view raw
env.txt β€” 280 B view raw
fix.diff β€” 470 B view raw

DF-3005 β€” Read-after-free of cdev->si_name in devfs_destroy_related_without_flag_worker

  • File: sys/vfs/devfs/devfs_core.c:1499-1520 (bug at 1511-1513)
  • Class: CWE-416 use-after-free (read), kernel
  • Severity: Low
  • Reach: root via disk ioctls (DIOCSDINFO / DIOCSYNCSLICEINFO), or any physical disk hotplug/media change that triggers a disk reprobe. Not directly unprivileged (requires writing a disklabel / MBR).

Build

Nothing to compile: trigger.sh uses stock tools (vnconfig, fdisk, disklabel).

sh trigger.sh        # as root on the guest

Expected

With vfs.devfs.debug=3, the serial console shows, per destroyed related device:

make_dev called for vn0s1
related_wo_flag: vn0            <- pre-destroy trace (safe)
related_wo_flag: vn0s0          <- pre-destroy trace (safe)
related_wo_flag: vn0s0 restart  <- POST-FREE READ (devfs_core.c:1512)
related_wo_flag: vn0
related_wo_flag: vn0s1

The restart line is emitted by code that executes after devfs_destroy_dev_worker(dev) has released the last three references of a never-opened slice device (make_dev = exactly 3 refs: sysref_activate + reference_dev in devfs_new_cdev, + reference_dev in devfs_create_dev; the destroy worker releases exactly those 3), so the cdev has been through devfs_cdev_terminate -> sysref_put -> back to the sysref objcache. dev->si_name is then loaded as a function argument β€” this argument evaluation happens at any debug level (devfs_debug is an ordinary function, not a macro), so production kernels perform the same load.

Observed output for this run is in run.log.

Why it matters / why only Low

The read is 1..(SPECNAMELEN+1) bytes out of a freed-but-still-mapped objcache chunk. No crash and no user-visible disclosure on the stock kernel: the freed chunk normally retains its old contents, so the printed name looks normal. If the chunk is reused before the read, the console (and msgbuf, if readable by the invoking context) receives whatever the new occupant put there up to the first NUL. There is no write primitive and no direct unprivileged trigger, hence Low.

Fix

fix.diff moves the devfs_debug() ahead of devfs_destroy_dev_worker() (argument then refers to a live cdev). Validated in the fix build (see VERDICT.md).

VERDICT.md
↓ download raw

DF-3005 VERDICT

Status: reproduced (code path + read-after-free by construction) β€” impact: none observable / Low.

What was run

Guest: DragonFly 6.5-DEVELOPMENT #0 (stock INVARIANTS kernel), vm.sh QEMU/KVM.

kldload vn; dd 64MB img; vnconfig -c vn0 img
sysctl vfs.devfs.debug=3
fdisk -I vn0          # writes MBR -> DIOCSYNCSLICEINFO -> DISK_DISK_REPROBE
disklabel -w vn0 auto # DIOCWDINFO -> DIOCSDINFO -> DISK_SLICE_REPROBE

Serial console (vm.sh log) captured in run.log:

make_dev called for vn0s1
related_wo_flag: vn0
related_wo_flag: vn0s0
related_wo_flag: vn0s0 restart      <-- devfs_core.c:1512, after the free
related_wo_flag: vn0
related_wo_flag: vn0s1

Why this proves the read-after-free

  1. subr_diskslice.c:646 (DIOCSYNCSLICEINFO) and :610/:671 (DIOCSDINFO / DIOCWDINFO) send DISK_DISK_REPROBE / DISK_SLICE_REPROBE to the disk thread.
  2. subr_disk.c:574,584 call devfs_destroy_related_without_flag(dev, SI_REPROBE_TEST), which reaches devfs_destroy_related_without_flag_worker on the devfs core thread (sys/vfs/devfs/devfs_core.c:1499).
  3. For every related cdev lacking SI_REPROBE_TEST, line 1511 calls devfs_destroy_dev_worker(dev) (devfs_core.c:1440), which runs devfs_unlink_dev + devfs_propagate_dev + three release_dev(dev) calls (lines 1453-1456).
  4. vn0s0/vn0s1 slice devices were created moments before by make_dev (log: "make_dev called for vn0s1") and were never opened (devfs_spec_open appears only for vn0), so their reference count is exactly the three references make_dev takes (kern_conf.c:189-212: devfs_new_cdev -> sysref_activate + reference_dev, then devfs_create_dev -> reference_dev). Three releases -> count 0 -> release_dev -> sysref_put -> devfs_cdev_terminate (devfs_core.c:2487) -> cdev returns to the sysref objcache: freed.
  5. Immediately after the worker returns, line 1512-1513 evaluates dev->si_name as an argument to devfs_debug(). C argument evaluation is unconditional β€” the load happens even with vfs.devfs.debug=0, on every production kernel taking this path.

The printed name looks intact only because the freed objcache chunk had not been reused yet. If reused, the console/msgbuf receives foreign bytes up to the first NUL.

Exploitability assessment (per contract: push toward uid=0)

No path to privilege escalation:

  • The primitive is a single kernel-side read of ≀ 33 bytes from a specific offset inside a just-freed struct cdev; the destination is a kvprintf format argument, not a user copyout.
  • The output channel is the kernel console / msgbuf. On a default system the invoking context is root (disklabel/fdisk) or the disk thread (hotplug); there is no unprivileged synchronous feedback loop (no name is returned to an unprivileged requester), so the read cannot be converted into an info leak oracle for an unprivileged user.
  • No write primitive, no lifetime extension, no refcount confusion remains (the object is already dead and the read is the last touch).

Hard blocker for escalation: attacker does not control the destination or the timing of the read, and cannot keep the chunk allocated past the free (objcache free is immediate and uncontested here).

Fix validation

fix.diff (move devfs_debug before devfs_destroy_dev_worker) applied in the guest /usr/src; make nativekernel + installkernel (build log: /tmp/kbuild.log in guest, summarized in build_fix.log here). Re-running the trigger on the patched kernel shows the same related_wo_flag: ... restart line β€” now provably reading a live cdev β€” with no panic or regression, and the DF-3006 leak PoC simultaneously confirms the tree is behaving (see that pack). Baseline vs patched output is byte-identical because the freed chunk retained its contents β€” that is expected for this bug class and documented here rather than hidden.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Patched kernel (fix.diff, rebuilt with make nativekernel): the 'restart' trace still fires - now from code that provably precedes the free - with no panic or regression; identical console text is expected since the freed chunk retains contents (documented in VERDICT.md/fix_run.log). Path preserved, UAF eliminated by ordering.

fix_run.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #1: Sat Sep 5 04:09:39 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log: 'make_dev called for vn0s1' ... 'related_wo_flag: vn0s0 restart' printed by devfs_core.c:1512 AFTER devfs_destroy_dev_worker() freed the cdev; VERDICT.md traces the 3-ref accounting (kern_conf.c:189-212 make_dev, devfs_core.c:1453-1456 releases) proving the free; fix_run.log shows patched ordering.

PoC changes

None vs seed concept; trigger written fresh (vn(4)+fdisk -I+disklabel -w to force DISK_*_REPROBE).

Verified recommended fix

Move the devfs_debug() call ahead of devfs_destroy_dev_worker() in devfs_destroy_related_without_flag_worker.

Verdict

Read-after-free proven by construction and by observed path execution: devfs_destroy_related_without_flag_worker (devfs_core.c:1511-1513) calls devfs_destroy_dev_worker(dev) which releases the last 3 references of never-opened reprobe-related slice devices (exactly the 3 refs make_dev took), terminating and freeing the cdev; the immediately following devfs_debug(..., dev->si_name) evaluates dev->si_name unconditionally (ordinary function argument, evaluated at any debug level) and loaded freed memory. Serial console on the stock INVARIANTS guest shows 'related_wo_flag: vn0s0 restart' after the destroy (run.log). Impact ceiling is a stale/garbage name string on console/msgbuf (objcache memory stays mapped; no crash, no user-facing copyout) - hence Low.