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

Module-unload handler destroys shared objcache even when dm_target_remove fails EBUSY causing NULL-deref panic on subsequent I/O

Summary

dmtd_mod_handler MOD_UNLOAD case calls _objcache_destroy() unconditionally instead of only on successful unload. When delay target still loaded dm_target_remove returns EBUSY at :441 but _objcache_destroy() at :444 still runs freeing global obj_cache and setting it NULL. Module handler returns EBUSY so module stays registered but obj_cache gone. Next I/O against existing delay target reaches _strategy :245 objcache_get(obj_cache M_WAITOK) and objcache_get dereferences oc->cache_percpu[mycpuid] with oc==NULL panicking kernel. Attacker: privileged root or principal able to open /dev/mapper/control and issue kldunload2(2). Trigger: load dm_target_delay create delay table kldunload dm_target_delay (EBUSY but corrupts state) then any subsequent I/O panics.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2437 Β· 10 files
FileTypeDescriptionSize
poc.c trigger-source libprop NETBSD_DM_IOCTL: create/reload/resume/teardown delay device 5.8 KB view raw
run.sh run-script orchestrates kldload + setup + I/O + kldunload + crash-trigger 2.1 KB view raw
build.sh build-script cc -O2 -o poc poc.c -lprop 62 B view raw
run.log run-log baseline unpatched run: SSH hangs, guest panicked 757 B view raw
fix_run.log run-log patched-module run: EBUSY, no panic, guest stays up 966 B view raw
fix_build.log build-log single-fix dm_target_delay.ko module build output (rc=0) 275 B view raw
fix.diff suggested-fix move _objcache_destroy() inside if(err==0) block 415 B view raw
panic.txt panic-signature Fatal trap 12 / objcache_put+0x3a / fault from destroyed objcache 337 B view raw
env.txt environment uname, cc, kern.version, /dev/mapper/control perms 326 B view raw
VERDICT.md verdict full mechanism walkthrough + Phase 8 fix validation 5.7 KB ↓ raw
VERDICT.md verdict full mechanism walkthrough + Phase 8 fix validation
↓ download raw

DF-2437 β€” dm_target_delay module-unload handler destroys shared objcache

Verdict

REPRODUCED (panic / local DoS) + FIX VALIDATED. The bug is real and deterministically crashes the kernel when kldunload dm_target_delay is attempted while a delay dm device is active. The MOD_UNLOAD handler calls _objcache_destroy() unconditionally β€” even when dm_target_remove returns EBUSY (active device). The objcache is destroyed (set to NULL) while the module stays loaded and devices keep doing I/O. The next objcache_put(NULL, ...) in the delay I/O path page-faults β†’ panic. The escalation to uid=0 is blocked by a valid hard blocker: this is a NULL-deref (read/write fault on destroyed/freed objcache) reachable only by root (kldunload + dm control device are root-only), with no attacker-controlled write primitive. Realistic impact ceiling: local DoS (root/operator can panic the kernel). The authored fix.diff is built as a single-fix dm_target_delay.ko module, installed, and confirmed to close the bug (panic β†’ clean EBUSY, guest stays up).

Mechanism (trigger β†’ primitive β†’ effect)

dmtd_mod_handler() in sys/dev/disk/dm/delay/dm_target_delay.c:

 440: case MOD_UNLOAD:
 441:     err = dm_target_remove("delay");
 442:     if (err == 0)
 443:         kprintf("dm_target_delay: unloaded\n");
 444:     _objcache_destroy();     <-- UNCONDITIONAL! runs even when err=EBUSY
 445:     break;

_objcache_destroy() (lines 403–410):

 403: static void
 404: _objcache_destroy(void)
 405: {
 406:     if (obj_cache) {
 407:         objcache_destroy(obj_cache);   <-- frees the objcache
 408:         obj_cache = NULL;              <-- sets global to NULL
 409:     }
 410: }

The sequence

  1. A delay dm device is created and its table loaded+resumed. The table entry holds a reference to the delay target (dm_target_lookup in dm_table_load_ioctl at dm_ioctl.c:737 increments ref_cnt). The delay kernel thread (dmdl0) is running.
  2. kldunload dm_target_delay calls the module's MOD_UNLOAD handler. dm_target_remove("delay") finds ref_cnt > 0 β†’ returns EBUSY (dm_target.c:181-184). The target is NOT removed.
  3. But _objcache_destroy() runs ANYWAY (line 444 is outside the if block). The global obj_cache is freed and set to NULL.
  4. The handler returns EBUSY, so the linker aborts the unload β€” the module stays loaded. But the objcache is gone.
  5. The next I/O on the still-active delay device enters _strategy (dm_target_delay.c:241) which calls objcache_get(obj_cache=NULL, ...). At kern_objcache.c:429: c struct percpu_objcache *cpucache = &oc->cache_percpu[mycpuid]; With oc==NULL, this dereferences NULL β†’ page fault. Alternatively, the delay kernel thread's _submit_queue calls objcache_put(obj_cache=NULL, dp) (dm_target_delay.c:307) which also crashes.

Observed crash signature (boot.log)

Fatal user address access from kernel mode from dd at ffffffff8064b175
Fatal trap 12: page fault while in kernel mode
cpuid = 2; Stopped at      objcache_put+0x3a:      addq    $0x1,0xa8(%r12)
db>

The crash is at objcache_put+0x3a (addq $0x1,0xa8(%r12)) β€” incrementing a statistics counter at offset 0xa8 in the (now-destroyed/NULL) objcache structure. %r12 holds the NULL/invalid objcache pointer.

Trigger

  1. kldload dm; kldload dm_target_delay
  2. Create a delay dm device: command="create", name="df2437".
  3. Reload a delay table with delay > 0: command="reload" with target type "delay", params /dev/md0 0 100 (100ms read delay).
  4. Resume: command="resume" β€” makes the table ACTIVE, device usable.
  5. Generate I/O on /dev/mapper/df2437 (e.g. dd if=/dev/mapper/df2437).
  6. kldunload dm_target_delay β†’ objcache destroyed (EBUSY returned, module stays loaded).
  7. More I/O (or the delay thread processing queued bufs) β†’ crash.

Privilege analysis β€” why this is DoS, not privesc

  1. Module load: reaching the dm ioctl requires kldload dm, root-only.
  2. Module unload: kldunload is also root-only (PRIV_KLD_LOAD).
  3. Device node: /dev/mapper/control is 0640 root:operator (device-mapper.c:181).
  4. The primitive is a NULL/destroyed-pointer deref β€” no attacker-controlled bytes are written before the trap.

Valid hard blocker for Phase 6.

Exploit chain

none β€” NULL/destroyed-objcache deref panic, no write primitive (valid hard blocker).

Fix (fix.diff)

Move _objcache_destroy() inside the if (err == 0) block so the objcache is only destroyed when the unload actually succeeds (target removed, no active devices):

case MOD_UNLOAD:
    err = dm_target_remove("delay");
    if (err == 0) {
        kprintf("dm_target_delay: unloaded\n");
        _objcache_destroy();
    }
    break;

Fix validation (Phase 8)

  1. Baseline (with-src, kernel 6.5-DEVELOPMENT #0, unpatched dm_target_delay.ko): PoC panics deterministically β€” Fatal trap 12, Stopped at objcache_put+0x3a. Guest in DDB.
  2. Patched: applied fix.diff to /usr/src/sys/dev/disk/dm/delay/dm_target_delay.c, built the module alone, installed dm_target_delay.ko β†’ /boot/kernel/, reloaded.
  3. Re-run (Γ—2): same PoC sequence β€” kldunload dm_target_delay returns kldunload: can't unload file: Device busy (EBUSY). I/O continues normally. No panic. Guest stays up. Deterministic.
  4. Verdict: fix closes the bug (panic β†’ clean EBUSY, guest survives).

PoC

poc.c β€” libprop NETBSD_DM_IOCTL helper for create/reload/resume/teardown. run.sh β€” orchestrates the full trigger sequence (kldload, setup, I/O, kldunload, more I/O).

Build: cc -O2 -o poc poc.c -lprop Run (as root): ./run.sh

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

VALIDATED: PoC panics deterministically on unpatched dm_target_delay.ko baseline (Fatal trap 12, Stopped at objcache_put+0x3a) and does NOT panic on single-fix module (kldunload returns 'Device busy', objcache preserved, I/O continues, guest stays up). Ran twice β€” deterministic. Fix closes the bug.

baseline: Fatal trap 12 / Stopped at objcache_put+0x3a: addq $0x1,0xa8(%r12) / db> (guest dead). patched: kldunload: can't unload file: Device busy / guest up, I/O continues normally.
↓ fix.diffDragonFly 6.5-DEVELOPMENT #0 (same kernel, module-only rebuild of dm_target_delay.ko)

Confirmed kernel references

Detail

Exploit chain

none β€” NULL/destroyed-objcache deref panic, no write primitive (valid hard blocker). kldunload root-only (PRIV_KLD_LOAD), dm control device root/operator-only. No corruption to groom, no escalation chain possible.

Evidence (decisive lines)

Fatal user address access from kernel mode from dd at ffffffff8064b175 / Fatal trap 12: page fault while in kernel mode / cpuid = 2; Stopped at objcache_put+0x3a: addq $0x1,0xa8(%r12) / db>. AFTER FIX: kldunload: can't unload file: Device busy / guest stays up, no panic.

PoC changes

Created poc.c (libprop NETBSD_DM_IOCTL helper for create/reload/resume/teardown of delay device) and run.sh (orchestrates kldload + setup + background dd I/O + kldunload dm_target_delay + crash trigger). Trigger sequence: create delay device with 100ms read delay, resume, start dd reads, then kldunload dm_target_delay which destroys objcache despite EBUSY. build.sh, VERDICT.md, fix.diff, manifest.json.

Verified recommended fix

Move _objcache_destroy() inside 'if (err == 0)' block in MOD_UNLOAD case of dmtd_mod_handler (dm_target_delay.c:441-444). Ensures objcache only destroyed when unload actually succeeds (dm_target_remove returned 0 = no active devices), preventing objcache from being destroyed while module still loaded and devices active. Supersedes finding proposal (none existed).

Verdict

REPRODUCED. The bug is real and deterministic: the MOD_UNLOAD handler in dmtd_mod_handler (dm_target_delay.c:440-445) calls _objcache_destroy() UNCONDITIONALLY β€” even when dm_target_remove returns EBUSY (active delay device). The objcache is freed and set to NULL while the module stays loaded and devices keep doing I/O. The next objcache_get/put(NULL,...) page-faults. Confirmed by panic: Fatal trap 12, Stopped at objcache_put+0x3a (addq $0x1,0xa8(%r12) with r12=NULL/freed).