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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4751 Β· 12 files
FileTypeDescriptionSize
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):

  1. if (dm_dev_counter > 0) return EBUSY; reads the counter with no lock while dm_dev_insert() increments it under dm_dev_mutex (sys/dev/disk/dm/dm_dev.c:193) β€” unload proceeds with live devices (console shows malloc_uninit: 1984 bytes of 'dm' still allocated).
  2. 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 through dev->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).

VERDICT.md
↓ download raw

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-src guest, stock): panic IP = 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 inside dmioctl when 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 every dev_d* dispatch β€” acquire in compile_dev_ops/make_dev, per-call acquire/release in dev_dioctl and siblings; or revector synchronously (devfs_msg_send_sync) and make the linker honor a per-open-cdev module reference so kldunload returns 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_failed
baseline reproduced→ patch + rebuild →patched reproduced

Two 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'
↓ fix.diffper-fix-DF-4751

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.