dm_dev_remove_ioctl / dm_dev_resume_ioctl use-after-free: dmv operated on after dm_dev_unbusy drops the last reference
Summary
Two ioctls drop device busy reference (ref_cnt->0) while device still on global list THEN continue to dereference dmv pointer. Concurrent remover can dm_dev_remove() same device in window freeing dmv first thread then calls dm_dev_remove(dmv)/dm_table_destroy(&dmv->table_head) on freed memory. dm_dev_remove_ioctl :349 lookup takes ref :354 reads is_open :356 unbusy drops it(1->0) while dmv STILL on dm_dev_list :361 calls dm_dev_remove(dmv). Between 356 and 361 second thread runs disable_dev+dm_dev_destroy+kfree(dmv). dm_dev_resume_ioctl :518 unbusy :521 dm_table_destroy dereferences freed dmv->table_head. Two or more threads issuing remove ioctl for same device race or one resume racing another remove. With slab grooming M_DM objcache controlled UAF -> arbitrary kernel code execution -> local root.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-2447 Β· 11 files| File | Type | Description | Size | |
|---|---|---|---|---|
| dm_race_uaf.c | trigger-source | concurrent-remove UAF racer (libprop NETBSD_DM_IOCTL + pipe barrier) | 7.5 KB | view raw |
| build.sh | build-script | cc -O2 -o dm_race_uaf dm_race_uaf.c -lprop | 201 B | view raw |
| run.sh | run-script | ./dm_race_uaf <racers> <iters> (as root, after kldload dm) | 262 B | view raw |
| run.log | run-log | baseline (unpatched) run + panic signature | 931 B | view raw |
| panic.txt | panic-signature | Bad link elm ... prev->next != elm in dm_dev_remove_ioctl->dm_dev_remove | 536 B | view raw |
| fix.diff | suggested-fix | atomic dm_dev_destroy_by_key() + resume unbusy reorder; git-apply-able | 4.1 KB | view raw |
| fix_build.log | build-log | patched dm.ko build (-Werror clean, sha256 4c3239e9...) | 1.7 KB | view raw |
| fix_run.log | run-log | patched dm.ko run -> clean completion over ~16000 races, no panic | 1.3 KB | view raw |
| env.txt | environment | uname / kern.version / cc / dm.ko hash / control dev perms / maxx id | 609 B | view raw |
| VERDICT.md | verdict | full mechanism + primitive characterization + privilege analysis + fix before/after | 11.6 KB | β raw |
| README.md | readme | how to build/run, expected output, privilege note | 2.2 KB | β raw |
DF-2447 β dm_dev_remove_ioctl / dm_dev_resume_ioctl use-after-free
Reproduction + fix for the dm device-mapper UAF race in
sys/dev/disk/dm/dm_ioctl.c.
The bug (confirmed)
dm_dev_remove_ioctl() and dm_dev_resume_ioctl() drop the device's busy
reference (dm_dev_unbusy) and then keep dereferencing the dmv pointer
(dm_dev_remove(dmv) / dm_table_destroy(&dmv->table_head,β¦)). Two
concurrent remove ioctls on the same device race through this window: the
first remover frees dmv while the second is still between its unbusy and
its dm_dev_remove β use-after-free / double-remove β kernel panic.
Reproduce (as root)
cc -O2 -o dm_race_uaf dm_race_uaf.c -lprop # build.sh
kldload dm # root: load the dm module
./dm_race_uaf 8 2000 # run.sh ; 8 racers x 2000 iters
Expected on the unpatched (#0) kernel: a near-instant kernel panic
panic: Bad link elm 0xfffff80117ed3a40 prev->next != elm ... dm_dev_remove() at dm_dev_remove+0x25 ... dm_dev_remove_ioctl() at dm_dev_remove_ioctl+0xb7 ... dmioctl() ... db>
Guest wedges in DDB. Serial console (dfbsd-qemu/boot.log) captures it.
Expected on the patched dm.ko: the racer completes all iterations cleanly
("exhausted N iterations without a panic"), guest stays up, boot.log empty.
Privilege note (why this is panic/DoS, not uid0)
/dev/mapper/control is 0640 root:operator and the dm module must be
kldload-ed by root. The whole dm ioctl surface is therefore root/operator-only.
maxx (uid 1001, not in operator/wheel) cannot open the control dev and
cannot kldload. 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. See VERDICT.md for the full privilege analysis.
Fix
fix.diff β atomic dm_dev_destroy_by_key() helper (lookup+is_open+remove+
drain+destroy under dm_dev_mutex, so two removers are serialized and the
drop-ref-then-deref window is eliminated) + move dm_dev_unbusy after
dm_table_destroy in dm_dev_resume_ioctl. git apply --check clean; built
and validated as dm.ko.
DF-2447 β dm_dev_remove_ioctl / dm_dev_resume_ioctl use-after-free
Verdict
REPRODUCED (panic / local DoS via kernel list corruption) + FIX VALIDATED.
Both cited ioctls drop the device's busy reference and then keep dereferencing
the dmv pointer. Driven by concurrent remove ioctls on the same device this
deterministically panics the kernel with a corrupted-list assertion
(Bad link elm ... prev->next != elm) inside dm_dev_remove_ioctl β
dm_dev_remove β list manipulation β exactly the drop-ref-then-deref window
the finding describes. The authored fix.diff is built, installed as dm.ko,
and confirmed to close the bug (panic β clean completion under ~16000+ concurrent
races). Escalation to uid=0 is blocked by a valid hard blocker (the whole
dm ioctl surface is root/operator-only), see below.
Mechanism (trigger β primitive β effect)
dm_dev_remove_ioctl() in sys/dev/disk/dm/dm_ioctl.c:
330: int
331: dm_dev_remove_ioctl(prop_dictionary_t dm_dict)
332: {
...
349: if ((dmv = dm_dev_lookup(name, uuid, minor)) == NULL) { // ref_cnt++ (busy)
...
354: is_open = dmv->is_open;
356: dm_dev_unbusy(dmv); // <-- DROPS the busy reference
...
361: return dm_dev_remove(dmv); // <-- uses dmv AFTER the ref is dropped
362: }
dm_dev_unbusy() (dm_dev.c:398) decrements ref_cnt and cv_broadcasts when
it hits 0. dm_dev_remove() (dm_dev.c:305) β disable_dev() removes the
device from dm_dev_list and waits for ref_cnt==0, then dm_dev_destroy() β
dm_dev_free() β kfree(dmv, M_DM).
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, broadcasts (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 / reused) β "Bad link elm ... prev->next != elm" panic / use-after-free / double-free.
The window between step 4 (dm_dev_unbusy) and step 7 (dm_dev_remove) is the
bug. 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.
dm_dev_resume_ioctl() has the identical window at a different site:
518: dm_dev_unbusy(dmv); // <-- DROPS the busy ref
...
521: dm_table_destroy(&dmv->table_head, DM_TABLE_INACTIVE); // <-- uses dmv
(dm_table_destroy is dm_table.c:130; it only touches head->table_mtx /
head->tables, independent of ref_cnt, so simply reordering β destroying the
table before unbusying β is the correct fix, matching the safe pattern already
used by dm_table_clear_ioctl at dm_ioctl.c:562/566.)
Confirmed effect (unpatched #0 kernel)
panic: Bad link elm 0xfffff80117ed3a40 prev->next != elm
cpuid = 5
Trace beginning at frame 0xfffff801185cb648
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
Debugger("panic")
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 within the first race
iteration (8 racers). 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 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 (same as sibling DF-2446).
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_race_uaf.c)
A libprop NETBSD_DM_IOCTL racer (modeled on the sibling DF-2446 PoC). It
repeatedly creates a dm device df2447racer 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_race_uaf dm_race_uaf.c -lprop - Run (as root):
kldload dm && ./dm_race_uaf 8 2000
Args: <racers> <iterations> (defaults 6 / 4000).
Fix (fix.diff)
Minimal and targeted at the root cause β eliminate the drop-ref-then-deref window in both cited paths:
dm_dev_remove_ioctlβ replace the lookup β readis_openβ unbusy βdm_dev_remove(dmv)sequence with a single atomic helperdm_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).dm_dev_resume_ioctlβ movedm_dev_unbusy(dmv)to afterdm_table_destroy(&dmv->table_head, DM_TABLE_INACTIVE)so the busy reference is held across the lastdmvdereference. This matches the safe pattern already used bydm_table_clear_ioctl(dm_ioctl.c:562β566). Safe becausedm_table_destroyis independent ofref_cnt.- 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.
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 iteration 0; guest DDB |
apply fix.diff to /usr/src |
6 hunks applied cleanly (dm_dev.c Γ2, dm.h Γ1, dm_ioctl.c Γ3) |
| rebuild dm module | make in sys/dev/disk/dm β dm.ko OK, -Werror, no warnings/errors |
| install dm.ko | /boot/kernel/dm.ko replaced (sha256 4c3239e9β¦); kernel image stays #0 (dm is purely loadable) |
| confirm fix in loaded module | nm dm.ko shows new T dm_dev_destroy_by_key symbol |
| re-run PoC (8 racers Γ 2000 iters) | clean completion, RUN_EXIT=0, "exhausted β¦ without a panic", guest UP, boot.log empty |
| longer stress (8 racers Γ 8000) | survived 5500+ iterations (~44000 races) before the 90s timeout cutoff (not a crash); guest UP |
The same PoC that deterministically panicked the unpatched dm module now
completes cleanly under heavy concurrent remove pressure 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 sibling DF-2446.)
Files
| file | purpose |
|---|---|
dm_race_uaf.c |
trigger PoC (concurrent remove ioctls via libprop + pipe barrier) |
build.sh |
cc -O2 -o dm_race_uaf dm_race_uaf.c -lprop |
run.sh |
./dm_race_uaf <racers> <iters> (as root, after kldload dm) |
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 + resume reorder) |
fix_build.log |
patched dm.ko build log (-Werror clean, sha256 4c3239e9β¦) |
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: the PoC (./dm_race_uaf 8 2000) that DETERMINISTICALLY PANICKED the unpatched dm module now COMPLETES CLEANLY on the patched dm.ko: RUN_EXIT=0, exhausted 2000 iterations without a panic, guest UP, boot.log empty. A longer stress (8 racers x 8000 iters, ~44000 races) also survived 5500+ iterations with no panic before the 90s timeout. The atomic dm_dev_destroy_by_key() closes the drop-ref-then-deref UAF window and the resume reorder holds the ref across dm_table_destroy.
baseline #0 (unpatched dm): panic 'Bad link elm 0xfffff80117ed3a40 prev->next != elm' in dm_dev_remove_ioctl+0xb7 -> dm_dev_remove+0x25, iteration 0, guest DDB. patched dm.ko (4c3239e9...): exhausted 2000 iterations without a panic, RUN_EXIT=0, guest UP, boot.log empty (0 bytes).
Confirmed kernel references
Detail
Exploit chain
VALID HARD BLOCKER for uid0 (no escalation chain possible). The whole dm ioctl surface is root/operator-only: /dev/mapper/control is 0640 root:operator and dm must be kldloaded by root (not in GENERIC). maxx gets open EACCES and cannot kldload. Root->kernel is game-over by definition. Primitive: UAF/double-remove on a kmalloc(sizeof(dm_dev_t), M_DM) ~2KB object; second remover re-enters disable_dev on freed memory -> double-free + lock-object reuse; controlled corruption primitive with slab grooming on noinv. No chain file written because the privilege gate is a valid hard blocker.
Evidence (decisive lines)
baseline (unpatched #0): panic 'Bad link elm 0xfffff80117ed3a40 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> (iteration 0, guest DDB).
PoC changes
Authored PoC from scratch (dir empty): dm_race_uaf.c libprop NETBSD_DM_IOCTL racer (modeled on sibling DF-2446) that repeatedly creates device 'df2447racer' and, via pipe barrier, fires N concurrent 'remove' ioctls simultaneously so dm_dev_lookup calls stack (ref_cnt 0->1->2) before any dm_dev_unbusy runs β the UAF precondition. Plus build.sh, run.sh, full evidence pack.
Verified recommended fix
fix.diff: (1) add dm_dev_destroy_by_key(name,uuid,minor) in dm_dev.c which atomically does lookup + is_open check + disable_dev (TAILQ_REMOVE + wait ref_cnt==0) + dm_dev_destroy all under dm_dev_mutex, so two concurrent removers are serialized (first removes+frees, second gets ENOENT); rewrite dm_dev_remove_ioctl to call it; (2) in dm_dev_resume_ioctl move dm_dev_unbusy(dmv) AFTER dm_table_destroy so the busy ref is held across the last dmv deref; (3) declare dm_dev_destroy_by_key in dm.h. git apply --check passes. Full diff in findings/poc/DF-2447/fix.diff.
Verdict
REPRODUCED (panic / local DoS). Both cited ioctls drop the device busy reference then keep dereferencing dmv: dm_dev_remove_ioctl does dm_dev_unbusy(dmv) at dm_ioctl.c:356 then dm_dev_remove(dmv) at :361; dm_dev_resume_ioctl does dm_dev_unbusy at :518 then dm_table_destroy(&dmv->...) at :521. Two concurrent remove ioctls on the same device race through this window: remover A's dm_dev_remove->disable_dev->TAILQ_REMOVE+kfree(dmv) completes while remover B is still between its dm_dev_unbusy and its dm_dev_remove(dmv); B then re-enters disable_dev on the freed dm_dev_t and TAILQ_REMOVE reads poisoned/reused dmv->next_devlist links. Confirmed by panic 'Bad link elm 0xfffff80117ed3a40 prev->next != elm' with backtrace dm_dev_remove_ioctl+0xb7 -> dm_dev_remove+0x25, hit within race iteration 0 (8 racers), guest wedged in DDB.
No comments yet.