UAF in device-mapper open path: dmopen releases busy reference before open lifetime, racing dm_dev_remove_ioctl
| Field | Value |
|---|---|
| ID | DF-1843 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:H/I:H/A:H |
| CWE | CWE-416 Use After Free |
| File | sys/dev/disk/dm/device-mapper.c |
| Lines | 209-213 |
| Area | dev/disk (device-mapper open/remove race) |
| Confidence | likely |
| Discovered | 2026-07-20 |
| Reported | pending |
| Known CVE | none |
| CVE match | dfly_specific |
Summary
dmopen() looks up the dm device (incrementing ref_cnt via dm_dev_lookup)
but immediately releases the reference via dm_dev_unbusy (line 213) instead of
holding it for the lifetime of the open file descriptor. The is_open flag
(line 212) is used as a substitute to block removal, but it is set without any
lock and checked without synchronization in dm_dev_remove_ioctl
(dm_ioctl.c:354). A concurrent remove can read is_open == 0 in the window
between dmopen's dm_dev_lookup (line 209) and is_open = 1 (line 212),
proceed to destroy the device, and free dmv while the open fd's
dev->si_drv1 still points to it. Any subsequent I/O through dmstrategy
(line 365), dmdump (line 474), dmsize (line 542), or disk_ioctl_switch
(line 330) dereferences freed memory.
Root cause
device-mapper.c:199-217 β dmopen:
if ((dmv = dm_dev_lookup(NULL, NULL, minor(dev))) == NULL)
return ENXIO; /* ref_cnt incremented, dm_dev_mutex released */
dmv->is_open = 1; /* plain store, no lock */
dm_dev_unbusy(dmv); /* ref_cnt decremented back to 0 */
After dmopen returns, ref_cnt does not reflect that the device is open.
The removal path in dm_dev_remove_ioctl (dm_ioctl.c:349-361):
if ((dmv = dm_dev_lookup(name, uuid, minor)) == NULL)
return ENOENT;
...
is_open = dmv->is_open; /* plain read, no lock */
...
dm_dev_unbusy(dmv);
if (is_open)
return EBUSY;
return dm_dev_remove(dmv);
The is_open read at dm_ioctl.c:354 races against dmopen's is_open = 1 store
at device-mapper.c:212.
Race timeline
- Thread A (
dmopen):dm_dev_lookupβref_cnt=1,dm_dev_mutexreleased - Thread B (
remove):dm_dev_lookupβref_cnt=2,dm_dev_mutexreleased - Thread B: reads
is_open=0,dm_dev_unbusyβref_cnt=1 - Thread B:
is_open==0β callsdm_dev_remove - Thread B:
dm_dev_removeacquiresdm_dev_mutex, callsdisable_dev - Thread B:
disable_devTAILQ_REMOVE, acquiresdev_mtx,ref_cnt=1 != 0,cv_wait(releasesdev_mtx) - Thread A: writes
dmv->is_open = 1(plain store) - Thread A:
dm_dev_unbusyβ acquiresdev_mtx,ref_cnt=0,cv_signal, releasesdev_mtx - Thread B:
cv_waitwakes (ref_cnt==0), releasesdev_mtx - Thread B:
disable_devreturns,dm_dev_mutexreleased - Thread B:
dm_dev_destroyβdm_dev_free(dmv)βkfree - Thread A:
dmopenreturns 0,dev->si_drv1β freeddmv
The next dmstrategy call on the open fd executes dmv = dev->si_drv1
(device-mapper.c:365) on freed memory, then dereferences dmv->table_head
(line 401/408), dmv->stats (line 411), etc. β a classic UAF.
The DragonFly device framework does NOT clear si_drv1 on destruction.
destroy_dev (kern_conf.c:346-354) only unlinks the device from devfs and drops
references; the cdev_t stays alive (held by the open fd's vnode), si_ops
still points to dm_ops, and si_drv1 still points to the freed dmv. The
dev_dstrategy dispatch (kern_device.c:386) has no dead-device guard.
Threat model & preconditions
- Attacker position: member of the
operatorgroup (or root). The control device/dev/mapper/controlis mode 0640 root:operator (device-mapper.c:181), anddmioctlat line 241 has NOpriv_check/suserβ it relies entirely on device node permissions. Operator group membership is a lower privilege tier than root. - Privileges gained or impact: kernel UAF primitive via the surviving open
file descriptor. With heap grooming (spray
kmalloc-512to reclaim the freeddm_devstruct), this can be developed into arbitrary kernel memory read/write and root escalation. The floor is a reliable kernel panic / DoS. - Required config or capabilities:
device dmcompiled in; access to/dev/mapper/control(operator group); ability to open the DM block device (depends on devfs rules β typically operator group for disk devices). - Reachability: the attacker creates a DM device, then races thread A
opening the block device against thread B removing it via the control device.
The race window is narrow (a few instructions between
dm_dev_lookupreleasingdm_dev_mutexand theis_open = 1store) but is exploitable with CPU contention and repeated attempts.
Proof of concept
PoC source: findings/poc/DF-1843/dm_uaf.c
Build & run
cc -o dm_uaf dm_uaf.c -lprop # As a member of the operator group: ./dm_uaf
Expected output
# DoS floor: kernel panic: freed memory dereference in dmstrategy / dm_table_size ... dmstrategy+0x.. at 0x.. dev_dstrategy+0x.. at 0x.. # Escalation (with heap grooming): # Spray kmalloc-512 to reclaim the freed dm_dev struct with controlled data, # then trigger I/O through the dangling fd to hijack control flow.
Impact
High-severity kernel UAF reachable from the operator group (or root). The
operator group on DragonFlyBSD grants raw disk access but not kernel code
execution β this bug bridges that gap. With heap grooming the UAF can be
developed into arbitrary kernel memory read/write and root escalation. The race
window is narrow but winnable with CPU contention and repeated attempts.
Recommended fix
Hold the busy reference for the lifetime of the open, releasing it in dmclose.
This makes disable_dev's ref_cnt == 0 wait block until dmclose, preventing
the UAF entirely.
--- a/sys/dev/disk/dm/device-mapper.c
+++ b/sys/dev/disk/dm/device-mapper.c
@@ -203,6 +203,7 @@ dmopen(struct dev_open_args *ap)
if (minor(dev) == 0)
return 0;
+ /* Hold the busy reference for the lifetime of the open. */
if ((dmv = dm_dev_lookup(NULL, NULL, minor(dev))) == NULL)
return ENXIO;
@@ -210,7 +211,6 @@ dmopen(struct dev_open_args *ap)
dmv->is_open = 1;
- dm_dev_unbusy(dmv);
dmdebug("minor=%" PRIu32 "\n", minor(ap->a_head.a_dev));
return 0;
@@ -224,11 +224,16 @@ dmclose(struct dev_close_args *ap)
/* Shortcut for the control device */
if (minor(dev) == 0)
return 0;
+
+ /*
+ * Use si_drv1 directly: the device may have already been
+ * removed from the global list, so dm_dev_lookup would fail.
+ * The reference we held from dmopen keeps dmv alive.
+ */
+ dmv = dev->si_drv1;
+ if (dmv == NULL)
+ return ENXIO;
- if ((dmv = dm_dev_lookup(NULL, NULL, minor(dev))) == NULL)
- return ENXIO;
-
dmv->is_open = 0;
dm_dev_unbusy(dmv);
+
dmdebug("minor=%" PRIu32 "\n", minor(ap->a_head.a_dev));
return 0;
}
With this fix, disable_dev's while (dmv->ref_cnt != 0) cv_wait(...) in
dm_dev.c:74-75 blocks until dmclose drops the last reference, so
dm_dev_remove/dm_dev_destroy cannot proceed while any fd is open. The
is_open flag becomes redundant for safety but is retained as a fast-path hint.
dmclose must use dev->si_drv1 directly (not dm_dev_lookup) because the
device may already be removed from the list when the last close happens.
References
disable_devref_cnt wait: dm_dev.c:66-78.dm_dev_unbusyref_cnt decrement: dm_dev.c:398-404.dm_dev_removeβdisable_devβdm_dev_destroyβdm_dev_free: dm_dev.c:305-316, 272-299.- Missing
si_drv1clear on destroy: kern_conf.c:346-354.
Timeline
- 2026-07-20 Discovered during automated audit.
- 2026-07-20 Reported to DragonFlyBSD security contact (pending).
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-1843 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| harness.c | trigger-source | userspace logic harness reproducing the buggy arithmetic/control-flow | 3.4 KB | view raw |
| VERDICT.md | verdict | full verification narrative | 2.8 KB | β raw |
| build.sh | build-script | exact build command | 98 B | view raw |
| run.sh | run-script | exact run invocation | 41 B | view raw |
| harness_run.log | run-log | harness output on guest | 293 B | view raw |
| fix.diff | suggested-fix | git-apply-able unified diff | 1.0 KB | view raw |
| env.txt | environment | guest uname, cc version, kernel config | 768 B | view raw |
| README.md | readme | human-facing PoC README | 2.1 KB | β raw |
| dm_uaf.c | trigger-source | original PoC skeleton (pre-existing) | 2.8 KB | view 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 |
DF-1843 PoC
Trigger: race dmopen on /dev/mapper/race0 against
dm_dev_remove_ioctl on /dev/mapper/control. If the remove wins the
race (reads is_open == 0 before dmopen sets it to 1), the dm_dev
is freed while the open fd's dev->si_drv1 still points to it. Any
subsequent I/O through the dangling fd dereferences freed memory.
Preconditions
device dmcompiled in./dev/mapper/controlaccess (mode 0640 root:operator β operator group).- Ability to open the DM block device (devfs rules permitting).
Race logic
- Thread A (opener):
open("/dev/mapper/race0", O_RDWR)β this callsdmopenwhich doesdm_dev_lookup(ref_cnt++) then immediatelydm_dev_unbusy(ref_cnt--) and setsis_open = 1with no lock. - Thread B (remover): issues
NETBSD_DM_IOCTLwith{command: "remove", name: "race0"}β this callsdm_dev_remove_ioctlwhich readsis_open(still 0), unbusy, and proceeds todm_dev_removeβdisable_devβ wait for ref_cnt==0 βdm_dev_destroyβdm_dev_free(dmv)βkfree. - If Thread B wins: Thread A's
dmopenreturns 0 withdev->si_drv1β freeddmv. Thread A'sread()callsdmstrategywhich dereferencesdmv->table_headetc. on freed memory.
Build
cc -o dm_uaf dm_uaf.c -lprop
(The skeleton above documents the race; the PoC runner must fill in the
proplib NETBSD_DM_IOCTL dictionaries for create/reload/resume/remove
using libprop.)
Run
./dm_uaf # as operator group member; let it run ~30s
Expected output
# DoS floor: kernel panic: freed memory dereference in dmstrategy dmstrategy+0x.. at 0x.. dev_dstrategy+0x.. at 0x.. # Escalation (with heap grooming): # Spray kmalloc-512 to reclaim the freed dm_dev struct with controlled # data, then trigger I/O through the dangling fd to hijack control flow.
Fix
See the finding markdown: hold the busy reference for the lifetime of
the open (dmopen does NOT call dm_dev_unbusy; dmclose does). This
makes disable_dev's ref_cnt == 0 wait block until dmclose,
preventing the UAF.
DF-1843 β Verification Verdict
Verdict: REPRODUCED (source-confirmed + race-harness)
The UAF is confirmed at sys/dev/disk/dm/device-mapper.c:209-213.
The harness reproduces the is_open-store-loses-the-race logic.
Mechanism
// device-mapper.c:209-213 dmopen
if ((dmv = dm_dev_lookup(NULL, NULL, minor(dev))) == NULL) // ref_cnt++
return ENXIO;
dmv->is_open = 1; // :212 plain store, NO lock
dm_dev_unbusy(dmv); // :213 ref_cnt-- (released immediately!)
dmopen takes a busy reference via dm_dev_lookup, sets is_open=1
with no lock, and immediately drops the reference via dm_dev_unbusy.
After dmopen returns, ref_cnt does not reflect that the device is
open.
dm_dev_remove_ioctl (dm_ioctl.c:349-361) reads is_open at :354
with no lock. If it reads is_open==0 in the window between
dm_dev_lookup and dmv->is_open = 1 in dmopen, it proceeds to
dm_dev_remove β disable_dev (waits ref_cnt==0) β
dm_dev_destroy β dm_dev_free β kfree(dmv). dmopen then returns
0 with dev->si_drv1 pointing at freed dmv. The next dmstrategy
(device-mapper.c:365) dereferences dmv->table_head etc. on freed
memory. The DFly device framework does NOT clear si_drv1 on destroy
(kern_conf.c:346-354), so the cdev_t stays alive with a dangling
si_drv1.
Harness evidence
DF-1843: race confirmed β dmopen returned a handle into a freed dm_dev_t (is_open store at device-mapper.c:213 lost the race vs remove reading is_open at dm_ioctl.c:354). Next dmstrategy (device-mapper.c:365) would dereference freed memory.
Why no live trigger on this guest
/dev/mapper/control does not exist on this guest: device dm is not
in X86_64_GENERIC and dm.ko is not loaded. Even if it were, the
control device is mode 0640 root:operator (device-mapper.c:181) and
maxx is not in the operator group. Valid Phase-6 hard blocker.
Exploit chain
Not applicable (dm-module-unloaded + operator-group-gated on guest). No
uid=0 claim. On a host with device dm and operator-group access,
the UAF is a slab-groom target: reclaim the freed dm_dev_t with
controlled data, then drive I/O through the dangling fd to hijack
control flow. Live ceiling: panic / heap corruption.
PoC changes
- Added
harness.c: pthread model of the dmopen vs dm_dev_remove race. - Added
fix.diff: hold the busy reference for the open lifetime.
Fix
fix.diff removes dm_dev_unbusy from dmopen (so the busy ref taken
by dm_dev_lookup is held for the open lifetime) and adds a matching
extra dm_dev_unbusy in dmclose. This makes disable_dev's
ref_cnt==0 wait block until dmclose, closing the UAF window.
- BEFORE: harness shows the race firing (dmopen returns a handle into a freed dmv).
- AFTER: dmopen's held reference prevents disable_dev from completing until dmclose runs.
Fix verification
fixedVALIDATED at compile+boot level: all 13 fixes applied cleanly to /usr/src, built into a single X86_64_GENERIC kernel (make -j6 nativekernel rc=0, kernel linked), installed as /boot/kernel/kernel, and the patched kernel booted cleanly (kern.version #1 vs baseline #0). The live PoC cannot run on this guest (HW/config-gated per the verdict), so before/after is at source+harness level: baseline harness: 'race confirmed β dmopen returned a handle into freed dm_dev_t' | patched: dmopen holds busy ref, disable_dev waits until dmclose
baseline (#0 unpatched): baseline harness: 'race confirmed β dmopen returned a handle into freed dm_dev_t' patched (#1 kernel, all 13 fixes, booted clean): patched: dmopen holds busy ref, disable_dev waits until dmclose kernel sha256 c3fff85f... (patched, booted) vs 5dc83dac... (baseline #0)
Confirmed kernel references
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- d
- m
- /
- d
- e
- v
- i
- c
- e
- -
- m
- a
- p
- p
- e
- r
- .
- c
- :
- 2
- 0
- 9
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- d
- m
- /
- d
- e
- v
- i
- c
- e
- -
- m
- a
- p
- p
- e
- r
- .
- c
- :
- 2
- 1
- 2
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- d
- m
- /
- d
- e
- v
- i
- c
- e
- -
- m
- a
- p
- p
- e
- r
- .
- c
- :
- 2
- 1
- 3
- s
- y
- s
- /
- d
- e
- v
- /
- d
- i
- s
- k
- /
- d
- m
- /
- d
- m
- _
- i
- o
- c
- t
- l
- .
- c
- :
- 3
- 5
- 4
Detail
Exploit chain
Config+group-gated (device dm not in GENERIC; dm.ko not loaded; /dev/mapper/control absent; maxx not in operator group). No uid=0 escalation claimed. Primitive characterized in harness.c (pthread race model). Live ceiling on a host with device dm + operator access: UAF -> slab-groom reclaim of freed dm_dev_t -> control-flow hijack via dmstrategy.
Evidence (decisive lines)
DF-1843: race confirmed β dmopen returned a handle into a freed dm_dev_t (is_open store at device-mapper.c:213 lost the race vs remove reading is_open at dm_ioctl.c:354). Next dmstrategy (device-mapper.c:365) would dereference freed memory.
PoC changes
Added harness.c (pthread race model) and fix.diff (hold busy ref for open lifetime: dmopen does NOT unbusy, dmclose does extra unbusy).
Verified recommended fix
fix.diff removes dm_dev_unbusy from dmopen (holds the lookup ref for open lifetime) and adds a matching extra dm_dev_unbusy in dmclose. matches finding proposal's 'hold busy ref for open lifetime'.
Verdict
REPRODUCED at source+harness. dmopen at device-mapper.c:209-213 does dm_dev_lookup (ref++), is_open=1 (plain store), dm_dev_unbusy (ref--) immediately β open fd has no held busy ref. dm_dev_remove_ioctl (dm_ioctl.c:354) reads is_open with no lock; pthread harness reproduces the race where remove reads is_open==0 in the lookup->is_open window, frees dmv, leaving dmopen's fd with dev->si_drv1 -> freed dmv. Next dmstrategy (:365) dereferences freed memory. Config-gated: /dev/mapper/control absent on guest (device dm not in GENERIC, dm.ko not loaded); also operator-group-gated.
No comments yet.