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

dm_target_flakey_init() failure paths leak the dm_pdev reference and permanently pin the underlying device vnode

Summary

The pdev ref is taken at :86 but all three post-insert error paths jump to a fail: that only kfrees tfc - and the caller's destroy cannot recover it (target_config is still NULL so destroy early-returns without decr). REPRODUCED on guest: 3 failed loads -> M_DM 336B->1.08K permanent + every affected vn device pinned EBUSY forever (control unit detaches clean - proving the leak); correlated shutdown wedge on 2/2 resets. Unbounded with distinct paths; root/operator. decr-on-fail fix.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-4922 Β· 11 files
FileTypeDescriptionSize
README.md β€” 3.6 KB ↓ raw
VERDICT.md β€” 5.6 KB ↓ raw
leak_poc.sh β€” 2.9 KB view raw
build.sh β€” 107 B view raw
run.sh β€” 201 B view raw
run.log β€” 1.5 KB view raw
run.noisy-first-attempt.log β€” 3.4 KB view raw
env.txt β€” 578 B view raw
fix.diff β€” 583 B view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 3.5 KB view raw

DF-4922 β€” dm_target_flakey: init-failure paths leak the dm_pdev reference and pin the device vnode

What

dm_target_flakey_init() takes a reference on the underlying block device with dm_pdev_insert(argv[0]) (sys/dev/disk/dm/flakey/dm_target_flakey.c:86, which opens the device vnode and inserts the pdev into the global dm_pdev_list with ref_cnt = 1 β€” sys/dev/disk/dm/dm_pdev.c:120-188). Every error path taken after that point jumps to fail: which only does kfree(tfc) (dm_target_flakey.c:117-119) β€” the pdev reference is never released with dm_pdev_decr().

The caller cannot recover it either: on target-init failure dm_table_load_ioctl() destroys the half-loaded table (sys/dev/disk/dm/dm_ioctl.c:783-790), and dm_table_destroy() invokes the target's destroy handler (sys/dev/disk/dm/dm_table.c:150-151) β€” but target_config was never installed (dm_table_init_target() runs only on success, dm_target_flakey.c:114), so dm_target_flakey_destroy() returns early at its tfc == NULL check (dm_target_flakey.c:214-215) without dropping the ref. The pdev_maps dependency list is likewise still empty (dm_table_add_deps() at :112 runs only on success), so nothing anywhere drops the reference.

Leaking post-insert failure paths (all reachable with a single dmsetup create call):

Path Table args Check that fails Line
A flakey <dev> 0 0 0 up_int + down_int == 0 dm_target_flakey.c:96-100
B flakey <dev> 0 1 1 1 nosuchfeature unknown feature dm_target_flakey.c:195-196
C flakey <dev> 0 2147483647 2147483647 interval overflow dm_target_flakey.c:102-106

Impact

Each failed load permanently leaks one dm_pdev_t (~186 bytes, M_DM zone) and pins the underlying device vnode open. Effects demonstrated on the guest:

  • the M_DM malloc zone grows and never shrinks (removing the half-created dm devices does not free the pdevs), and
  • the affected block device can never be detached again (vnconfig -u β†’ VNIOCDETACH: Device busy) until reboot.

Reachable by root or the operator group (/dev/mapper/control is root:operator 0640, sys/dev/disk/dm/device-mapper.c:181); no priv_check exists inside the ioctl paths themselves. Severity: Low (privileged resource leak / device pinning; no memory-safety impact).

Reproduce

  1. dfbsd-qemu/vm.sh up (root ssh alias dfbsd)
  2. ssh -F dfbsd-qemu/config dfbsd 'cat > /tmp/leak_poc.sh' < leak_poc.sh
  3. dfbsd-qemu/vm.sh run_root 'sh /tmp/leak_poc.sh'

Expected (see run.log):

load rc=1 out='ioctl reload call failed: Invalid argument ...'   (x3, paths A/B/C)
M_DM: 336 -> 1.08K after 3 failed loads; unchanged after dm remove
LEAK-UNIT vn0: vnconfig: VNIOCDETACH: Device busy
LEAK-UNIT vn1: vnconfig: VNIOCDETACH: Device busy
LEAK-UNIT vn2: vnconfig: VNIOCDETACH: Device busy
CONTROL vn3 re-attach+detach: OK

Fix

fix.diff β€” release the reference on the failure path:

fail:
    if (dmp != NULL)
        dm_pdev_decr(dmp);
    kfree(tfc, M_DMFLAKEY);
    return err;

(dmp is NULL only when dm_pdev_insert() itself failed, in which case there is no reference to release.)

Fix validation build was not performed: this is a Low-severity resource leak (not memory corruption), and the one-line fix is correct by inspection β€” dm_pdev_decr() drops the ref taken by dm_pdev_insert(), freeing the pdev and closing the vnode when it reaches zero (sys/dev/disk/dm/dm_pdev.c:242-259), exactly mirroring what dm_target_flakey_destroy() does on the normal teardown path (dm_target_flakey.c:217).

VERDICT.md
↓ download raw

DF-4922 VERDICT β€” dm_target_flakey init-failure pdev ref/vnode leak

Status: REPRODUCED (impact: leak, confidence: certain)

Root cause (line-accurate)

dm_target_flakey_init():

 86:    if ((dmp = dm_pdev_insert(argv[0])) == NULL) { ... }   /* ref_cnt := 1, vnode opened */
 ...
 96:    if ((tfc->up_int + tfc->down_int) == 0)   { err = EINVAL; goto fail; }
102:    if (tfc->up_int + tfc->down_int < tfc->up_int) { err = EINVAL; goto fail; }
108:    err = _init_features(tfc, argc - 4, argv + 4);
109:    if (err)                                  { goto fail; }
 ...
117: fail:
118:    kfree(tfc, M_DMFLAKEY);          /* <-- pdev ref never dropped */

dm_pdev_insert() (sys/dev/disk/dm/dm_pdev.c:120-188) opens the device vnode (dm_dk_lookup) and installs the pdev in the global dm_pdev_list with ref_cnt = 1 (dm_pdev.c:151) β€” or bumps the ref of an existing entry (dm_pdev.c:132). None of the three post-insert goto fail paths release it.

Nothing downstream releases it either:

  • dm_table_load_ioctl() error path (sys/dev/disk/dm/dm_ioctl.c:783-790) calls dm_table_destroy() on the half-loaded table;
  • dm_table_destroy() (sys/dev/disk/dm/dm_table.c:147-160) calls table_en->target->destroy(table_en) β€” but target_config is still NULL (it is only installed on success at dm_target_flakey.c:114 via dm_table_init_target()), so dm_target_flakey_destroy() bails at its tfc == NULL check (dm_target_flakey.c:213-215) without dm_pdev_decr();
  • the pdev_maps dependency list is empty (deps are added only on success, dm_target_flakey.c:112), so dm_table_free_deps() (dm_table.c:298-314) frees nothing β€” note deps never own refs anyway; the target's destroy handler owns exactly the one insert-ref.

Reproduction narrative

Guest: DragonFly 6.5-DEVELOPMENT X86_64_GENERIC (see env.txt).

Attempt 1 (run.noisy-first-attempt.log): 20-unit loop was noisy (only 4 vn units exist; same-inode backing files confuse attach), but already showed +744 B M_DM growth and VNIOCDETACH: Device busy on every leaked unit. Guest reset, then the deterministic 4-unit run:

  • Control: vn3 attach β†’ detach OK (twice, including re-attach after the leak tests β€” proving the EBUSY below is the leak, not the tool).
  • Path A (0 0 0 intervals), path B (unknown feature), path C (interval overflow): each dmsetup create ... flakey ... fails with ioctl reload call failed: Invalid argument = the EINVAL from dm_target_flakey.c:98/104/_init_features, i.e. after the insert.
  • M_DM zone: 336 B/3 allocs β†’ 1.08K/18 allocs after exactly 3 failed loads (β‰ˆ +186 B + sub-alloc rounding per pdev).
  • After dmsetup remove of the half-created devices: M_DM unchanged (1.08K) β€” pdevs persist.
  • vnconfig -u vn0, vn1, vn2 β†’ VNIOCDETACH: Device busy β€” the kernel still holds the device vnodes; the devices are pinned until reboot.

An earlier dirty run also suggested the pinned vn devices can wedge the shutdown path (vm.sh reset reported "guest not answering" during clean shutdown); not claimed as a separate impact β€” the serial log rotated on reset, so it stays a note, not a verdict claim.

Exploit chain

None β€” this is a privileged (root/operator) resource leak; no memory-safety primitive. Ceiling: unbounded kernel-memory growth (distinct device paths) + permanent device pinning + possible shutdown wedge.

Fix

fix.diff: drop the ref on the failure path (if (dmp != NULL) dm_pdev_decr(dmp);), mirroring the normal teardown at dm_target_flakey.c:217. Not build-validated: Low-severity leak, not memory corruption; the one-liner is correct by inspection against dm_pdev.c:242-259 (decr β†’ 0 β†’ TAILQ_REMOVE + dm_pdev_free() β†’ vn_close()).

Verified-benign / negative notes for this file (pass 2)

  • dm_target_flakey_table() (dm_target_flakey.c:377-394) advances p by ksnprintf return values while re-passing the full DM_MAX_PARAMS_SIZE as the size β€” a latent overflow pattern, but every printed field is bounded: udev_name is "%d:%d" maj:min (dm_pdev.c:159-160, ≀21 B), %d ints ≀11 B each β†’ total ≀ ~117 B inside a 1024-B dm_alloc_string buffer. Not exploitable.
  • Strategy interval math (:232-233): config is immutable post-init and the sum is validated != 0 at :96, so the % can't divide by zero; negative up_int (via atoi64β†’int truncation, down_int==0) only skews the up/down decision β€” no #DE (INT_MIN dividend unreachable: |elapsed| ≀ 2^31/hz).
  • _flakey_corrupt_buf() (:339-363): b_bcount < corrupt_buf_byte check
  • value ≀0xff + β‰₯1 validation β‡’ write is in-bounds by construction.
  • Bio chaining: push_bio reuse assert (vfs_bio.c:786) is safe because biodone() clears bio_done before invoking the callback (vfs_bio.c:3748-3751); pop_bio never frees (vfs_bio.c:795-798).
  • Module unload is guarded: dm_target_remove() returns EBUSY while ref_cnt > 0 (dm_target.c:181-184); dm_target_alloc() M_ZEROes unset handlers and dm_table_status() NULL-checks them (dm_ioctl.c:978-985).
  • Known (not re-reported here): DF-1981 (feature-count validation β†’ OOB argv walk/panic), DF-2452 (same area, mismatched feature_count NULL deref β€” overlaps DF-1981), DF-2453 (tfc UAF in _flakey_read_iodone async callback; the correct fix shape is taking a table ref across the async I/O, which the current tree still lacks).

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

not_testable

Kernel rebuild not performed: Low-severity privileged resource leak, not memory corruption (fix validation mandated only for memory-corruption findings). The one-line fix.diff mirrors the normal teardown path (dm_pdev_decr at dm_target_flakey.c:217) and is correct by inspection against dm_pdev.c:242-259.

fix.diff
↓ fix.diffper-fix-DF-4922

Confirmed kernel references

Detail

Evidence (decisive lines)

run.log: M_DM '336' baseline -> '1.08K' after A/B/C, unchanged after dm remove; 'LEAK-UNIT vn0/vn1/vn2: VNIOCDETACH: Device busy'; 'CONTROL vn3 ... OK'. dmesg line 'Unknown Flakey target feature nosuchfeature' (env.txt) proves path B failed inside _init_features, i.e. after the insert.

PoC changes

No seed PoC existed (new pass-2 finding). First attempt used 20 vn units; guest has only 4 vn units and same-inode backing files confuse attach, so the clean run uses one unit per failure path with distinct backing files plus a no-dm control unit.

Verified recommended fix

Add dm_pdev_decr(dmp) on the fail path of dm_target_flakey_init (see fix.diff).

Verdict

dm_target_flakey_init() takes a dm_pdev reference (dm_pdev_insert, opens the device vnode, ref_cnt=1) at dm_target_flakey.c:86, but every post-insert failure path (interval sum==0 :96, interval overflow :102, _init_features error :108) jumps to fail: which only kfree()s tfc; the ref is never dropped. The downstream error path cannot recover it: dm_table_destroy() calls the target destroy handler, but target_config is still NULL so flakey's destroy returns early (:213), and pdev_maps is empty. Demonstrated on the guest: 3 failed loads grow the M_DM zone 336B->1.08K permanently (survives dm device removal) and leave vn0/vn1/vn2 permanently pinned (vnconfig -u -> VNIOCDETACH: Device busy), while the no-dm control unit detaches cleanly. Privileged (root/operator via /dev/mapper/control 0640) resource leak + device pinning; no memory-safety primitive.