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)
PoC verification
Evidence pack
findings/poc/DF-5037 · 13 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 |
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'svn_open(holdsdm_pdev_mutex, blocked indm_dev_lookupondm_dev_mutex) whileremove_all/remove(holdsdm_dev_mutex) reaches any target dtor →dm_pdev_decr→ blocks ondm_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 ondm_dev_mutex(its old-table destroy doesdm_pdev_decr→dm_pdev_free→vn_close(/dev/mapper/X)→dmclose→dm_dev_lookup; its ctor doesvn_open→dmopen→dm_dev_lookup). Concurrentlyremoveof that same device sleeps indisable_dev's cv_wait holdingdm_dev_mutexwaiting 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: untildm_dev_resume_ioctl→dmsetdiskinfoinitializes the disk media info, the disk layer rejects opens of the node with ENXIO and every stacked pdev open would fail), thencreate stk. - P1 hammers
reload stkwith 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 onstk). - P2 hammers
remove stk(2-5 ms pacing). - Watchdog detects both counters frozen >6 s; ps-watcher and
dmsetup lscanaries 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:
- 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 exceededin ~16-100 s. - The remove-hammer trips the DF-2443/DF-2447 dm_dev_remove no-ref-held UAF
family (
devstat_remove_entryNULL deref) unless remove/recreate is serialized. - 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).
- A raw-ioctl reproducer must
resumethe 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_insertno longer holdsdm_pdev_mutexacrossvn_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_devonly unlinks; the reference-drain cv_wait moved todm_dev_wait_refs()called WITHOUT dm locks;dm_dev_removeanddm_dev_remove_allwait and destroy outsidedm_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 s1over vbd0,create s2stacked 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
fixedApplied 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
Confirmed kernel references
- sys/dev/disk/dm/dm_pdev.c:128
- sys/dev/disk/dm/dm_pdev.c:143
- sys/dev/disk/dm/dm_pdev.c:168
- sys/dev/disk/dm/dm_pdev.c:249
- sys/dev/disk/dm/dm_dev.c:105
- sys/dev/disk/dm/dm_dev.c:66
- sys/dev/disk/dm/dm_dev.c:308
- sys/dev/disk/dm/dm_dev.c:326
- sys/dev/disk/dm/device-mapper.c:200
- sys/dev/disk/dm/dm_ioctl.c:711
- sys/dev/disk/dm/dm_table.c:136
- sys/dev/disk/dm/linear/dm_target_linear.c:67
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.
No comments yet.