⬢ DragonFlyBSD Kernel Audit
← triage · dashboard
DF-5037

dm_pdev_insert holds dm_pdev_mutex across vn_open/VOP_GETATTR/dev_dioctl which re-enter dm (dmopen→dm_dev_lookup→dm_dev_mutex), inverting against dm_dev_remove/remove_all holding dm_dev_mutex across the ref-drain cv_wait and table destruction (→ dm_pdev_decr → dm_pdev_mutex): permanent AB-BA kernel deadlock of the whole dm subsystem

Summary

Textbook AB-BA on the dm-on-dm stacking path the code itself anticipates (dm_pdev.c:92-99): pdev side takes pdev->dev order via vn_open->dmopen->dm_dev_lookup, dev side takes dev->pdev order via cv_wait drain + table destroy -> dm_pdev_decr. Plus a deterministic second interleaving: reload's busy-ref vs remove's drain-holding-dm_dev_mutex. REPRODUCED on guest: both workers D-state on complementary wchans (dmdevlis vs dm_dev), 42 frozen snapshots, all dm ioctls + dm-backed I/O wedged, ssh cascade. Both-sides fix validated on rebuilt kernel (full-rate duel + dm-on-dm dd clean). Root-triggerable; panics_observed.txt documents pre-empting manifestations of known DF-4474/2443/2447.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5037 · 13 files
FileTypeDescriptionSize
dm_abba.c — 12.5 KB view raw
guest_duel.sh — 417 B view raw
run.log — 27.6 KB view raw
ps_detail.txt — 343 B view raw
run_patched.log — 2.0 KB view raw
panics_observed.txt — 2.7 KB view raw
fix.diff — 3.9 KB view raw
env.txt — 320 B view raw
build.sh — 239 B view raw
run.sh — 248 B view raw
VERDICT.md — 7.2 KB ↓ raw
manifest.json — 1.5 KB view raw
verdict.json — 6.6 KB view raw
VERDICT.md
↓ download raw

DF-5037 — VERDICT

Status: reproduced (impact: dos — permanent kernel deadlock of the whole dm subsystem; privileged local trigger). Fix: validated on a rebuilt kernel.

Root cause (static proof)

Two lock-acquisition orders exist in the dm subsystem and they invert:

Edge A — sys/dev/disk/dm/dm_pdev.c (the audited file's flaw). dm_pdev_insert() acquires the global dm_pdev_mutex EXCLUSIVE at dm_pdev.c:128 and holds it across blocking, dm-reentrant VFS operations: dm_dk_lookup() → vn_open() at dm_pdev.c:143/78, then VOP_GETATTR() (dm_pdev.c:288) and dev_dioctl(DIOCGPART) (dm_pdev.c:168). With dm-on-dm stacking (a table backed by /dev/mapper/X — explicitly supported; see the stacking comment at dm_pdev.c:92-99), vn_open runs dmopen() (device-mapper.c:200-217) → dm_dev_lookup() (dm_dev.c:105-118) which takes the global dm_dev_mutex EXCLUSIVE. Lock order: dm_pdev_mutex → dm_dev_mutex.

Edge B — sys/dev/disk/dm/dm_dev.c (partner edge). dm_dev_remove() (dm_dev.c:305-316) and dm_dev_remove_all() (dm_dev.c:318-345) hold dm_dev_mutex EXCLUSIVE across disable_dev(), which cv_waits for ref_cnt == 0 (dm_dev.c:73-75) while STILL HOLDING dm_dev_mutex, and (remove_all) across dm_dev_destroy() → dm_table_destroy() (dm_table.c:136-163) → target dtor (e.g. dm_target_linear.c:157) → dm_pdev_decr() (dm_pdev.c:242-259) which takes dm_pdev_mutex. Lock order: dm_dev_mutex → dm_pdev_mutex.

Cycle ⇒ permanent deadlock. Two concrete interleavings, both proven live:

  • Variant 1 (mutex ↔ mutex): loader thread inside dm_pdev_insert's vn_open (holds dm_pdev_mutex, blocked in dm_dev_lookup on dm_dev_mutex) while remove_all/remove (holds dm_dev_mutex) reaches any target dtor → dm_pdev_decr → blocks on dm_pdev_mutex.
  • Variant 2 (busy-ref ↔ mutex, the deterministic duel): reloader holds a dm_dev busy reference for the whole dm_table_load_ioctl (busy at dm_ioctl.c:711, unbusy at :800) and, inside it, blocks on dm_dev_mutex (its old-table destroy does dm_pdev_decr → dm_pdev_free → vn_close(/dev/mapper/X) → dmclose → dm_dev_lookup; its ctor does vn_open → dmopen → dm_dev_lookup). Concurrently remove of that same device sleeps in disable_dev's cv_wait holding dm_dev_mutex waiting for the reloader's busy reference, which the reloader can never drop.

Reproduction (what was run)

./dm_abba duel /dev/vbd0 90 as root on the stock guest (dfbsd-qemu/vm.sh reset with-src, kldload dm dm_target_linear):

  • Setup: create pbase + reload pbase (linear over /dev/vbd0) + resume pbase (required: until dm_dev_resume_ioctl → dmsetdiskinfo initializes the disk media info, the disk layer rejects opens of the node with ENXIO and every stacked pdev open would fail), then create stk.
  • P1 hammers reload stk with a linear table backed by /dev/mapper/pbase (~3.5k ioctls/s; each reload destroys the previous inactive table → vn_close → dmclose → dm_dev_mutex, and re-opens the pdev → vn_open → dmopen → dm_dev_mutex, all while holding the busy ref on stk).
  • P2 hammers remove stk (2-5 ms pacing).
  • Watchdog detects both counters frozen >6 s; ps-watcher and dmsetup ls canaries mirror everything to /dev/console so evidence survives the wedge.

Result (see run.log, ps_detail.txt): both workers froze in D state on the complementary locks — wchan dmdevlis (the dm_dev_mutex, lockinit'd "dmdevlist" at dm_dev.c:443) and wchan dm_dev (dmv->dev_mtx, lockinit'd "dmdev" at dm_dev.c:238) — for 42 consecutive 2-second snapshots (84 s of frozen evidence, after which the guest was reset). Both dmsetup canaries were simultaneously wedged behind dm_dev_mutex, proving subsystem-wide loss: every dm ioctl, and therefore every I/O to any dm device, hangs. The parent's own deadlock-detector canary (pid 913, child of the duel parent) wedged too. ssh stayed alive in this variant; in an earlier manifestation the wedge also blocked new ssh sessions (pty allocation through devfs), i.e. practical impact reaches beyond dm.

Why this took several attempts (honest engineering log)

The same race surface is littered with other defects that pre-empted the deadlock interleaving and are all already-filed findings — see panics_observed.txt:

  1. kHz-rate table churn with failing reloads feeds the DF-4474 (dm_ioctl.c error-path prop leaks) leak into the per-malloc-type limit (ks_limit = RAM/10, kern_slaballoc.c:371-372) → panic: prop dictionary: malloc limit exceeded in ~16-100 s.
  2. The remove-hammer trips the DF-2443/DF-2447 dm_dev_remove no-ref-held UAF family (devstat_remove_entry NULL deref) unless remove/recreate is serialized.
  3. A's vn_open in its nlookup phase racing B's destroy_dev hits a devfs sysref_get-on-PUTAWAY assertion panic (reference_dev via v_associate_rdev).
  4. A raw-ioctl reproducer must resume the backing dm device (ENXIO until dmsetdiskinfo runs) or every stacked pdev open fails — which silently converts the PoC into the leak-panic generator of (1).

None of these change the verdict: with the churn tamed, the duel froze the kernel deterministically on the first properly-configured run.

Fix (fix.diff) and validation

Two-sided, breaking both edges:

  • dm_pdev.c: dm_pdev_insert no longer holds dm_pdev_mutex across vn_open/VOP_GETATTR/dev_dioctl. The lock is only taken for the list lookup and insertion; after opening the vnode the lock is re-taken and the list re-checked, a lost race closing the loser's vnode (lookup + ref under the lock, so no new references can appear once unlinked).
  • dm_dev.c: disable_dev only unlinks; the reference-drain cv_wait moved to dm_dev_wait_refs() called WITHOUT dm locks; dm_dev_remove and dm_dev_remove_all wait and destroy outside dm_dev_mutex (remove_all collects victims on a private TAILQ preserving reverse creation order).

Validation on the guest: make nativekernel + installkernel with fix.diff applied (kernel #1: Sat Sep 26 08:14:53 UTC 2026), then the exact same duel:

  • run 1 (90 s): counters raced (175,513 reloads / 9,257 removes in 50 s), no freeze, all processes exited cleanly;
  • run 2 (60 s): same;
  • functional sanity: dmsetup create s1 over vbd0, create s2 stacked over /dev/mapper/s1, dd write+read through /dev/mapper/s2 (OK), clean removes.

Baseline wedged within one 90 s run; patched kernel survived everything. See run_patched.log.

Bottom line

A root-triggerable (device-mapper ioctls on /dev/mapper/control, 0640 root:operator) permanent kernel deadlock: two syscalls (reload with a dm-on-dm backing concurrent with remove/remove_all) wedge the entire dm subsystem and any I/O to dm devices, requiring a reboot. Privilege bar keeps it at Medium (consistent with the other dm-family findings), but the loss is total and permanent once triggered.

Fix status (batch validation)

Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Applied fix.diff to the guest's /usr/src, rebuilt (make nativekernel) and installed the kernel, rebooted, re-ran the identical duel twice (90s + 60s at ~4.5k ioctls/s collision rate): no deadlock, no freeze, workers exit cleanly, dm-on-dm create/dd-I/O/remove all functional. Baseline (unpatched) wedged permanently within one 90s run (42 frozen ps snapshots in run.log).

run_patched.log; VERDICT.md 'Fix and validation' section; fix.diff
↓ fix.diffper-fix-DF-5037

Confirmed kernel references

Detail

Exploit chain

root: dmsetup-equivalent ioctls on /dev/mapper/control. (1) create pbase + load linear table over a raw disk + RESUME it (resume is required: until dm_dev_resume_ioctl->dmsetdiskinfo initializes the disk info the disk layer rejects opens of the node with ENXIO). (2) create stk. (3) P1: hammer reload(stk) with a linear table backed by /dev/mapper/pbase — each reload holds a dm_dev busy ref on stk for the whole ioctl and inside it takes dm_dev_mutex twice via vn_close(pbase)->dmclose->dm_dev_lookup and vn_open(pbase)->dmopen->dm_dev_lookup. (4) P2: hammer remove(stk) — dm_dev_remove_ioctl unbusies then dm_dev_remove takes dm_dev_mutex and disable_dev cv_waits for ref_cnt==0 still holding dm_dev_mutex. Interleave: P2 sleeps holding dm_dev_mutex waiting for P1's busy ref; P1 sleeps in dmclose/dmopen->dm_dev_lookup waiting for dm_dev_mutex. Permanent kernel deadlock of the whole dm subsystem; any I/O to any dm device hangs; unkillable D-state threads; reboot required.

Evidence (decisive lines)

run.log (full serial console of the wedged run: 42 consecutive 2s ps snapshots with 886 D0 dmdevlis dm_abba / 887 D3 dm_dev dm_abba / two wedged dmsetup canaries); ps_detail.txt (process tree incl. the parent's wedged deadlock-detector canary); run_patched.log (fix validation: counters race, clean exits, dm-on-dm dd I/O + removes OK on the patched kernel); panics_observed.txt (pre-empting known-bug manifestations on the same surface: DF-4474 leak->malloc-limit panic, DF-2443/2447-adjacent devfs sysref/devstat panics, ENXIO-until-resume gotcha); VERDICT.md (full narrative with path:line lock-order proof).

PoC changes

Rewrote the seeded approach entirely into a raw NETBSD_DM_IOCTL proplib client: struct plistref mirror (guest lacks sys/plistref.h), prop_dictionary_sendrecv_ioctl(dict, fd, cmd, &out) argument order, version array [4,16,0]. Three decisive discoveries vs the naive PoC: (1) the guest's remove-hammer trips the known DF-2443/2447 remove-path UAF (devstat_remove_entry NULL deref) unless remove/recreate is serialized with pacing; (2) kHz churn whose reloads fail ENOENT feeds the known DF-4474 prop leak into the per-malloc-type limit (RAM/10) and panics the guest in ~16-100s; (3) a dm node is unopenable (ENXIO from the disk layer) until dm_dev_resume_ioctl->dmsetdiskinfo runs, so the backing dm device MUST be create+load+resume'd or every stacked pdev vn_open fails — which silently turns the deadlock PoC into (2). With those tamed, the deterministic 'duel' (reload-vs-remove of a stacked device) froze the kernel on the first properly configured run.

Verified recommended fix

Do not hold dm_pdev_mutex across vn_open/VOP_GETATTR/dev_dioctl in dm_pdev_insert (lookup/insert under lock only, re-check after open, loser closes its vnode), and do not hold dm_dev_mutex across the ref-drain cv_wait or device destruction in dm_dev_remove/dm_dev_remove_all (wait/destroy outside the lock).

Verdict

dm_pdev_insert() holds the global dm_pdev_mutex EXCLUSIVE across vn_open()/VOP_GETATTR()/dev_dioctl() (dm_pdev.c:128-182); with dm-on-dm stacking vn_open re-enters dm via dmopen()->dm_dev_lookup() and needs dm_dev_mutex, while dm_dev_remove()/dm_dev_remove_all() hold dm_dev_mutex across disable_dev()'s ref-drain cv_wait and across dm_table_destroy()->target dtor->dm_pdev_decr()->dm_pdev_mutex: a textbook AB-BA inversion, root-triggerable with two ioctls (reload with a dm backing vs remove/remove_all). Reproduced on the stock guest: both workers froze in D state on the complementary locks (wchan dmdevlis vs dm_dev) for 84+ seconds of captured snapshots, all dm ioctls (dmsetup canaries) wedged behind dm_dev_mutex, unkillable, reboot required. Fixed by narrowing dm_pdev_mutex to lookup/insert (re-check after vn_open, loser closes its vnode) and by moving the ref-drain wait and device destruction out of dm_dev_mutex in dm_dev.c; the rebuilt kernel survived the identical duel (two runs, ~4.5k ioctls/s collision rate) with zero wedges and correct dm-on-dm I/O.