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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2447 Β· 11 files
FileTypeDescriptionSize
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
README.md readme how to build/run, expected output, privilege note
↓ download 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.

VERDICT.md verdict full mechanism + primitive characterization + privilege analysis + fix before/after
↓ download raw

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:

  1. A: dm_dev_lookup β†’ ref_cnt 0β†’1. Reads is_open.
  2. B: dm_dev_lookup β†’ ref_cnt 1β†’2. Reads is_open. (Both hold a ref.)
  3. A: dm_dev_unbusy β†’ ref_cnt 2β†’1.
  4. B: dm_dev_unbusy β†’ ref_cnt 1β†’0, broadcasts (no waiter yet).
  5. A: dm_dev_remove(dmv) β†’ disable_dev under dm_dev_mutex: TAILQ_REMOVE (dmv off the list), wait ref_cnt==0 (already 0), release mutex.
  6. A: dm_dev_destroy(dmv) β†’ kfree(dmv). ← dmv is freed.
  7. B: dm_dev_remove(dmv) β†’ disable_dev on freed dmv: TAILQ_REMOVE(&dm_dev_list, dmv, next_devlist) reads dmv->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_t is ~1.5 KB (name
  • uuid + devt + locks + cv + table_head + disk + devstat) β†’ it lives in the kmalloc-2048 slab bucket (M_DM objcache).
  • What the race yields: the second remover re-enters disable_dev on freed memory β†’ reads/writes dmv->next_devlist (list links), dmv->dev_mtx (a struct lock), dmv->dev_cv, then dm_dev_destroy re-runs dm_table_destroy / disk_destroy / kfree(dmv) on freed memory β†’ double free + lock-object reuse. With slab grooming (fill the M_DM bucket 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:

  1. Module load: dm is a KLD module (DECLARE_MODULE(dm, …), device-mapper.c); it is not in the GENERIC kernel and not auto-loaded. Reaching any dm ioctl requires kldload dm, a root-only op.
  2. 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.
  3. Unprivileged user cannot reach it β€” verified on the guest: maxx (uid 1001, gid 1001, not in operator or wheel) gets open /dev/mapper/control: Permission denied. There is no devfs rule relaxing this, and dmsetup/lvm are 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:

  1. dm_dev_remove_ioctl β€” replace the lookup β†’ read is_open β†’ unbusy β†’ dm_dev_remove(dmv) sequence with a single atomic helper dm_dev_destroy_by_key(name, uuid, minor) (new, in dm_dev.c). It does the lookup, the is_open check, the removal from dm_dev_list and the disable_dev wait-for-refcnt-drain all under dm_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 which dmv is dereferenced without a reference, and no double-remove. The helper never takes a long-lived busy reference of its own, so disable_dev's while(ref_cnt!=0) cv_wait can complete (no deadlock).
  2. dm_dev_resume_ioctl β€” move dm_dev_unbusy(dmv) to after dm_table_destroy(&dmv->table_head, DM_TABLE_INACTIVE) so the busy reference is held across the last dmv dereference. This matches the safe pattern already used by dm_table_clear_ioctl (dm_ioctl.c:562β†’566). Safe because dm_table_destroy is independent of ref_cnt.
  3. Add a NOTE to dm_dev_remove documenting that the caller must not hold a busy reference (it never did in practice, but the contract is now explicit), and declare dm_dev_destroy_by_key in dm.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

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: 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).
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (kernel image unchanged; dm is a purely loadable KLD so only /boot/kernel/dm.ko was swapped to the patched build, sha256 4c3239e9...; nm confirms the new dm_dev_destroy_by_key symbol is resident)

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.