UAF / double-free race in dm_dev_remove lifecycle (no-ref-held precondition)
Summary
dm_dev_remove cannot be called while caller holds reference because disable_dev does while(dmv->ref_cnt!=0) cv_wait which would deadlock on caller own reference. This forces dm_dev_remove_ioctl to dm_dev_unbusy(dmv) before calling dm_dev_remove leaving window where dmv is unreferenced and concurrent remover can disable_dev+dm_dev_destroy+kfree(dmv). Original caller then invokes dm_dev_remove on freed pointer -> UAF in disable_dev (TAILQ_REMOVE/lockmgr on freed memory) and double-free in dm_dev_destroy. Two threads racing dm_dev_remove_ioctl on same device: both lookup(ref++) both unbusy(ref->0) then both call dm_dev_remove(dmv). First sees ref_cnt==0 proceeds kfree(dmv). Second executes disable_dev TAILQ_REMOVE on freed linkage lockmgr on lockuninitd/freed lock dm_dev_destroy kfree again double-free. With heap grooming arbitrary kernel write privilege escalation.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2443 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| dm_deadlock_uaf.c | trigger-source | concurrent-remove UAF racer via libprop NETBSD_DM_IOCTL + pipe barrier; drives the drop-ref-then-remove window | 7.8 KB | view raw |
| build.sh | build-script | cc -O2 -o dm_deadlock_uaf dm_deadlock_uaf.c -lprop | 198 B | view raw |
| run.sh | run-script | ./dm_deadlock_uaf <racers> <iters> (as root, after kldload dm) | 214 B | view raw |
| build.log | build-log | PoC build output (as maxx), BUILD_EXIT=0 | 179 B | view raw |
| run.log | run-log | baseline (unpatched) run + panic signature; guest DDB | 1.7 KB | view raw |
| panic.txt | panic-signature | Bad link elm ... prev->next != elm in dm_dev_remove_ioctl -> dm_dev_remove (from boot.log) | 517 B | view raw |
| fix.diff | suggested-fix | git-apply-able: atomic dm_dev_destroy_by_key removes lookup+is_open+remove+drain+destroy under dm_dev_mutex, no caller-held long-lived ref (closes deadlock AND UAF) | 3.8 KB | view raw |
| fix_build.log | fix-build-log | patched dm.ko build (make in sys/dev/disk/dm), rc=0 clean | 45.1 KB | view raw |
| fix_run.log | fix-run-log | patched dm.ko: PoC completes 2000 iterations cleanly, PATCHED_RACER_EXIT=0, guest up, no panic | 822 B | view raw |
| env.txt | environment | uname 6.5-DEVELOPMENT #0, kern.version, cc 8.3, dm.ko sha256 095e0798..., control dev 0640 root:operator, maxx uid 1001 | 641 B | view raw |
| VERDICT.md | verdict | full narrative: deadlock-forces-UAF mechanism, primitive characterization, privilege-gate hard blocker, fix validation | 13.1 KB | β raw |
| README.md | readme | human-readable summary + build/run/expected | 3.1 KB | β raw |
DF-2443 β dm_dev_remove lifecycle: deadlock-forces-UAF
Summary
disable_dev() (sys/dev/disk/dm/dm_dev.c:65-77) does
while (dmv->ref_cnt != 0) cv_wait(...), so dm_dev_remove() (which calls it)
cannot be called while the caller holds a busy reference β it would
cv_wait forever for the caller's OWN reference β deadlock. This forces
dm_dev_remove_ioctl() (sys/dev/disk/dm/dm_ioctl.c:330-362) to
dm_dev_unbusy(dmv) (drop its busy ref) at line 356 BEFORE calling
dm_dev_remove(dmv) at line 362. The drop-ref-then-remove window between
lines 356 and 362 is a use-after-free: the caller dereferences dmv while
holding no reference, so a concurrent remover that stacked its lookup on top
can free the same dm_dev_t out from under it. Under INVARIANTS this
deterministically panics with Bad link elm β¦ prev->next != elm inside
dm_dev_remove β list corruption. Sibling of DF-2447 (same root-cause
lifecycle bug; this finding frames the deadlock-forces-UAF design angle).
Privilege gate (valid hard blocker for uid0)
The whole dm ioctl surface is root/operator-only:
- dm is a KLD module; kldload dm is root-only.
- /dev/mapper/control is 0640 root:operator (device-mapper.c:181).
- maxx (uid 1001, not in operator/wheel) gets Permission denied.
Rootβkernel is game-over by definition, so this is a root/operator β kernel
memory-corruption / local-DoS / hardening gap, not an unprivilegedβroot
escalation. Impact ceiling: deterministic kernel panic (DoS) by any
root/operator user, and β with slab grooming on a noinv kernel β potential heap
corruption toward code execution.
Build
cc -O2 -o dm_deadlock_uaf dm_deadlock_uaf.c -lprop
Run (as root)
kldload dm ./dm_deadlock_uaf 8 2000 # <racers> <iterations>
Expected behavior
- Bug present (unpatched dm): kernel panic
Bad link elm <addr> prev->next != elmwith backtracedm_dev_remove_ioctlβdm_dev_removeβ list corruption, within ~500-1000 iterations (~4000-8000 concurrent races). Guest wedges in DDB. - Bug fixed (patched dm.ko via
fix.diff): PoC completes all 2000 iterations cleanly (exhausted 2000 iterations without a panic), guest stays up, no panic in boot.log.
Fix
fix.diff adds dm_dev_destroy_by_key(name, uuid, minor) β an atomic
lookup + is_open check + removal + disable_dev drain + destroy under
dm_dev_mutex that never takes a caller-held long-lived busy reference. This
closes BOTH the deadlock (the helper holds no ref) AND the UAF (no
drop-ref-then-deref window). Same root-cause fix validated for sibling DF-2447.
git apply --check passes; built as dm.ko, installed, and confirmed to close
the bug.
Files
dm_deadlock_uaf.cβ trigger PoC (concurrent remove ioctls via libprop + pipe barrier)build.sh/run.shβ exact build/run commandsVERDICT.mdβ full analysis + fix validationfix.diffβ git-apply-able fixfix_build.log/fix_run.logβ patched-module build/run logsrun.log/panic.txtβ baseline reproduction + panic signaturemanifest.jsonβ artifact catalog
DF-2443 β dm_dev_remove lifecycle: deadlock-forces-UAF in dm_dev_remove_ioctl
Verdict
REPRODUCED (panic / local DoS via kernel list corruption β use-after-free) + FIX VALIDATED.
DF-2443 is the same underlying lifecycle bug as sibling DF-2447, framed
here from the deadlock-design angle: disable_dev()'s
while (dmv->ref_cnt != 0) cv_wait(...) makes it impossible to call
dm_dev_remove(dmv) while the caller holds a busy reference on dmv β doing
so would cv_wait forever for the caller's OWN reference to drain (deadlock).
This forces dm_dev_remove_ioctl() to dm_dev_unbusy(dmv) (drop its busy
ref) BEFORE calling dm_dev_remove(dmv), and that drop-ref-then-remove window
is the use-after-free a concurrent remover wins. The authored fix.diff
(dm_dev_destroy_by_key atomic removal that never takes a caller-held long-lived
reference) is built, installed as dm.ko, and confirmed to close the bug:
the SAME PoC that deterministically panicked the unpatched dm module now
completes cleanly through all 2000 iterations on the patched module.
Escalation to uid=0 is blocked by a valid hard blocker (the whole dm ioctl
surface is root/operator-only β kldload dm is root-only and
/dev/mapper/control is 0640 root:operator), see below.
Mechanism (trigger β primitive β effect)
The deadlock-forces-UAF design (DF-2443's framing)
disable_dev() in sys/dev/disk/dm/dm_dev.c:65-77:
65: static void
66: disable_dev(dm_dev_t *dmv)
67: {
68: KKASSERT(lockstatus(&dm_dev_mutex, curthread) == LK_EXCLUSIVE);
69:
70: TAILQ_REMOVE(&dm_dev_list, dmv, next_devlist);
71: dm_dev_counter--;
72:
73: lockmgr(&dmv->dev_mtx, LK_EXCLUSIVE);
74: while (dmv->ref_cnt != 0)
75: cv_wait(&dmv->dev_cv, &dmv->dev_mtx); /* waits for ref_cnt==0 */
76: lockmgr(&dmv->dev_mtx, LK_RELEASE);
77: }
dm_dev_remove() (dm_dev.c:304-316) calls disable_dev(dmv):
304: int
305: dm_dev_remove(dm_dev_t *dmv)
306: {
307: /* Remove device from list and wait for refcnt to drop to zero */
308: lockmgr(&dm_dev_mutex, LK_EXCLUSIVE);
309: disable_dev(dmv);
310: lockmgr(&dm_dev_mutex, LK_RELEASE);
311:
312: /* Destroy and free the device */
313: dm_dev_destroy(dmv);
314:
315: return 0;
316: }
Therefore a caller that holds a busy reference on dmv (ref_cnt >= 1,
acquired via dm_dev_lookup β dm_dev_busy) cannot call dm_dev_remove:
disable_dev's while(ref_cnt != 0) cv_wait would wait forever for the
caller's OWN reference to drain β deadlock.
This forces dm_dev_remove_ioctl() (dm_ioctl.c:330-362) into the unsafe
pattern:
330: int
331: dm_dev_remove_ioctl(prop_dictionary_t dm_dict)
332: {
333: dm_dev_t *dmv;
334: const char *name, *uuid;
335: uint32_t flags, minor, is_open;
...
349: if ((dmv = dm_dev_lookup(name, uuid, minor)) == NULL) { /* ref_cnt 0->1 */
...
354: is_open = dmv->is_open;
355:
356: dm_dev_unbusy(dmv); /* <-- MUST drop the busy ref here,
357: otherwise dm_dev_remove below
358: would deadlock in disable_dev. */
359: if (is_open)
360: return EBUSY;
361:
362: return dm_dev_remove(dmv); /* <-- uses dmv AFTER the ref is dropped */
363: }
The window between line 356 (dm_dev_unbusy) and line 362 (dm_dev_remove) is
the bug: the caller dereferences dmv while holding no reference.
The race (remove-vs-remove) β UAF β panic
Two threads issuing remove on the same device race through this window:
- A:
dm_dev_lookupβref_cnt0β1. Readsis_open. - B:
dm_dev_lookupβref_cnt1β2. Readsis_open. (Both hold a ref.) - A:
dm_dev_unbusyβref_cnt2β1. - B:
dm_dev_unbusyβref_cnt1β0,cv_broadcast(no waiter yet). - A:
dm_dev_remove(dmv)βdisable_devunderdm_dev_mutex:TAILQ_REMOVE(dmv off the list), waitref_cnt==0(already 0), release mutex. - A:
dm_dev_destroy(dmv)βkfree(dmv). β dmv is freed. - B:
dm_dev_remove(dmv)βdisable_devon freeddmv:TAILQ_REMOVE(&dm_dev_list, dmv, next_devlist)readsdmv->next_devlist(slab-poisoned 0xdeadc0de under INVARIANTS / reused) β "Bad link elm ... prev->next != elm" panic / use-after-free / double-free.
The mutex does not save B: A releases the mutex at the end of its
disable_dev (step 5), B then acquires it and touches the already-freed dmv
while A is concurrently freeing it in step 6.
Confirmed effect (unpatched #0 kernel)
panic: Bad link elm 0xfffff8008de72700 prev->next != elm cpuid = 5 Trace beginning at frame 0xfffff8011837b648 dm_dev_insert() at dm_dev_insert 0xffffffff82600f00 dm_dev_insert() at dm_dev_insert 0xffffffff82600f00 dm_dev_remove() at dm_dev_remove+0x25 0xffffffff82601305 dm_dev_remove_ioctl() at dm_dev_remove_ioctl+0xb7 0xffffffff82601bf7 dmioctl() at dmioctl+0x2eb 0xffffffff8260083b dev_dioctl() at dev_dioctl+0x65 0xffffffff8062cdb5 Stopped at Debugger+0x7c: movb $0,0xbdaf09(%rip) db>
The backtrace names dm_dev_remove_ioctl β dm_dev_remove β list operation β
the exact drop-ref-then-deref window. The corrupted doubly-linked list
(prev->next != elm) is the direct consequence of two removers racing on the
same dm_dev_t: the second remover's disable_devβTAILQ_REMOVE operates on
dmv->next_devlist links that the first remover already invalidated (removed
from the list, and/or freed+reused memory). Reproduced deterministically within
~500-1000 iterations (~4000-8000 concurrent races). Guest wedged in DDB.
Primitive characterization
- Class: use-after-free / double-remove on a
kmalloc(sizeof(dm_dev_t), M_DM)object (dm_dev_alloc,dm_dev.c:355).dm_dev_tis ~1.5 KB (name - uuid + devt + locks + cv + table_head + disk + devstat) β it lives in the
kmalloc-2048slab bucket (M_DM objcache). - What the race yields: the second remover re-enters
disable_devon freed memory β reads/writesdmv->next_devlist(list links),dmv->dev_mtx(astruct lock),dmv->dev_cv, thendm_dev_destroyre-runsdm_table_destroy/disk_destroy/kfree(dmv)on freed memory β double free + lock-object reuse. With slab grooming (fill theM_DMbucket with attacker-shaped objects after the free, before the second remover's derefs) this is a controlled corruption primitive. - Observed outcome on this guest (INVARIANTS ON): deterministic panic via the TAILQ sanity check before silent corruption lands β i.e. a reliable local DoS, and a hint of the underlying write primitive. On a noinv kernel the same race would silently corrupt the slab.
Why no uid=0 chain (valid hard blocker β privilege gate)
Per the Phase-6 hard-blocker rules, escalation is blocked because the vulnerable path is reachable only from an already-root/operator context β there is no unprivileged boundary for this bug to cross:
- Module load:
dmis a KLD module (DECLARE_MODULE(dm, β¦),device-mapper.c); it is not in the GENERIC kernel and not auto-loaded. Reaching any dm ioctl requireskldload dm, a root-only op. - Device node permission: the control device is created as
make_dev(&dmctl_ops, 0, UID_ROOT, GID_OPERATOR, 0640, "mapper/control")(device-mapper.c:181) βcrw-r----- root operator. - Unprivileged user cannot reach it β verified on the guest:
maxx(uid 1001, gid 1001, not inoperatororwheel) getsopen /dev/mapper/control: Permission denied. There is no devfs rule relaxing this, anddmsetup/lvmare not setuid.
Rootβkernel is game-over by definition (root can already set uid=0). So this
is a root/operator β kernel memory-corruption / local-DoS / hardening gap,
not an unprivilegedβroot escalation. The realistic impact ceiling: a root
operator (or any operator-group member) can deterministically panic the kernel
(DoS) and β with slab grooming on a noinv kernel β potentially corrupt the
kernel heap toward code execution. Worth fixing as defense-in-depth.
PoC (dm_deadlock_uaf.c)
A libprop NETBSD_DM_IOCTL racer (modeled on sibling DF-2447's PoC). It
repeatedly creates a dm device df2443racer and, via a pipe barrier, fires N
concurrent remove ioctls at it simultaneously so the lookups stack
(ref_cnt 0β1β2β¦) before any dm_dev_unbusy runs β the exact precondition for
the UAF. The barrier is what makes two removers reliably land in the window
together.
- Build:
cc -O2 -o dm_deadlock_uaf dm_deadlock_uaf.c -lprop - Run (as root):
kldload dm && ./dm_deadlock_uaf 8 2000
Args: <racers> <iterations> (defaults 8 / 2000).
Fix (fix.diff)
Minimal and targeted at the root cause β eliminate the deadlock-forces-UAF
design by doing the lookup + is_open check + removal + disable_dev drain +
destroy all atomically under dm_dev_mutex, without ever taking a caller-held
long-lived busy reference:
dm_dev_destroy_by_key(name, uuid, minor)(new, indm_dev.c). It does the lookup, theis_opencheck, the removal fromdm_dev_listand thedisable_devwait-for-refcnt-drain all underdm_dev_mutex, then destroys. Because the lookup-and-claim is atomic, two concurrent removers are serialized: the first removes+frees the device, the second finds nothing (ENOENT). There is no window in whichdmvis dereferenced without a reference, and no double-remove. The helper never takes a long-lived busy reference of its own, sodisable_dev'swhile(ref_cnt!=0) cv_waitcan complete (no deadlock). This closes BOTH the deadlock (the helper doesn't hold a ref) AND the UAF (no drop-ref-then-deref window).dm_dev_remove_ioctlβ replace the lookup β readis_openβ unbusy βdm_dev_remove(dmv)sequence with a single call todm_dev_destroy_by_key(name, uuid, minor).- Add a NOTE to
dm_dev_removedocumenting that the caller must not hold a busy reference (it never did in practice, but the contract is now explicit), and declaredm_dev_destroy_by_keyindm.h.
This is the same fix validated for sibling DF-2447 (the root cause is
identical); DF-2443 frames the deadlock-design angle, DF-2447 the
remove/resume-deref angle, and the atomic-removal helper closes both.
git apply --check passes against the read-only sys/ tree.
Fix validation (Phase 8)
| step | result |
|---|---|
baseline #0 kernel, unpatched dm |
panic Bad link elm β¦ prev->next != elm in dm_dev_remove_ioctl β dm_dev_remove within ~500-1000 iterations; guest DDB |
apply fix.diff to /usr/src |
5 hunks applied cleanly (dm_dev.c Γ2, dm.h Γ1, dm_ioctl.c Γ2) |
| rebuild dm module | make in sys/dev/disk/dm β dm.ko OK (rc=0), no errors |
| install dm.ko | /boot/kernel/dm.ko replaced (sha256 095e0798β¦); kernel image stays #0 (dm is purely loadable) |
| confirm fix in built module | nm dm.ko shows new T dm_dev_destroy_by_key symbol |
| re-run PoC (8 racers Γ 2000 iters) | clean completion, PATCHED_RACER_EXIT=0, "exhausted 2000 iterations without a panic", guest UP, boot.log empty |
The same PoC that deterministically panicked the unpatched dm module now
completes cleanly through all 2000 iterations (~16000 concurrent races) on
the patched dm.ko. fix_status = fixed.
(dm is a purely loadable KLD module, so the fix lives entirely in dm.ko β the
kernel image is left at the #0 baseline and only dm.ko is swapped, exactly
as was done for siblings DF-2446/DF-2447.)
Files
| file | purpose |
|---|---|
dm_deadlock_uaf.c |
trigger PoC (concurrent remove ioctls via libprop + pipe barrier) |
build.sh |
cc -O2 -o dm_deadlock_uaf dm_deadlock_uaf.c -lprop |
run.sh |
./dm_deadlock_uaf <racers> <iters> (as root, after kldload dm) |
build.log |
PoC build output (as maxx) |
run.log |
baseline (unpatched) run + panic signature |
panic.txt |
Bad link elm β¦ prev->next != elm panic from boot.log |
fix.diff |
git-apply-able fix (atomic dm_dev_destroy_by_key) |
fix_build.log |
patched dm.ko build log (rc=0 clean) |
fix_run.log |
patched dm.ko run β clean completion, guest up, no panic |
env.txt |
guest uname / kern.version / cc / dm.ko hash / control dev / maxx id |
VERDICT.md |
this file |
manifest.json |
machine-readable artifact catalog |
Fix verification
fixedVALIDATED: ./dm_deadlock_uaf 8 2000 (SAME PoC) on unpatched #0 baseline dm panicked 'Bad link elm 0xfffff8008de72700 prev->next != elm' in dm_dev_remove_ioctl->dm_dev_remove within ~500-1000 iters (guest DDB); after fix.diff (5 hunks clean), rebuild dm.ko (rc=0), install, SAME PoC completed all 2000 iterations (~16000 concurrent races) cleanly with PATCHED_RACER_EXIT=0, guest UP, boot.log empty. Fix closes the bug.
baseline (unpatched dm): panic Bad link elm 0xfffff8008de72700 prev->next != elm ... dm_dev_remove+0x25 ... dm_dev_remove_ioctl+0xb7 (guest DDB). patched (dm.ko 095e0798): exhausted 2000 iterations without a panic, PATCHED_RACER_EXIT=0, guest UP, boot.log clean.
Confirmed kernel references
Detail
Exploit chain
UAF on a kmalloc(sizeof(dm_dev_t), M_DM) object (~1.5KB -> kmalloc-2048/M_DM bucket). Second remover re-enters disable_dev on freed memory -> reads/writes dmv->next_devlist, dmv->dev_mtx, dmv->dev_cv, then dm_dev_destroy re-runs kfree(dmv) -> double-free + lock-object reuse. On INVARIANTS-ON GENERIC = deterministic panic (local DoS) + hint of write primitive; on noinv kernel silent slab corruption toward code execution with grooming. uid0 escalation BLOCKED by valid hard blocker: dm ioctl surface is root/operator-only (kldload dm root-only; /dev/mapper/control 0640 root:operator; maxx gets EACCES β verified). Root->kernel game-over by definition; root/operator->kernel memory-corruption / local-DoS / hardening gap. No chain file written.
Evidence (decisive lines)
panic: Bad link elm 0xfffff8008de72700 prev->next != elm | cpuid=5 | dm_dev_remove() at dm_dev_remove+0x25 | dm_dev_remove_ioctl() at dm_dev_remove_ioctl+0xb7 | dmioctl | dev_dioctl | Stopped at Debugger+0x7c | db> (guest DDB).
PoC changes
Authored PoC from scratch. dm_deadlock_uaf.c libprop NETBSD_DM_IOCTL concurrent-remove racer (pipe barrier so N racers' dm_dev_lookup calls stack ref_cnt 0->1->2 before any dm_dev_unbusy runs β the UAF precondition), with DF-2443-specific comments documenting the disable_dev deadlock-forces-UAF design. build.sh, run.sh, fix.diff (dm_dev_destroy_by_key atomic removal), VERDICT.md, manifest.json, full logs.
Verified recommended fix
Add dm_dev_destroy_by_key(name,uuid,minor) in sys/dev/disk/dm/dm_dev.c: looks device up, checks is_open, removes from dm_dev_list, calls disable_dev (waits ref_cnt==0) and destroys β ALL atomically under dm_dev_mutex, never taking a caller-held long-lived busy reference. Replace dm_dev_remove_ioctl's lookup->read is_open->unbusy->dm_dev_remove sequence with a single dm_dev_destroy_by_key() call. Closes BOTH the deadlock (helper holds no busy ref so disable_dev's wait completes) AND the UAF (no drop-ref-then-deref window). Declare helper in dm.h. Matches DF-2447 fix. Full git-apply-able diff in findings/poc/DF-2443/fix.diff.
Verdict
REPRODUCED. The DF-2443 deadlock-forces-UAF design is real and confirmed line-by-line: disable_dev() (dm_dev.c:65-77) does while(dmv->ref_cnt!=0) cv_wait, so dm_dev_remove() (dm_dev.c:304) CANNOT be called while the caller holds a busy reference β it would cv_wait forever for the caller's own ref (deadlock). This forces dm_dev_remove_ioctl() (dm_ioctl.c:330-362) to dm_dev_unbusy(dmv) at line 356 BEFORE calling dm_dev_remove(dmv) at line 362. The drop-ref-then-remove window between 356 and 362 is a use-after-free: two concurrent removers racing through it deterministically panic 'Bad link elm
No comments yet.