dm MOD_UNLOAD: unlocked dm_dev_counter check plus unpinned ops dispatch β kldunload racing a control ioctl executes freed module text (fatal trap 12)
Summary
MOD_UNLOAD reads dm_dev_counter unlocked (in-source 'race window here') vs dm_dev_insert's locked increment; compounding framework gap: dev_dioctl dispatches via si_ops with no ops/module ref and destroy_dev's dead-ops revector is async - an in-flight dmioctl keeps executing freed module text. REPRODUCED 4/4 on guest (dev_dioctl+0x1b freed-ops read, IP=0x0, dmioctl+0x2bf inside freed handler). Two driver-side fix attempts honestly recorded fix_failed - the residual window is the framework dispatch gap (affects every loadable driver); real fix = ops/module ref across dev_d* dispatch.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-4751 Β· 12 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 1.7 KB | β raw | |
| VERDICT.md | β | 4.5 KB | β raw | |
| dm_unloadrace.c | β | 4.0 KB | view raw | |
| build.sh | β | 121 B | view raw | |
| run.sh | β | 114 B | view raw | |
| run.log | β | 1.3 KB | view raw | |
| panic.txt | β | 2.3 KB | view raw | |
| env.txt | β | 892 B | view raw | |
| fix.diff | β | 1.3 KB | view raw | |
| fix_build.log | β | 1.7 KB | view raw | |
| manifest.json | β | 1.6 KB | view raw | |
| verdict.json | β | 3.9 KB | view raw |
DF-4751 β dm MOD_UNLOAD: unlocked counter check + unpinned ops dispatch β freed-module-text execution
Build (guest, as root)
cc -O -Wall -o /tmp/dm_unloadrace dm_unloadrace.c -lprop -lpthread
Run (as root; /dev/mapper/control is 0640 root:operator)
kldload dm # if not loaded
/tmp/dm_unloadrace 30 # churn vs kldunload hammer
Expect a kernel Fatal trap 12 within seconds-to-a-minute; serial console
(panic.txt) shows the panic; ssh dies; guest is down at the ddb> prompt.
What it proves
Two compounding windows in dm_modcmd(MOD_UNLOAD)
(sys/dev/disk/dm/device-mapper.c:154-169):
if (dm_dev_counter > 0) return EBUSY;reads the counter with no lock whiledm_dev_insert()increments it underdm_dev_mutex(sys/dev/disk/dm/dm_dev.c:193) β unload proceeds with live devices (console showsmalloc_uninit: 1984 bytes of 'dm' still allocated).- Neither
destroy_dev(dmcdev)(asynchronous revector via the devfs core, sys/vfs/devfs/devfs_core.c:755-765) nor the kld framework pins the module against ioctls already dispatched throughdev->si_ops->d_ioctl(sys/kern/kern_device.c:258) β the linker frees module text under the running ioctl.
Fix-validation history (all evidence in this pack)
- fix v1 (destroy_dev first + re-arm on counter>0): still panics β freed
module text executed (
Stopped at -0x7d9fefd7, run 3). - fix v2 (v1 +
sync_devs()): still panics βStopped at dmioctl+0x2bf(run 4). In-driver fixes cannot close a framework-level dispatch window.
Logs
run.log (churn), panic.txt (all four trap signatures), fix_build.log, env.txt, fix.diff (v2 β best-effort shrink, NOT sufficient standalone).
DF-4751 β VERDICT
Status: REPRODUCED (kernel panic, use-after-free of module text; four
distinct crash signatures captured across baseline AND two attempted in-driver
fixes β the fixes demonstrably do NOT close it: fix_failed).
Impact: privileged local kernel panic / potential code-execution-class UAF.
Severity Low (trigger requires root: kldunload dm on one side; the ioctl
side alone is operator-group reachable but cannot panic the box alone).
Root cause (two compounding windows)
Window 1 β the admitted counter race.
dm_modcmd(MOD_UNLOAD) (sys/dev/disk/dm/device-mapper.c:161-165) reads
dm_dev_counter with no lock while the only writer, dm_dev_insert()
(sys/dev/disk/dm/dm_dev.c:193), increments under dm_dev_mutex. The
source even carries the comment /* race window here */. When an ioctl
"create" slips past the check, unload proceeds with live devices:
every crashing run's console shows
malloc_uninit: 1984 bytes of 'dm' still allocated β allocations that
outlived dmdestroy().
Window 2 β unpinned ops dispatch (framework-level).
dev_dioctl() (sys/kern/kern_device.c:237-260) executes
dev->si_ops->d_ioctl(&ap) with no reference on the ops or module, and
destroy_dev()'s revector to dead_dev_ops happens asynchronously via the
devfs core (sys/vfs/devfs/devfs_core.c:755-765: "asynchronous entry point").
So a NETBSD_DM_IOCTL already executing dmioctl
(sys/dev/disk/dm/device-mapper.c:241) keeps running module text after the
linker frees dm.ko β Fatal trap 12.
Reproduction
dm_unloadrace (proplib ioctl client, adapted from the DF-4473 harness):
thread A cycles createβremove on df4751dev (counter oscillates 0β1, control
fd opened per iteration); thread B loops kldunload dm / kldload dm.
30-45 s runs on the QEMU guest:
- run 1 (stock): panic at
dev_dioctl+0x1bβ read through freed ops. - run 2 (fresh
vm.sh reset with-srcguest, stock): panicIP = 0x0β call through zeroed/freed ops entry. - run 3 (fix v1:
destroy_dev(dmcdev)before the counter check + re-arm on counter>0): STILL panics, executing freed module text (0xffffffff82601029). - run 4 (fix v2: v1 +
sync_devs()β the kern_conf.c-documented unload contract): STILL panics βStopped at dmioctl+0x2bfβ i.e. an ioctl already insidedmioctlwhen the module text was freed.
vm.sh status β down after each crashing run; full signatures in panic.txt.
Why the in-driver fixes failed (and what the real fix is)
sync_devs() (sys/kern/kern_conf.c:349-362) only waits for the devfs core
message queue; it cannot observe a thread that has already loaded
si_ops->d_ioctl and is executing it. Window 2 is a dev-dispatch framework
gap that no dm_modcmd reordering can close. Correct remediation:
- Framework: hold a reference on
dev_ops(and its module) across everydev_d*dispatch β acquire incompile_dev_ops/make_dev, per-call acquire/release indev_dioctland siblings; or revector synchronously (devfs_msg_send_sync) and make the linker honor a per-open-cdev module reference sokldunloadreturns EBUSY while any control fd is open. - Driver-side (defense in depth, shrinks but does not close): the v2 diff in
fix.diff β destroy +
sync_devs()+ stable counter check + re-arm β plus an in-flight-ioctl drain (enter/exit bracket counter that MOD_UNLOAD waits on).
Threat
Root can already crash the box many ways, so the panic itself is Low. The interesting ceiling: the freed kld region is returned to the linker allocator; execution continuing in freed-but-mapped text is a code-execution-class UAF β with a groomed reuse it is in principle more than a panic, but that requires root-timed racing against one's own kldunload, so no unprivileged chain exists. Filed for correctness and because the same framework gap (unpinned ops dispatch) affects every loadable driver, incl. nmdm.ko (no MOD_UNLOAD handler at all).
Honest notes
- 40+ unloads typically succeed benignly before the interleaving hits; the crash usually lands within 30-60 s of churn (4/4 runs crashed: 2 baseline, 2 patched).
- The churn thread is the DF-4473 client trimmed to create/remove only β no table/target modules involved, so the panic is core-dm, not target code.
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
fix_failedTwo in-driver fixes were built as dm.ko and A/B-tested with the identical PoC: v1 (destroy_dev before counter check + re-arm on counter>0) still panicked (run 3, freed-text exec); v2 (v1 + sync_devs() per the kern_conf.c unload contract) still panicked INSIDE dmioctl (run 4, dmioctl+0x2bf) because sync_devs only drains the devfs message queue, not calls that already loaded si_ops->d_ioctl. The window is a dev-dispatch framework gap; fix.diff (v2) is kept as a documented best-effort shrink, NOT a complete fix.
panic.txt runs 3 and 4; fix_build.log; VERDICT.md 'Why the in-driver fixes failed'
Confirmed kernel references
Detail
Exploit chain
No unprivileged chain: kldunload requires root. Ceiling is a code-execution-class UAF (execution continues in freed kld text) groomed by root against itself; treated as correctness + framework hardening (gap affects every loadable driver, incl. nmdm.ko).
Evidence (decisive lines)
panic.txt (all four Fatal trap 12 signatures + malloc_uninit lines); run.log (40 benign unloads then crash); fix_build.log (patched dm.ko build); VERDICT.md fix-validation section (v1 destroy_dev+re-arm and v2 +sync_devs both still panic)
PoC changes
Harness adapted from the DF-4473 proplib client (create/remove only, no targets); header comment de-nested to compile; include order fixed (sys/types.h first).
Verified recommended fix
Framework: reference dev_ops (and its module) across every dev_d* dispatch or make the revector synchronous + kldunload respect per-open-cdev module refs; driver-side shrink: fix.diff (destroy+sync_devs+stable counter+re-arm) plus an in-flight-ioctl drain that MOD_UNLOAD waits on.
Verdict
Reproduced 4/4 on the QEMU guest (2 baseline + 2 with patched modules): a root-driven create/remove churn on /dev/mapper/control racing kldunload dm reliably panics the kernel with Fatal trap 12 executing freed module text (signatures: dev_dioctl+0x1b read of freed ops; IP=0x0 call through zeroed ops; freed-text exec at 0xffffffff82601029; dmioctl+0x2bf inside the freed handler with the sync_devs()-augmented fix installed). Root cause is two-fold: (1) dm_modcmd(MOD_UNLOAD) reads dm_dev_counter unlocked (device-mapper.c:161, 'race window here') vs dm_dev_insert()'s locked increment (dm_dev.c:193) β every crash log shows 'malloc_uninit: 1984 bytes of dm still allocated' proving devices outlived the check; (2) framework-level: dev_dioctl dispatches through dev->si_ops with no ops/module reference (kern_device.c:258) and destroy_dev's dead_dev_ops revector is asynchronous (devfs_core.c:759), so an ioctl already executing dmioctl survives into freed text. Trigger requires root (kldunload) on one side; ioctl side alone is operator-group reachable β hence Low severity, panic impact.
No comments yet.