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

kdmsg_iocom_uninit leaks the queued LNK_PING when iocom threads are gone (one kdmsg_msg per hammer2 unmount)

Field Value
ID DF-2615
Status new
Severity Info
CVSS 3.1 CVSS:3.1/AV:L/AC:L/PR:H/UI:N/S:U/C:N/I:N/A:L
CWE CWE-401 Missing Release of Memory on Effective Lifetime End
File sys/kern/kern_dmsg.c
Lines 264-317
Area kern
Confidence certain
Discovered 2026-08-28
Pass 2 (GLM 5.3 second pass, found via sys/vfs/hammer2/hammer2_iocom.c:66-71)
Bucket base:kern
Reported pending
Known CVE none
CVE match novel

Summary

Every hammer2 unmount calls hammer2_iocom_uninit β†’ kdmsg_iocom_uninit, which allocates and queues a LNK_PING to unstick the reader. When the iocom threads never ran (mount without cluster_fd β€” the default local case) or already exited (connection dropped earlier), nobody ever drains iocom->msgq, so the PING kdmsg_msg is leaked, and hmp (with the embedded iocom and msgq head) is subsequently freed with the message still linked.

Root cause

hammer2_iocom.c:66-71 calls kdmsg_iocom_uninit unconditionally once iocom.mmsg is set (iocom_init runs on every device mount, hammer2_vfsops.c:1341). kern_dmsg.c:264-317: (1) lines 280-281 allocate a DMSG_LNK_PING and call kdmsg_msg_write_locked, which β€” because KDMSG_IOCOMF_EXITNOACC is only set by the write-thread exit path (kern_dmsg.c:545), never here β€” TAILQ_INSERTs the msg onto iocom->msgq (kern_dmsg.c:2018-2021); (2) the thread-join loop at 284 is skipped instantly when msgrd_td/msgwr_td are NULL (threads only exist if hammer2_cluster_reconnect ran β€” they are created solely in kdmsg_iocom_reconnect at kern_dmsg.c:162-165); (3) msgq is never drained and msg_fp/msglk are released. The wr thread that would normally drain (kern_dmsg.c:547-554, kdmsg_drain_msgq at 619-633) does not exist. Result: one kmalloc'd kdmsg_msg (offsetof(any)+64 bytes, plus its state0 hold bookkeeping) leaked per mount/unmount cycle, with the leaked msg's qentry linked into the embedded iocom that is kfree'd with hmp β€” no further dereference occurs, so it is a pure leak.

Threat model & preconditions

  • Attacker position: local privileged (unmount of a hammer2 volume; the default non-clustered mount never starts iocom threads, so every such mount/unmount cycle leaks).
  • Privileges gained or impact: slow unbounded kernel heap memory growth under mount cycling; a dangling-into-freed-iocom queue entry. No attacker-controlled data, no dereference follows.
  • Required config or capabilities: root, hammer2 volume.
  • Reachability: mount -t hammer2 …; umount … loop.

Proof of concept

Build & run

as root on a system with a hammer2 volume:
for i in $(jot 1000); do mount -t hammer2 /dev/adx /mnt; umount /mnt; done
vmstat -m | grep dmsg    # or malloc stats diff / instrumented kernel

Expected output

allocation count minus free count for the dmsg/hammer2 zone increments by
exactly 1 per mount/unmount cycle.

Impact

Resource-accounting defect only; no user→root path exists for this class.

Drain the transmit queue in kdmsg_iocom_uninit once both threads are known dead, mirroring the write-thread exit path (kdmsg_drain_msgq is non-static, kern_dmsg.c:619, and uninit holds msglk throughout, satisfying its locking requirement):

--- a/sys/kern/kern_dmsg.c
+++ b/sys/kern/kern_dmsg.c
@@ -304,6 +304,15 @@ kdmsg_iocom_uninit(kdmsg_iocom_t *iocom)
    if ((state = iocom->freewr_state) != NULL) {
        iocom->freewr_state = NULL;
        kdmsg_state_drop(state);
    }

+   /*
+    * Both threads are gone (or never existed).  Drain any
+    * messages still queued for transmission (e.g. the LNK_PING
+    * allocated above); nothing will ever send them.
+    */
+   iocom->flags |= KDMSG_IOCOMF_EXITNOACC;
+   kdmsg_drain_msgq(iocom);
+
    /*
     * Drop communications descriptor
     */

References

  • kern_dmsg.c:547-554, 619-633 (wr-thread drain that the uninit path lacks)
  • hammer2_vfsops.c:1341, 1778-1781 (unconditional iocom_init; unmount order)

Timeline

  • 2026-08-28 Discovered during automated audit (pass 2, GLM 5.3).

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2615 Β· 19 files
FileTypeDescriptionSize
README.md β€” 3.3 KB ↓ raw
VERDICT.md β€” 2.7 KB ↓ raw
df2615_trigger.c β€” 1.8 KB view raw
run_df2615.sh β€” 1.4 KB view raw
instrument.diff β€” 1.1 KB view raw
fix.diff β€” 854 B view raw
build.sh β€” 367 B view raw
run.sh β€” 853 B view raw
build.log β€” 5.6 MB ↓ download
build.attempt1_buildkernel_fail.log β€” 5.6 MB ↓ download
build_baseline_kernel4.log β€” 5.6 MB ↓ download
fix_build.log β€” 5.6 MB ↓ download
run_base.dmesg_before β€” 6.4 KB ↓ download
run_base.dmesg_after β€” 22.4 KB ↓ download
run_base.out β€” 49 B view raw
run_fix.dmesg_before β€” 6.4 KB ↓ download
run_fix.dmesg_after β€” 21.3 KB ↓ download
run_fix.out β€” 49 B view raw
verdict.json β€” 6.0 KB view raw

DF-2615 β€” kdmsg_iocom_uninit leaks the queued LNK_PING

What was verified

Baseline (instrumented kernel, fix NOT applied): exactly one leaked kdmsg_msg per mount/umount cycle, 20/20 cycles. Instrumentation (instrument.diff, kprintf counters inside kdmsg_iocom_uninit on an otherwise-stock kernel) shows per cycle:

DF2615_DBG: uninit entered rd_td=0 wr_td=0 flags=00000003
DF2615_DBG: after ping write, msgq.first=0xfffff801168ef4c0
DF2615_DBG: uninit exit msgq_nleak=1
DF2615_LEAK: 1 kdmsg_msg(s) queued at iocom_uninit exit

i.e. no iocom threads exist (rd_td=wr_td=0), EXITNOACC (0x8000) is clear in flags, the LNK_PING allocated at kern_dmsg.c:280-281 is TAILQ_INSERTed onto iocom->msgq by kdmsg_msg_write_locked (kern_dmsg.c:2018-2021), and nothing drains it before hmp (with the embedded iocom) is freed β€” kdmsg_iocom_uninit (kern_dmsg.c:264-317) only joins threads, drops freerd/freewr_state caches and msg_fp.

Precondition refined (important correction to the filed text): the leak requires the mount to have been made with cluster_fd < 0 (no iocom threads ever created). Stock mount_hammer2(8) normally auto-starts the hammer2 service daemon (system("/sbin/hammer2 -q service"), mount_hammer2.c:225) and connects to it (cluster_fd >= 0) β€” in that configuration the write thread exists, sets EXITNOACC on exit, and the PING self-drains at write time (verified live: 0 leak lines via plain mount -t hammer2). The leak path is taken whenever the mount proceeds without a cluster connection: daemon not running / connect failed (mount_hammer2 passes cluster_fd=-1 and still mounts, mount_hammer2.c:162-167), or any direct mount(2) caller β€” reproduced with df2615_trigger.c (mount(2) with cluster_fd=-1, 20 cycles). The finding's mechanism is fully confirmed; its "default local case" wording overstates how often stock mounts hit it.

Why the leak is invisible to vmstat on a stock kernel: the msg is allocated from the per-device malloc type hmp->mmsg (kmalloc_create(&hmp->mmsg,"HAMMER2-msg"), hammer2_vfsops.c:1176) which is kmalloc_destroy'ed at unmount (vfsops:1893) β€” the type vanishes from vmstat -m while the allocation lives. (Separately observed, out of scope: the global M_HAMMER2 "HAMMER2-mount" zone grows ~3.5KB/cycle even on daemon-connected mounts β€” a different, unfixed accounting leak.)

Fix validated: fix.diff sets EXITNOACC and calls kdmsg_drain_msgq(iocom) in uninit once the threads are gone (mirroring the write-thread exit path, kern_dmsg.c:545-554; uninit holds msglk, satisfying the drain's locking requirement). On the rebuilt kernel: same trigger, 20/20 cycles succeed, after ping write msgq.first=<ptr> (unchanged path) but uninit exit msgq_nleak=0 and 0 DF2615_LEAK lines. Mount/umount behavior unchanged.

Files

  • df2615_trigger.c trigger: 20x mount(2){cluster_fd=-1}/unmount(2)
  • run_df2615.sh stock-kernel vmstat variant (kept for reference)
  • instrument.diff verification counters (NOT the fix)
  • fix.diff the fix (against pristine sys/)
  • build.log instrumented kernel build (nativekernel)
  • build.attempt1_buildkernel_fail.log why nativekernel is used
  • build_baseline_kernel4.log, fix_build.log baseline/fix kernels
  • run_base.* decisive baseline run (20 leak lines, full dmesg)
  • run_fix.* decisive fixed run (0 leak lines, full dmesg)
VERDICT.md
↓ download raw

DF-2615 VERDICT

Reproduced: YES β€” one kdmsg_msg (LNK_PING) leaked per hammer2

mount/umount cycle when the mount had no cluster connection (cluster_fd < 0). Proven on an instrumented kernel (leak counter inside kdmsg_iocom_uninit); fix validated on a rebuilt kernel.

Chain (all path:line verified in source and at runtime)

  • Every device mount runs hammer2_iocom_init (hammer2_vfsops.c:1341 -> hammer2_iocom.c:59-62); every unmount of the last PFS on the device runs hammer2_iocom_uninit (hammer2_vfsops.c:1778 -> hammer2_iocom.c:66-71) -> kdmsg_iocom_uninit (kern_dmsg.c:264-317).
  • uninit allocates DMSG_LNK_PING on state0 and queues it (kern_dmsg.c:280-281 -> kdmsg_msg_write_locked :2018-2021) because EXITNOACC is clear whenever the iocom write thread never ran.
  • The thread-join loop (:284-294) is a no-op when msgrd_td/msgwr_td are NULL; the function then never drains msgq β€” only freerd/freewr_state (:299-307) and msg_fp (:312-315) are released.
  • The leaked msg (kmalloc from the per-device "HAMMER2-msg" type, vfsops:1176) stays linked via qentry into the embedded iocom that is kfree'd together with hmp β€” no later dereference: pure leak, no attacker-controlled data. Runtime proof: after ping write, msgq.first=0xfffff801168ef4c0 + uninit exit msgq_nleak=1 on every cycle, 20/20 (run_base.dmesg_after).

Precondition correction (honest scope narrowing)

The filed text calls the never-connected case "the default local case". Verified live: stock mount_hammer2(8) auto-starts the hammer2 service daemon and supplies cluster_fd >= 0 (mount_hammer2.c:225-247); with threads present the PING is drained (EXITNOACC set by the write-thread exit path, kern_dmsg.c:545) and plain mount -t hammer2 cycles leak 0 msgs. The leak manifests when the daemon is unavailable (connect fails -> mount proceeds with -1, mount_hammer2.c:162-167) or for any direct mount(2) caller passing cluster_fd=-1 β€” reproduced with df2615_trigger. Impact ceiling unchanged (slow root-driven kernel-heap growth; Info).

Also explained: why vmstat -m cannot see it on a stock kernel β€” the msg's malloc type is destroyed at unmount (vfsops:1893) with the allocation live.

Fix validation

fix.diff: set EXITNOACC + kdmsg_drain_msgq(iocom) in uninit after the threads are gone (mirrors :545-554; msglk held). Rebuilt kernel, same 20-cycle trigger: 0 DF2615_LEAK lines, uninit exit msgq_nleak=0 x20, msgq.first still non-NULL after the ping write (same path taken), CYCLES_DONE=20, TRIG_RC=0 β€” leak eliminated, behavior unchanged. DF-2614's trigger (cluster-connected mount/unmount) also runs clean on the fixed kernel.

Bottom line

status=reproduced (memory leak; Info-class impact, root-gated cycling), fix validated (fixed).

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

baseline (instrumented, fix reverted, kernel #4): 20 leak lines / 20 cycles, exit nleak=1 each; patched (kernels #3/#5): 0 leak lines, exit nleak=0 x20, ping still queued (msgq.first non-NULL) then drained, 20/20 cycles succeed β€” leak eliminated, no behavior change

findings/poc/DF-2615/run_fix.dmesg_after vs run_base.dmesg_after; fix_build.log
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT DragonFly 6.5-DEVELOPMENT #5: Sat Aug 29 11:12:52 UTC 2026 root@dfbsd:/usr/obj/usr/src/sys/X86_64_GENERIC x86_64

Confirmed kernel references

Detail

Exploit chain

root mount(2) hammer2 with cluster_fd=-1 (or mount_hammer2 with service daemon unavailable) -> no iocom threads -> umount -> kdmsg_iocom_uninit queues LNK_PING on msgq -> threads absent so nothing drains -> kdmsg_msg leaked with embedded iocom when hmp is freed; repeat per mount/umount cycle

Evidence (decisive lines)

["findings/poc/DF-2615/run_base.dmesg_after β€” decisive baseline: 20x 'DF2615_LEAK: 1 kdmsg_msg(s) queued at iocom_uninit exit' with msgq.first pointers and flags=00000003 (no EXITNOACC, no threads)", "findings/poc/DF-2615/run_fix.dmesg_after β€” decisive fixed run: 0 leak lines, 20x 'uninit exit msgq_nleak=0', ping still queued then drained", 'findings/poc/DF-2615/instrument.diff β€” verification counters (not the fix)', 'findings/poc/DF-2615/fix.diff β€” EXITNOACC + kdmsg_drain_msgq(iocom) in kdmsg_iocom_uninit (pristine-tree diff)', 'findings/poc/DF-2615/build.log / fix_build.log β€” instrumented and fixed kernel builds (nativekernel X86_64_GENERIC)']

PoC changes

no seed existed. df2615_trigger.c written fresh (direct mount(2) with cluster_fd=-1 after live testing proved stock mount(8) cycles do NOT leak β€” see VERDICT.md precondition correction). Instrumentation v1 (counter at uninit exit only) returned 0 lines because stock mount(8) mounts are daemon-connected; v2 added entry/mid probes which exposed wr_td alive + flags=0x8003 on mount(8) mounts, isolating cluster_fd<0 as the true leak precondition.

Verified recommended fix

in kdmsg_iocom_uninit (kern_dmsg.c, after the thread-join/cache cleanup, before dropping msg_fp): iocom->flags |= KDMSG_IOCOMF_EXITNOACC; kdmsg_drain_msgq(iocom);

Verdict

REPRODUCED (memory leak, Info-class) on an instrumented kernel and FIX VALIDATED on a rebuilt kernel. Mechanism confirmed exactly as filed: kdmsg_iocom_uninit allocates a LNK_PING (kern_dmsg.c:280-281) which kdmsg_msg_write_locked TAILQ_INSERTs onto iocom->msgq (:2018-2021) because EXITNOACC is clear, the thread-join loop (:284-294) is a no-op with no threads, and nothing drains msgq before the embedded iocom is freed with hmp β€” instrumentation shows per cycle: 'uninit entered rd_td=0 wr_td=0 flags=00000003', 'after ping write msgq.first=0xfffff801168ef4c0', 'uninit exit msgq_nleak=1'; 20/20 cycles leak exactly one kdmsg_msg each (run_base.dmesg_after). IMPORTANT precondition correction: stock mount_hammer2(8) auto-starts the hammer2 service daemon and passes cluster_fd >= 0 (mount_hammer2.c:225-247), so plain 'mount -t hammer2' cycles do NOT leak (write thread sets EXITNOACC at kern_dmsg.c:545 and the PING self-drains β€” verified live); the leak is taken when the mount proceeds without a cluster connection (daemon unavailable -> cluster_fd=-1 at mount_hammer2.c:162-167, or any direct mount(2) caller) β€” reproduced with df2615_trigger.c (mount(2) with cluster_fd=-1). The leak is invisible to vmstat on a stock kernel because the msg's per-device malloc type is kmalloc_destroy'ed at unmount (hammer2_vfsops.c:1176/1893) while the allocation lives. Fix (set EXITNOACC + kdmsg_drain_msgq in uninit, mirroring the write-thread exit path :545-554, msglk held): same 20-cycle trigger on the rebuilt kernel gives 'after ping write msgq.first=' (same path), 'uninit exit msgq_nleak=0' x20, 0 DF2615_LEAK lines, CYCLES_DONE=20 β€” leak eliminated, mount/umount behavior unchanged; the DF-2614 cluster-mount trigger also runs clean. Impact ceiling: slow root-driven kernel heap growth under mount cycling; no attacker-controlled data, no dereference after free (pure leak) β€” Info stands.