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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2615 Β· 19 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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)
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=1on 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
fixedbaseline (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
Confirmed kernel references
- sys/kern/kern_dmsg.c:264
- sys/kern/kern_dmsg.c:280
- sys/kern/kern_dmsg.c:284
- sys/kern/kern_dmsg.c:545
- sys/kern/kern_dmsg.c:554
- sys/kern/kern_dmsg.c:619
- sys/kern/kern_dmsg.c:2018
- sys/vfs/hammer2/hammer2_iocom.c:66
- sys/vfs/hammer2/hammer2_vfsops.c:1341
- sys/vfs/hammer2/hammer2_vfsops.c:1778
- sys/vfs/hammer2/hammer2_vfsops.c:1176
- sys/vfs/hammer2/hammer2_vfsops.c:1893
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=
No comments yet.