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

dm_target_uninit() tears down the target registry unconditionally: frees still-registered/still-referenced dm_target_t objects and lockuninit's the global lock β€” deterministic freed-module-text execution (Fatal trap 12), plus heap UAF of busied targets in the DF-4751 race window

Summary

No ref_cnt/registry validation in uninit (the EBUSY machinery exists in remove but is absent here) and dmdestroy ignores the return - compounded by kern_linker's veto-path refs-- underflow, kldunload dm fully unloads with dm_target_linear still registered. REPRODUCED on guest: seven sequential commands, zero timing, Fatal trap 12 executing freed dm.ko text. Registry-empty-veto fix validated (patched dm.ko returns EBUSY, linear unloads cleanly, guest survives).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5065 Β· 11 files
FileTypeDescriptionSize
dmt_registry_uaf.c β€” 3.7 KB view raw
run.sh β€” 147 B view raw
build.sh β€” 183 B view raw
fix.diff β€” 1.9 KB view raw
run.log β€” 1.3 KB view raw
panic.txt β€” 2.1 KB view raw
run_patched.log β€” 3.2 KB view raw
env.txt β€” 636 B view raw
README.md β€” 4.0 KB ↓ raw
VERDICT.md β€” 6.2 KB ↓ raw
verdict.json β€” 5.2 KB view raw

DF-5065 β€” dm_target_uninit() tears the dm target registry down unconditionally: frees still-registered / still-referenced dm_target_t objects and destroys the registry lock

File: sys/dev/disk/dm/dm_target.c:277-295 (with the entry path in sys/dev/disk/dm/device-mapper.c:187-197) Severity: Medium Β· Confidence: certain Β· Reproduced: yes β€” deterministic Fatal trap 12, no race, no timing (1/1 attempts).

Build (guest, as root)

# host: ship sources + prop headers, then in guest:
cc -O2 -Wall -I/tmp/propinc -o /tmp/dmt_registry_uaf dmt_registry_uaf.c -lprop

(build.sh automates both sides β€” the guest has libprop.so but no headers.)

Run (as root; /dev/mapper/control is 0640 root:operator)

sh /tmp/run.sh

Expected (baseline)

Seven sequential steps β€” no race, no timing:

# command effect
0 kldload dm dm.ko linker refs=1 (base), userrefs=1; registry holds builtin error/zero
1 dmt_registry_uaf create df5065dev dm_dev_counter 0β†’1 (dm_dev.c:193)
2 kldunload dm dm_modcmd EBUSY (device-mapper.c:161) β†’ MOD_UNLOAD veto β†’ linker_file_unload veto path executes file->refs-- with refs==1 (kern_linker.c:514-519) β†’ dm refs 1β†’0: a failed unload destroyed the base reference
3 dmt_registry_uaf remove df5065dev counter 1β†’0 (dm_dev.c:71)
4 kldload dm_target_linear MDT_DEPEND does dm refs++ β†’ 0β†’1; refs > 1 deferral (kern_linker.c:494) can never trigger again β†’ MODULE_DEPEND pin defeated; linear registered in dm's registry
5 kldunload dm refs==1 β†’ full unload attempt β†’ counter==0 β†’ dmdestroy() β†’ dm_target_uninit() (dm_target.c:284-292) frees linear's registered dm_target_t and lockuninits dm_target_mutex β€” then dm.ko text/data are freed
6 kldunload dm_target_linear dmtl_mod_handler(MOD_UNLOAD) β†’ dm_target_remove("linear") β†’ call into freed dm.ko text

Observed (panic.txt): console prints Device Mapper unloaded with linear still loaded, then

Fatal trap 12: page fault while in kernel mode
fault virtual address = 0xffffffff82604950
fault code = supervisor read instruction, page not present
instruction pointer = 0x8:0xffffffff82604950      <- freed dm.ko text region
current process = 870                             <- the kldunload syscall

1/1 deterministic; guest down at db>.

Fix validation (same guest, vm.sh reset with-src, fix.diff applied to

/usr/src, dm.ko rebuilt -Werror-clean, identical PoC)

  • step 5 kldunload dm β†’ "can't unload file: Device busy" β€” the patched dm_target_uninit returns EBUSY (registry non-empty), dmdestroy propagates it, dm.ko stays loaded (kldstat: Refs=0 but present).
  • step 6 kldunload dm_target_linear β†’ rc=0, clean unload through live dm text; the dependency cascade then unloads dm.ko correctly; STILL_UP.

fix_status: fixed (baseline panics, patched does not).

Relationship to known findings (not dupes)

  • DF-4751 (device-mapper.c, Low, reproduced): the unlocked counter check + unpinned dev dispatch race. This finding is the second, independent defect at a different site: dm_target_uninit performs no ref_cnt/registry validation at all (contrast dm_target_remove, dm_target.c:181). It (a) survives a DF-4751 fix β€” an ioctl holding a looked-up target ref (dm_ioctl.c:737) is left with a freed heap object β€” and (b) yields a deterministic, race-free chain via the kern_linker.c veto-path refs underflow. The registry check that this file is missing is precisely the second veto that would have stopped both.
  • DF-2437 (dm_target_delay.c): delay's handler ignoring dm_target_remove EBUSY. Here the EBUSY machinery itself is missing.
  • Cross-file note for the orchestrator: sys/kern/kern_linker.c:518 (file->refs-- on a MOD_UNLOAD veto, executed even at refs==1) is the enabler of the deterministic variant and is itself an accounting bug worth a pass-2 audit of kern_linker.c.
VERDICT.md
↓ download raw

DF-5065 VERDICT β€” reproduced (deterministic panic), fix validated

What was claimed

dm_target_uninit() (sys/dev/disk/dm/dm_target.c:277-295) tears down the global target registry with zero validation: it frees every registered dm_target_t regardless of ref_cnt (contrast dm_target_remove, which carefully returns EBUSY at dm_target.c:181-184) and lockuninits dm_target_mutex while registered/referenced targets exist. Its only caller is dmdestroy() (device-mapper.c:187-197), which ignores its return value and always returns 0 to MOD_UNLOAD.

Why it is exploitable β€” two reachable variants

Variant 1 β€” deterministic, race-free (the one reproduced here). linker_file_unload()'s veto path does file->refs-- even when refs==1 (sys/kern/kern_linker.c:514-519) β€” i.e. a failed kldunload dm (EBUSY because a dm device exists, device-mapper.c:161) destroys dm.ko's base reference. Trace, all verified line-by-line in-tree:

  1. kldload dm β€” dm.ko refs=1 (kern_linker.c:456), userrefs=1 (:1353); builtin error/zero registered via SYSINIT (dm_target_error.c / zero.c, DM_TARGET_BUILTIN).
  2. create a tableless device β€” dm_dev_counter 0β†’1 (dm_dev.c:193).
  3. kldunload dm β€” refs==1 β†’ not deferred (:494) β†’ module_unload β†’ dm_modcmd EBUSY β†’ veto β†’ refs-- 1β†’0 (:518); userrefs restored (:856).
  4. remove the device β€” counter 1β†’0 (dm_dev.c:71).
  5. kldload dm_target_linear β€” MDT_DEPEND refs++ β†’ dm refs 0β†’1 (kern_linker.c:1367); the refs > 1 deferral can never fire again.
  6. kldunload dm β€” refs==1 β†’ full unload attempt β†’ counter==0 β†’ dmdestroy() β†’ dm_target_uninit() frees linear's registered entry (heap) and lockuninit's the registry lock β†’ dm.ko unloaded, text freed.
  7. kldunload dm_target_linear β€” dm_target_remove("linear") executes freed dm.ko text β†’ Fatal trap 12.

The SYSUNINIT builtins (dm_builtin_uninit, device-mapper.c:617-624) also call dm_target_remove after the lock was destroyed β€” they survive only because lockuninit (kern_lock.c:1325-1333) doesn't poison the still-mapped lock and the list walks empty β€” benign by luck, same root cause.

Variant 2 β€” the DF-4751 race. Any ioctl thread holding a busied target (dm_ioctl.c:737 lookup, ref transferred to table_en->target at :759) when the counter check is passed leaves live tables with dangling dm_target_t pointers β†’ dm_table_destroy's table_en->target->destroy(...) (dm_table.c:150-157) is a heap UAF. This variant is not separately demonstrable on the guest (it requires the same interleaving as DF-4751 and crashes in whichever site faults first), but the code path is unambiguous: nothing in dm_target_uninit distinguishes ref_cnt>0 entries.

Reproduction

  • Baseline: vm.sh reset with-src β†’ build (BUILD_OK) β†’ sh /tmp/run.sh β†’ Fatal trap 12 on the first and only attempt (no threads, no retry loops, no timing). panic.txt: IP=0xffffffff82604950, "supervisor read instruction, page not present" β€” the freed dm.ko text region (dm.ko was mapped at 0xffffffff82600000, size 0x6000, per kldstat in the patched run; DF-4751's freed-text signatures were in the same region). Current process = the kldunload dm_target_linear syscall. Guest down at db>.
  • The console printed Device Mapper unloaded while dm_target_linear was loaded and registered β€” the smoking gun that uninit tore the registry down with a foreign module's entry in it.

Fix validation (fix.diff β€” dm_target.c + device-mapper.c, both in dm.ko)

  • Applied to the guest's /usr/src, make obj && make in sys/dev/disk/dm (-Werror clean), dm.ko installed, guest fresh otherwise.
  • Identical PoC (sh /tmp/run.sh): step 5 now prints kldunload: can't unload file: Device busy (dm_target_uninit β†’ EBUSY β†’ dmdestroy propagates β†’ dm_modcmd vetoes); kldstat shows dm.ko Refs=0 but loaded; step 6 kldunload dm_target_linear returns 0 cleanly through live text; the dependency cascade then unloads dm.ko itself (correct order); STILL_UP.
  • Design note: the fix also makes dmdestroy unregister the builtin error/zero targets explicitly before the registry check (their SYSUNINITs run after the MOD_UNLOAD handler, so without this the "registry must be empty" rule would make dm permanently unloadable). Normal unload keeps working β€” demonstrated by the clean cascade unload at the end of the patched run. Known cosmetic degradation: if the veto fires, error/zero stay unregistered while dm remains loaded (tables using them would then fail to load); strictly safer than the UAF, and only reachable in the corrupted-refs state.
  • fix_status: fixed β€” baseline panics, patched does not, exact same PoC.

Exploit chain ceiling

Trigger side needs root (kldload/kldunload; kern.securelevel must be ≀0 β€” it is -1 on the guest and by default). The ioctl side alone is operator-group reachable (0640 root:operator, device-mapper.c:181), but the deterministic chain needs the privileged unload. Impact: freed-kernel-text execution (deterministic) + freed-heap dm_target_t use (race variant) β€” a code-execution-class primitive, but groomed by root against root β‡’ Medium (deterministic kernel panic at minimum; the memcorrupt-class primitive is real and was demonstrated as a trap-12).

Honesty notes

  • attempts = 1 baseline + 1 patched; deterministic chain hit on the first try both times.
  • The kern_linker.c:518 refs-- enabler is out of this finding's file; the finding is the missing in-file validation (the second veto), and the fix stands alone: with it, the chain dies even with the linker bug present (proven on the guest). The linker bug should still get its own finding β€” it affects every module that vetoes unloads.
  • Guest reset with-src performed after the runs; no sys/ file in the audit tree was modified (fix.diff authored against the read-only tree, applied only inside the disposable guest).

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

fix.diff (dm_target.c non-empty-registry EBUSY + device-mapper.c explicit builtin unregistration and error propagation; both files ship in dm.ko) applied to a fresh with-src guest, module rebuilt -Werror-clean, installed; the IDENTICAL PoC no longer panics: the fatal kldunload dm is vetoed with 'Device busy' while dm_target_linear is registered, linear then unloads cleanly through live dm text, and the dependency cascade unloads dm.ko correctly afterwards (STILL_UP). Normal dm unload still works (demonstrated by the clean cascade at end of patched run).

run_patched.log (full trace: veto at step 5, clean unload at step 6, STILL_UP); fix.diff; VERDICT.md fix-validation section
↓ fix.diffper-fix-DF-5065

Confirmed kernel references

Detail

Exploit chain

kldload dm -> create device (counter=1) -> kldunload dm (EBUSY veto; linker veto-path refs-- destroys dm.ko's base ref) -> remove device (counter=0) -> kldload dm_target_linear (dep ref on underflowed dm) -> kldunload dm (full unload: registry freed with linear's entry in it, dm.ko freed) -> kldunload dm_target_linear -> call into freed dm.ko text -> Fatal trap 12. Escalation ceiling: freed-kernel-text execution primitive groomed by root against root; treated as deterministic-panic + memcorrupt-class correctness bug (Medium).

Evidence (decisive lines)

panic.txt (Fatal trap 12, IP in freed dm.ko text, 'Device Mapper unloaded' printed with linear loaded); run.log (chain narration); run_patched.log (identical PoC on fix.diff: kldunload dm vetoed 'Device busy', linear unloads cleanly, guest STILL_UP); fix.diff (dm_target.c + device-mapper.c, -Werror clean module build); VERDICT.md (full line-by-line trace + fix-validation narrative)

PoC changes

Harness is a de-threaded adaptation of the DF-4751 proplib client (create/remove only); the deterministic chain replaced DF-4751's churn/race with seven sequential shell steps; fix.diff regenerated mechanically after hand-written hunk counts failed patch --dry-run.

Verified recommended fix

dm_target_uninit: return EBUSY when the registry is non-empty (any registered or referenced target) instead of freeing everything + lockuninit; dmdestroy: unregister builtin error/zero targets explicitly before the check and propagate uninit's error so MOD_UNLOAD vetoes.

Verdict

Reproduced 1/1 deterministic (no race, no timing): on a fresh with-src guest, seven sequential steps -- kldload dm; create tableless device; kldunload dm (EBUSY veto, kern_linker.c:518 refs-- 1->0); remove device; kldload dm_target_linear (dep ref 0->1, MODULE_DEPEND pin permanently defeated); kldunload dm (SUCCEEDS: dm_target_uninit at dm_target.c:284-287 frees linear's registered dm_target_t and lockuninit's the registry, dm.ko text freed); kldunload dm_target_linear -- executes dm_target_remove() through freed dm.ko text: Fatal trap 12, IP=0xffffffff82604950 (supervisor read instruction, page not present; dm.ko was mapped at 0xffffffff82600000), guest down at db>. Root cause is in-file: dm_target_uninit performs no ref_cnt/registry validation at all (contrast dm_target_remove's EBUSY at dm_target.c:181) and its caller dmdestroy() ignores its return. Race variant: an ioctl holding a looked-up target ref (dm_ioctl.c:737) when MOD_UNLOAD slips past the counter check gets a freed heap dm_target_t used by dm_table_destroy (dm_table.c:150-157) -- same missing-validation defect, not separately demonstrable from DF-4751's crash sites. Trigger needs root (kldload/kldunload, securelevel<=0); ioctl side is operator-group reachable.