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)
PoC verification
Evidence pack
findings/poc/DF-3005 Β· 9 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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).
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
subr_diskslice.c:646(DIOCSYNCSLICEINFO) and:610/:671(DIOCSDINFO / DIOCWDINFO) sendDISK_DISK_REPROBE/DISK_SLICE_REPROBEto the disk thread.subr_disk.c:574,584calldevfs_destroy_related_without_flag(dev, SI_REPROBE_TEST), which reachesdevfs_destroy_related_without_flag_workeron the devfs core thread (sys/vfs/devfs/devfs_core.c:1499).- For every related cdev lacking
SI_REPROBE_TEST, line 1511 callsdevfs_destroy_dev_worker(dev)(devfs_core.c:1440), which runsdevfs_unlink_dev+devfs_propagate_dev+ threerelease_dev(dev)calls (lines 1453-1456). vn0s0/vn0s1slice devices were created moments before bymake_dev(log: "make_dev called for vn0s1") and were never opened (devfs_spec_openappears only forvn0), so their reference count is exactly the three references make_dev takes (kern_conf.c:189-212:devfs_new_cdev-> sysref_activate + reference_dev, thendevfs_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.- Immediately after the worker returns, line 1512-1513 evaluates
dev->si_nameas an argument todevfs_debug(). C argument evaluation is unconditional β the load happens even withvfs.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 akvprintfformat 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
fixedPatched 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
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.
No comments yet.