Unbounded recursion in kdmsg_simulate_failure overflows the kernel thread stack (remote DoS)
| Field | Value |
|---|---|
| ID | DF-0017 |
| Status | new |
| Severity | High |
| CVSS 3.1 | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H |
| CWE | CWE-674 Uncontrolled Recursion; CWE-787 Out-of-bounds Write |
| File | sys/kern/kern_dmsg.c |
| Lines | 1321-1351, 917, 1255, 555 |
| Area | kern |
| Confidence | certain |
| Discovered | 2026-06-29 |
| Reported | pending |
Summary
kdmsg_simulate_failure() recurses without any depth limit through the
kdmsg_state subq child tree. The DMSG protocol lets a peer build an
arbitrarily deep parentβchild chain by sending CREATE messages whose circuit
field references the previously-created state's msgid. Triggering cleanup β a
DELETE on the chain root, or simply closing the connection β drives unbounded
recursion that overflows the 16 KB LWKT kernel thread stack, panicking or
corrupting the kernel. The kernel does not verify DMSG CRCs on receive, so a
peer that can reach a DMSG link (HAMMER2 cluster network via the userland
relay daemon, or locally via DIOCRECLUSTER on a disk device node) can forge
the triggering messages.
Root cause
sys/kern/kern_dmsg.c:1321-1351 β kdmsg_simulate_failure:
void
kdmsg_simulate_failure(kdmsg_state_t *state, int meto, int error)
{
kdmsg_state_t *substate;
kdmsg_state_hold(state);
if (meto)
kdmsg_state_abort(state);
again:
TAILQ_FOREACH(substate, &state->subq, entry) {
if (substate->flags & KDMSG_STATE_ABORTING)
continue;
state->scan = substate;
kdmsg_simulate_failure(substate, 1, error); /* :1346 unbounded recursion */
if (state->scan != substate)
goto again;
}
kdmsg_state_drop(state);
}
The deep chain is built on the receive path: the CREATE case selects a parent
state pstate from the attacker-supplied msg->any.head.circuit and links the
new state as its child (:917 TAILQ_INSERT_TAIL(&pstate->subq, state, entry)).
There is no depth/nesting counter anywhere in struct kdmsg_state or the
CREATE path.
The recursion is triggered by:
kdmsg_state_cleanuprxatsys/kern/kern_dmsg.c:1255βkdmsg_simulate_failure(msg->state, 0, DMSG_ERR_LOSTLINK)when a state with a non-emptysubqreceives a DELETE.- the write-thread teardown at
sys/kern/kern_dmsg.c:555on connection close.
The LWKT thread stack is UPAGES * PAGE_SIZE = 4 * 4096 = 16384 bytes
(sys/sys/thread.h, sys/cpu/x86_64/include/param.h). At roughly 50β64 bytes
per combined frame, about 250 nesting levels suffice to overflow; an attacker
can trivially create thousands.
Threat model & preconditions
- Attacker position: a DMSG peer. Reachable via (a) the userland hammer2
relay daemon carrying cluster network traffic (HAMMER2 clustering in use) β a
malicious/compromised cluster peer or network MITM, since
LNK_AUTHis unimplemented; or (b) locally via theDIOCRECLUSTERioctl on a disk device node (typically requires root/operator). - Privileges gained or impact: guaranteed kernel stack overflow β panic (full-system DoS). On configurations without an effective kernel-stack guard page, the stack overflow is also a kernel-memory-corruption primitive with code-execution potential.
- Required config or capabilities: a reachable DMSG link. For the network vector, HAMMER2 clustering must be in use.
- Reachability: forge CREATE messages to build the chain, then a DELETE on the root (or close the connection). CRC is not verified on receive.
Proof of concept
PoC source: findings/poc/DF-0017/kdmsg_stackoverflow.c
Builds N chained CREATE messages (circuit = previous msgid) then a root
DELETE, writing them to a supplied connected DMSG fd.
Build & run
cc -o kdmsg_stackoverflow findings/poc/DF-0017/kdmsg_stackoverflow.c # attach a connected fd to a DMSG iocom (DIOCRECLUSTER on a disk device, # or speak the relay protocol to the hammer2 daemon), then: ./kdmsg_stackoverflow <fd> [depth]
Expected output
Kernel panic from a stack overflow / double-fault deep in the recursion (or a stack-guard hit) once the root DELETE (or connection close) drives the unbounded recursion.
Impact
For HAMMER2-clustered deployments, a malicious peer (or network MITM, given no receive-side CRC and unimplemented auth) can crash the kernel at will β a remote denial of service. The defect is deterministic (no race). Rated High.
Recommended fix
Cap circuit nesting depth in the CREATE path and, as defense-in-depth, convert
the recursive traversals in kdmsg_simulate_failure (:1321) and
kdmsg_state_dying to iterative (explicit-stack) walks.
--- a/sys/kern/kern_dmsg.c
+++ b/sys/kern/kern_dmsg.c
@@ -56,6 +56,8 @@
#include <sys/dmsg.h>
+#define DMSG_MAX_CIRCUIT_DEPTH 32
+
RB_GENERATE(kdmsg_state_tree, kdmsg_state, rbnode, kdmsg_state_cmp);
@@ -899,6 +901,14 @@
msg->state = state; /* inherits freerd ref */
state->parent = pstate;
+ if (pstate != &iocom->state0 &&
+ pstate->depth >= DMSG_MAX_CIRCUIT_DEPTH) {
+ kdio_printf(iocom, 1, "circuit nesting too deep (%d)\n",
+ pstate->depth);
+ error = EINVAL;
+ break;
+ }
+ state->depth = (pstate == &iocom->state0) ? 0 : pstate->depth + 1;
KKASSERT(state->iocom == iocom);
This requires adding a depth field to struct kdmsg_state in
sys/sys/dmsg.h. (Defensive follow-up: rewrite the two recursive tree walks
iteratively so a wide/unexpected tree cannot overflow the stack.)
References
sys/kern/kern_dmsg.c:1346β unbounded recursion inkdmsg_simulate_failure.sys/kern/kern_dmsg.c:917β child linkage on CREATE (circuitnesting).sys/kern/kern_dmsg.c:1255/:555β trigger paths.sys/sys/thread.hβLWKT_THREAD_STACK = 16 KB.- CWE-674 Uncontrolled Recursion; CWE-787 Out-of-bounds Write (stack).
Timeline
- 2026-06-29 Discovered during automated file-by-file audit of
sys/kern/kern_dmsg.c. - pending Reported to DragonFlyBSD security contact.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-0017 Β· 17 files| File | Type | Description | Size | |
|---|---|---|---|---|
| trigger.c | trigger-source | self-contained reproducer: disk open + socketpair + DIOCRECLUSTER setup + forged CREATE/DELETE chain -> stack overflow | 6.0 KB | view raw |
| kdmsg_stackoverflow.c | trigger-source | original wire-format builder (retained; expects caller-supplied fd) | 3.6 KB | view raw |
| build.sh | build-script | cc -o trigger trigger.c -lpthread | 336 B | view raw |
| run.sh | run-script | frees disk iocom (pkill hammer2) then runs ./trigger 300 | 2.8 KB | view raw |
| VERDICT.md | verdict | full narrative: mechanism, setup, evidence, reachability, fix + Phase-8 validation (cap 32 too high -> 8 verified) | 12.9 KB | β raw |
| README.md | readme | how to build/run/interpret | 3.2 KB | β raw |
| fix.diff | suggested-fix | git-apply-able: add depth field + DMSG_MAX_CIRCUIT_DEPTH=8 cap in receive/transmit CREATE paths; VALIDATED (closes the bug) | 2.8 KB | view raw |
| fix_notes.md | fix-notes | what the fix does, why depth=8 not 32 (empirical), Phase-8 validation matrix | 5.3 KB | β raw |
| build.log | build-log | final PoC build, full output + env | 1.0 KB | view raw |
| run.log | run-log | control (depth=5, no panic) + panic (depth=300, double fault) decisive runs | 4.0 KB | view raw |
| panic.txt | panic-signature | double-fault panic from boot.log (original reproduction) | 2.8 KB | view raw |
| panic_baseline.txt | panic-signature | double-fault panic from boot.log captured during Phase-8 baseline re-run on #0 | 319 B | view raw |
| fix_build.log | build-log | full nativekernel output of the validated cap=8 single-fix kernel build (NK_DONE rc=0) | 5.6 MB | β download |
| fix_run.log | run-log | patched #1 kernel PoC run (exit 0, no panic) + boot.log proof (cap fired, 0 double faults) | 1.7 KB | view raw |
| env.txt | environment | uname, cc, sysctl, LWKT stack size, disk perms, hammer2 daemon | 2.1 KB | view raw |
| ../fix_build_combined.log | build-log | Combined 41-finding kernel build (rc=0, -Werror clean) | 5.6 MB | β download |
| ../fix_build_summary.txt | build-summary | Summary of the combined 41-finding kernel build | 826 B | view raw |
DF-0017 β PoC (REPRODUCED)
Unbounded recursion in kdmsg_simulate_failure() / kdmsg_state_dying()
(sys/kern/kern_dmsg.c:1346 / :1428) overflows the 16 KB LWKT kernel
thread stack via a deep DMSG circuit-nesting chain. See VERDICT.md for
the full analysis.
Status
REPRODUCED + FIX VALIDATED β kernel stack-overflow DoS (double-fault
panic) on DragonFly master DEV 6.5-DEVELOPMENT #0. Prior
inconclusive (could not obtain a connected DMSG iocom fd) is resolved:
trigger.c performs the disk-open + socketpair + DIOCRECLUSTER setup
itself. A single-fix kernel with DMSG_MAX_CIRCUIT_DEPTH=8
(fix.diff) eliminates the panic; the finding's proposed bound of 32 is
empirically too high (33 levels still overflow β see VERDICT.md).
Files
trigger.cβ self-contained reproducer (the one to use). Opens/dev/vbd0, builds a socketpair, attaches one end to the kernel disk DMSG iocom viaDIOCRECLUSTER, writes N chained CREATE messages (circuit nesting) + a root DELETE. Includes a drain thread.kdmsg_stackoverflow.cβ the original wire-format builder (retained as the minimal trigger reference; it expects the caller to supply the fd).build.sh/run.shβ one-command build + run (run.sh also frees the disk iocom by killing the boot-time hammer2 daemon β see below).VERDICT.mdβ full narrative + evidence.build.log,run.log,panic.txt,env.txtβ untrimmed logs.
Build (on the DragonFly guest, as root)
./build.sh # cc -o trigger trigger.c -lpthread
Run (as root)
# (once, to capture the panic on a headless guest) echo 'console="comconsole"' >> /boot/loader.conf && reboot ./run.sh # default depth 300; frees the disk iocom, then fires # or: ./run.sh 300
Why run.sh kills the hammer2 daemon
On this image the userland hammer2 cluster daemon (pid "hammer2") connects
every disk iocom at boot via DIOCRECLUSTER (sbin/hammer2/cmd_service.c:898)
and relays peer DMSG traffic (TCP 987) into the kernel. That leaves each
disk iocom's reader blocked in fp_read(), which deadlocks a follow-on
DIOCRECLUSTER in kdmsg_iocom_reconnect() (kern_dmsg.c:141). Killing
the daemon breaks the pipes, the readers exit, and a fresh
DIOCRECLUSTER succeeds. The root fs (hammer2 on vbd0s1d) has its own
kernel iocom and is unaffected. run.sh does pkill -9 -x hammer2 first.
Expected output
- Bug present: kernel panic β
Fatal double fault(total stack exhaustion, page-alignedrsp), guest freezes in DDB.vm.sh resetto recover. - Control (
./run.sh 5): no panic, trigger exits 0 (shallow chain). - Fixed kernel (depth cap): trigger exits 0, no panic at any depth.
Reachability / impact
- Local (
DIOCRECLUSTER): needs to open a raw disk node (/dev/vbd0isroot:operator crw-r-----); unprivileged users are denied. β root/operator. - Remote (HAMMER2 cluster relay, TCP 987):
LNK_AUTHunimplemented, receive-side CRC not checked β a network peer/MITM can forge the chain. β unauthenticated DoS for HAMMER2-clustered deployments. - No guard page on the LWKT stack β also an (uncontrolled) kernel memory-corruption primitive; realistically reliable impact = DoS.
DF-0017 β VERDICT
Status: REPRODUCED (kernel stack-overflow DoS; also an uncontrolled
kernel memory-corruption primitive). Prior inconclusive resolved: the
iocom-fd setup gap is solved and the bug fires on master DEV.
One-line
Unbounded recursion in kdmsg_simulate_failure() /
kdmsg_state_dying() overflows the 16 KB LWKT kernel thread stack when a
DMSG peer builds a deep circuit-nesting chain. A 300-deep chain
deterministically panics the kernel with a double fault (total stack
exhaustion); a 5-deep chain (control) does not. Reachable locally via
DIOCRECLUSTER (root/operator) and remotely via the unauthenticated
HAMMER2 cluster relay (TCP 987).
The bug, confirmed in source
struct kdmsg_state (sys/sys/dmsg.h:735) has a subq child list and
no depth/nesting counter anywhere. Two routines walk that tree
recursively with no depth bound:
kdmsg_simulate_failure()βsys/kern/kern_dmsg.c:1321, recursive call at:1346:kdmsg_simulate_failure(substate, 1, error);kdmsg_state_dying()βsys/kern/kern_dmsg.c:1421, recursive call at:1428:kdmsg_state_dying(scan);
The deep chain is built on the receive path. The CREATE case
(kern_dmsg.c:850 case DMSGF_CREATE:) selects a parent state pstate
from the attacker-supplied msg->any.head.circuit (:868-879,
RB_FIND by msgid in staterd_tree) and links the new state as its child
at :917 TAILQ_INSERT_TAIL(&pstate->subq, state, entry). There is
no depth check. So a peer sends CREATE #1 with circuit=0 (child of
state0), CREATE #2 with circuit=1 (child of state #1), ... CREATE #N
with circuit=N-1, building an N-deep linear chain.
The recursion is triggered by teardown:
kdmsg_state_cleanuprx()βsys/kern/kern_dmsg.c:1236. When a state with a non-emptysubqreceives a DELETE (:1247), it callskdmsg_simulate_failure(msg->state, 0, DMSG_ERR_LOSTLINK)at:1255.- Write-thread teardown β
sys/kern/kern_dmsg.c:547-555. On connection close it callskdmsg_simulate_failure(&iocom->state0, 0, DMSG_ERR_LOSTLINK)at:555for any leftover states.
Inside kdmsg_simulate_failure(state, meto=1, ...) each level also calls
kdmsg_state_abort(state) (:1336) which calls kdmsg_state_dying(state)
(:1368) β itself an unbounded recursive walk over the remaining chain.
So the overflow is driven by both recursive functions.
The receive path does not verify the DMSG CRCs:
kdmsg_iocom_thread_rd() (kern_dmsg.c:326-394) checks only the magic
(:343) and sizes; hdr_crc/aux_crc are computed only on transmit
(:2009-2012). So forged wire messages are accepted. LNK_AUTH is
unimplemented (no kernel-layer auth on cluster links).
LWKT kernel thread stack = LWKT_THREAD_STACK = UPAGES*PAGE_SIZE =
4*4096 = 16384 bytes (sys/sys/thread.h:472,
sys/cpu/x86_64/include/param.h:126). It has no guard page
(kmem_alloc_stack in sys/vm/vm_extern.h:131-136 is just
kmem_alloc1(..|KM_STACK) with no guard mapping), so the overflow
corrupts adjacent kernel memory before double-faulting.
Setup (the prior blocker, solved)
A DMSG iocom fd is attached to the kernel disk-iocom parser by opening a
raw disk device node and issuing DIOCRECLUSTER
(sys/sys/diskslice.h:99, struct disk_ioc_recluster { int fd; }). The
kernel does holdfp(curthread, recl->fd, -1) (subr_diskiocom.c:118)
to obtain the struct file * and passes it to kdmsg_iocom_reconnect()
(subr_diskiocom.c:141). The kernel reader thread then parses whatever
is written to the other end of that fd as DMSG wire messages.
The PoC (trigger.c) is self-contained: it opens /dev/vbd0, builds an
AF_UNIX SOCK_STREAM socketpair, issues DIOCRECLUSTER with one end,
and writes the forged CREATE/DELETE messages to the other end (a drain
thread absorbs kernel replies so the writer never blocks).
The reconnect-deadlock wrinkle. On this guest the userland hammer2
cluster daemon (pid 68, hammer2: hammer2 autoconn_thread, listens on
TCP 987) connects every disk iocom at boot via DIOCRECLUSTER
(sbin/hammer2/cmd_service.c:898), leaving each disk iocom's reader
blocked in fp_read() on a pipe to the daemon. A follow-on
DIOCRECLUSTER then deadlocks inside kdmsg_iocom_reconnect()
(kern_dmsg.c:141, while (msgrd_td || msgwr_td)) because the stuck
reader never wakes to notice KILLRX. Killing the daemon
(pkill -9 -x hammer2) breaks the pipes, the readers get EOF and exit
(msgrd_td -> NULL), and a fresh DIOCRECLUSTER then succeeds. The
hammer2 root fs has its own kernel iocom (hmp->iocom) and is unaffected
by killing the userland daemon β verified (root fs stays rw, ssh stays
up). run.sh performs this kill as a documented setup step.
Evidence (decisive)
Run as root on DragonFly v6.5.0.1712.g89e6a-DEVELOPMENT (X86_64_GENERIC),
kernel console switched to serial (console="comconsole") so the panic
is captured in dfbsd-qemu/boot.log.
- Control β
./trigger 5: trigger exits 0, guest stays up, dmesg clean. Shallow chain, no overflow. (Proves the panic is depth-driven, not an artefact of the DIOCRECLUSTER setup.) - Panic β
./trigger 300: guest freezes; serial console shows:
DOUBLE FAULT
Fatal double fault
rip = 0xffffffff806564d4
rsp = 0xfffff800ab38f000 (page-aligned == rbp: total stack exhaustion)
panic: double fault
dblfault_handler() at dblfault_handler+0x10c
dblfault_handler() at dblfault_handler+0x10c
Stopped at Debugger+0x7c: movb $0,0xbd77f9(%rip)
db>
Reproduced twice (identical signature; only the exhausted-stack
address differs). Full logs: run.log, panic.txt.
A double fault with a page-aligned rsp is the canonical signature of a
kernel thread stack overflow on x86: the recursion exhausts the 16 KB
stack, the stack pointer runs off the allocation, and the next push/fault
finds no usable stack to dispatch even the page-fault handler -> double
fault -> panic. The trace shows only dblfault_handler() because the
original kdmsg frames are destroyed by the stack exhaustion (no
recoverable frame to walk). There is no guard page, so the overflow is
also a memory-corruption primitive that reliably manifests as a DoS.
Exploit chain
Not developed to root. The primitive is an uncontrolled kernel stack
overflow into adjacent kernel memory (no guard page). Converting it to
reliable code execution would require: (a) controlling the slab/heap
layout adjacent to a chosen LWKT thread stack to place a victim object
(function pointer / ucred *) at the overflow offset, and (b) surviving
long enough past the overflow to dereference the corrupted object before
the double-fault β the overflow happens inside a deep kernel recursion,
so the double-fault lands almost immediately, making heap-grooming
extremely fragile. The realistically reliable, defensible impact is the
DoS (deterministic kernel panic). The original trigger PoC
(kdmsg_stackoverflow.c) is retained as the minimal wire-format builder;
trigger.c is the self-contained reproducer (setup + trigger).
Privilege / reachability note
- Local vector (
DIOCRECLUSTER): requires opening a raw disk node (/dev/vbd0etc.), which isroot:operator crw-r-----. Unprivilegedmaxx(uid 1001, not inoperator) is denied (Permission deniedconfirmed). So the local vector needs root or operator. - Remote vector (HAMMER2 cluster relay): the hammer2 daemon listens
on TCP 987 and relays peer DMSG traffic into the kernel disk iocom.
LNK_AUTHis unimplemented and receive-side CRC is not checked, so a network peer (or MITM) can forge the chain-building CREATE messages. For HAMMER2-clustered deployments this is a remote, unauthenticated DoS β which is why the finding is rated High.
What changed vs the original PoC
The original kdmsg_stackoverflow.c only built the wire-format messages
and expected the caller to supply a "connected DMSG fd" β which it never
showed how to obtain, so it could not run (inconclusive). The new
trigger.c is fully self-contained: it performs the disk open +
socketpair + DIOCRECLUSTER setup itself, adds a drain thread to avoid
reply-backpressure deadlock, and drives both trigger paths (root DELETE
via cleanuprx, plus connection-close via the write-thread teardown).
build.sh / run.sh make it one-command reproducible (run.sh also
performs the documented hammer2-daemon kill needed to free the disk
iocom for a local reconnect on this guest image).
Recommended fix
Cap circuit nesting depth at the source β the receive CREATE path β by
adding a depth field to struct kdmsg_state and rejecting any CREATE
whose parent is already at DMSG_MAX_CIRCUIT_DEPTH. This bounds the
chain at the input, which in turn bounds the recursive teardown walks
in kdmsg_simulate_failure() (kern_dmsg.c:1321) and
kdmsg_state_dying() (kern_dmsg.c:1421) so they can no longer
overflow the 16 KB LWKT thread stack. (Converting those two walks to
iterative explicit-stack traversals remains a worthwhile defense-in-depth
follow-up, but with the depth cap enforced they are bounded and safe.)
The verified fix lives in fix.diff and supersedes the finding
markdown's proposed diff (which used a depth bound of 32 β see the
critical correction below).
CRITICAL: the depth bound must be 8, NOT 32
The finding's ## Recommended fix and the first cut of this runner's
fix.diff both proposed DMSG_MAX_CIRCUIT_DEPTH = 32. That value is
too high and does NOT close the bug. Phase-8 fix validation (this run)
proved it:
-
A single-fix kernel built with the cap at 32 was driven by the same PoC (
./trigger 300). The cap fired correctly β only 33 states (depths 0β32) formed the chain, CREATE #34 was rejected with the newcircuit nesting too deeplog, and #35β300 fell through to the pre-existingmissing parent in stacked transpath. But the connection-close teardown STILL double-faulted (identicalFatal double fault/ page-alignedrspsignature as the unpatched baseline). -
Root cause of the underestimate: the finding's "β250 nesting levels suffice to overflow" assumed ~50β64 bytes per nesting level (β1β2 frames). That is wrong because
kdmsg_state_abort()re-enters the receive path (kern_dmsg.c:1404kdmsg_msg_receive_handling(msg)βkdmsg_state_msgrxβkdmsg_state_cleanuprxβkdmsg_simulate_failure). So each nesting level is a ~5-function call cycle, not 1β2 frames, and the real per-level cost is ~485 B. 33 levels Γ ~485 B β 16 KB β overflow, exactly as observed. -
The verified
fix.difftherefore usesDMSG_MAX_CIRCUIT_DEPTH = 8(max chain = 9 states, worst-case recursion ~9 levels β 4.4 KB β well within the 16 KB stack with a >3Γ margin). Legitimate DMSG/HAMMER2 circuit nesting is 1β3, so 8 remains generously non-restrictive.
Fix validation (Phase 8) β VALIDATED
Built and booted a single-fix kernel (cap = 8) and re-ran the same PoC.
| kernel | kern.version |
PoC (./trigger 300) |
result |
|---|---|---|---|
| unpatched baseline | 6.5-DEVELOPMENT #0 (Thu Jul 2 06:02:54 UTC 2026) |
depth 300 | panic β Fatal double fault, page-aligned rsp=0xfffff800ab38f000, dblfault_handler trace; guest freezes in DDB |
| single-fix #1 (cap=32, insufficient) | #1 (Thu Jul 2 08:57:14 UTC 2026) |
depth 300 | panic β identical double fault; cap fired (circuit nesting too deep (33)) but 33 levels still overflow |
| single-fix #1 (cap=8, verified) | 6.5-DEVELOPMENT #1 (Thu Jul 2 09:28:57 UTC 2026) |
depth 300, 300, 300, 5000, 5 | no panic β TRIGGER_EXIT=0, guest stays up; boot.log shows circuit nesting too deep (9) then missing parent in stacked trans for the rejected excess CREATEs; 0 double faults |
Before/after contrast (the decisive evidence):
BASELINE (#0, ./trigger 300): DOUBLE FAULT Fatal double fault rsp = 0xfffff800ab38f000 (page-aligned == rbp: total stack exhaustion) panic: double fault dblfault_handler() at dblfault_handler+0x10c db> (guest frozen in DDB) PATCHED #1 (cap=8, ./trigger 300): [7] survived; no panic observed TRIGGER_EXIT=0 boot.log: kdmsg: circuit nesting too deep (9), rejecting CREATE boot.log: kdmsg: missing parent in stacked trans (x290, CREATEs #11-300) [double fault / panic count]: 0 (guest responsive; depth 5000 also clean β cap bounds regardless of input)
The cap=8 fix is deterministic (4 consecutive deep runs, no panic) and
correct (the cap fires at depth 9, bounding the chain to 9 states so the
teardown recursion stays ~9 levels β 4.4 KB, far under the 16 KB stack).
The fix.diff is git apply-able (git apply --check rc=0) and the
single-fix kernel compiled cleanly (NK_DONE rc=0).
Full logs: fix_build.log (full nativekernel output, rc=0),
fix_run.log (patched-kernel PoC run + boot.log proof of no panic),
panic_baseline.txt (the #0 double-fault signature).
DF-0017 β fix.diff validation notes
Authored by the runner post-verification against the read-only sys/ tree
(the master DEV kernel that reproduced the panic), then built, booted,
and validated in a single-fix kernel (Phase 8). Never applied to sys/.
Status: VALIDATED β fix closes the bug (cap = 8)
What the fix does
Caps DMSG circuit-nesting depth at the source β the receive CREATE path β
so a peer can never build the arbitrarily deep parentβchild chain whose
teardown overflows the 16 KB LWKT thread stack. This is the root-cause
fix; with the chain bounded, the recursive walks in
kdmsg_simulate_failure() (kern_dmsg.c:1321) and
kdmsg_state_dying() (kern_dmsg.c:1421) cannot overflow the stack, so
their existing recursive structure becomes safe.
Changes
-
sys/sys/dmsg.hβ addint depth;tostruct kdmsg_state.state0is covered becausekdmsg_iocom_init()doesbzero(iocom, sizeof(*iocom))(kern_dmsg.c:113), sostate0.depth = 0. Every other state iskmalloc(... M_ZERO), so the field defaults to 0. -
sys/kern/kern_dmsg.c-#define DMSG_MAX_CIRCUIT_DEPTH 8near the top (see "Why 8" below). - Receive CREATE path (kdmsg_state_msgrx, afterpstateis resolved frommsg->any.head.circuit): ifpstate != state0 && pstate->depth >= DMSG_MAX_CIRCUIT_DEPTH, logcircuit nesting too deep (%d)andbreakwitherror = EINVAL. The check sits beforeiocom->freerd_stateis consumed (line 898), so a rejected CREATE returns cleanly through thedone:path (error != 0β skip state update at:1071, no state leak) andfreerd_stateremains available for reuse. - Setstate->depthin the receive CREATE path (right afterstate->parent = pstate;) and in the transmit CREATE path ofkdmsg_msg_alloc()(:1799) so a transmit-created state used as a circuit parent (viaDMSGF_REVCIRClookups instatewr_tree) carries an accurate depth and the receive-side cap cannot be bypassed.
Why depth = 8 (NOT 32) β empirically derived
The finding's ## Recommended fix and the first cut of this fix.diff
both proposed DMSG_MAX_CIRCUIT_DEPTH = 32. Phase-8 validation proved
32 is too high β a single-fix kernel built with cap=32 was driven by
the same PoC; the cap fired correctly (33 states, CREATE #34 rejected
with circuit nesting too deep (33)), but the connection-close teardown
STILL double-faulted with the identical stack-exhaustion signature.
Root cause of the underestimate: the finding assumed ~50β64 B per nesting
level (β1β2 frames). That is wrong because
kdmsg_state_abort() re-enters the receive path β
kdmsg_state_abort (kern_dmsg.c:1404) calls
kdmsg_msg_receive_handling(msg) β kdmsg_state_msgrx β
kdmsg_state_cleanuprx β kdmsg_simulate_failure. So each nesting level
is a ~5-function call cycle, not 1β2 frames, and the real per-level cost
is ~485 B. 33 levels Γ ~485 B β 16 KB β overflow, exactly as observed.
DMSG_MAX_CIRCUIT_DEPTH = 8 gives a worst-case chain of 9 states
(depths 0β8) β ~9-level recursion β 4.4 KB, well within the 16 KB stack
with a >3Γ margin. Legitimate DMSG/HAMMER2 circuit nesting is 1β3, so 8
remains generously non-restrictive.
Validation (Phase 8)
| step | result |
|---|---|
git apply --check findings/poc/DF-0017/fix.diff |
RC 0 |
patch --dry-run -p1 on guest /usr/src |
all 4 hunks succeeded |
make -j6 nativekernel KERNCONF=X86_64_GENERIC (single-fix) |
NK_DONE rc=0, no errors (fix_build.log) |
installed kernel.stripped β /boot/kernel/kernel, rebooted |
kern.version β #1 (Thu Jul 2 09:28:57 UTC 2026) |
PoC ./trigger 300 on unpatched #0 |
panic β Fatal double fault, page-aligned rsp, DDB (panic_baseline.txt) |
PoC ./trigger 300 on patched #1 (cap=8) |
no panic, TRIGGER_EXIT=0, guest stays up; boot.log shows circuit nesting too deep (9) + 290Γ missing parent; 0 double faults |
PoC ./trigger 5000 on patched #1 |
no panic β cap bounds regardless of attacker input |
4 consecutive deep runs on patched #1 |
all clean β fix is deterministic |
Scope note (defense-in-depth follow-up, NOT required to close the bug)
The finding's ## Recommended fix also suggests converting the two
recursive subq walks to iterative (explicit-stack) traversals as
defense-in-depth. With the depth cap enforced at 8, those walks are
bounded (β€9 levels) and cannot overflow, so this fix does not rewrite
them β converting kdmsg_simulate_failure in particular is non-trivial
because its state->scan / goto again logic handles concurrent list
mutation during traversal (kdmsg_state_abort can remove elements), and
a faithful iterative rewrite risks introducing new bugs. The cap is the
minimal, root-cause fix; an iterative rewrite remains a worthwhile
hardening follow-up (and would let the cap be raised if a legitimate use
case ever needs deeper nesting).
This runner-authored fix.diff supersedes the finding markdown's
proposed diff (which used a bound of 32 β too high, empirically still
overflows β and placed the cap after freerd_state consumption in one
reading, leaking the freerd state on rejection; this version sits before
the consumption and uses the empirically-safe bound of 8).
Fix verification
fixedVALIDATED the fix. The PoC panicked the unpatched #0 baseline (Fatal double fault, page-aligned rsp, DDB) and does NOT panic the single-fix #1 kernel built with DMSG_MAX_CIRCUIT_DEPTH=8 (TRIGGER_EXIT=0, guest stays up; boot.log shows the cap firing at depth 9 + 290 missing-parent rejects + 0 double faults). The fix closes the bug. NOTE: an intermediate cap=32 single-fix kernel was also tested and FAILED (still double-faulted at 33 levels) β this is what forced the bound down to 8; the finding's proposed 32 would have been an insufficient fix (fix_failed) had it not been caught by Phase-8.
baseline (#0, ./trigger 300): DOUBLE FAULT / Fatal double fault / rsp = 0xfffff800ab38f000 / panic: double fault / dblfault_handler() at dblfault_handler+0x10c / db> [guest frozen]. patched #1 (cap=8, ./trigger 300): [7] survived; no panic observed / TRIGGER_EXIT=0 [boot.log: 'circuit nesting too deep (9), rejecting CREATE' x1, 'missing parent in stacked trans' x290, double-fault/panic x0; depth 5000 also clean]. nativekernel build rc=0 (fix_build.log).
Confirmed kernel references
Detail
Exploit chain
Local/remote DoS via unbounded kernel-thread-stack recursion. A DMSG peer builds an arbitrarily deep parent->child circuit chain via CREATE messages (child linked into pstate->subq at kern_dmsg.c:917); triggering teardown (root DELETE -> cleanuprx:1255, or connection close -> write-thread teardown:555) recurses depth-first through kdmsg_simulate_failure (recursive call :1346) and kdmsg_state_dying (recursive call :1428), each level additionally re-entering the receive path via kdmsg_state_abort (:1404). At ~485 B/level the 16 KB stack overflows at ~33 levels; there is no guard page (kmem_alloc_stack is a plain kmem_alloc1), so the overflow is also an uncontrolled kernel-memory-corruption primitive. Realistically reliable impact = DoS (deterministic double-fault panic); heap-grooming the overflow into code execution is fragile because the double-fault lands almost immediately inside the deep recursion. Reachable locally via DIOCRECLUSTER (root/operator) and remotely via the unauthenticated HAMMER2 cluster relay (TCP 987; LNK_AUTH unimplemented, receive-side CRC unchecked). Not developed to uid0.
Evidence (decisive lines)
BASELINE (#0, ./trigger 300): 'DOUBLE FAULT / Fatal double fault / rsp = 0xfffff800ab38f000 (page-aligned: total stack exhaustion) / panic: double fault / dblfault_handler() at dblfault_handler+0x10c / Stopped at Debugger+0x7c / db>' (guest frozen). PATCHED #1 (cap=8, ./trigger 300): '[7] survived; no panic observed / TRIGGER_EXIT=0'; boot.log diagnostic counts: circuit-nesting-too-deep=1 (cap fired at depth 9), missing-parent=290 (CREATEs #11-300 rejected), double-fault/panic=0; depth 5000 also clean (cap bounds regardless of input).
PoC changes
No changes to the PoC trigger itself (trigger.c unchanged, already self-contained). Refined fix.diff: lowered DMSG_MAX_CIRCUIT_DEPTH from 32 to 8 (the finding's 32 was empirically disproved by Phase-8 β a cap=32 kernel still double-faulted because kdmsg_state_abort re-enters the receive path making ~485 B/level), and regenerated the diff with correct hunk line-counts and proper ---/+++ headers so git apply --check passes (rc=0). Regenerated via diff -u against /tmp copies of the clean guest sources. Updated VERDICT.md (added full Phase-8 section + the critical 32->8 correction), fix_notes.md (rewrote with the empirical justification and validation matrix), manifest.json (added fix{}/fix_build.log/fix_run.log/panic_baseline.txt artifacts), and README.md (status line).
Verified recommended fix
Cap DMSG circuit-nesting depth at 8 (DMSG_MAX_CIRCUIT_DEPTH) in the receive CREATE path of kdmsg_state_msgrx (sys/kern/kern_dmsg.c), by adding an int depth; field to struct kdmsg_state (sys/sys/dmsg.h, initialized to 0 by bzero/M_ZERO so state0 and fresh states start at depth 0) and rejecting with EINVAL any CREATE whose parent state is already at the cap; set state->depth in both the receive and transmit CREATE paths so a transmit-created state used as a circuit parent carries an accurate depth. This bounds the recursive kdmsg_simulate_failure/kdmsg_state_dying teardown to ~9 levels (~4.4 KB, >3x margin under the 16 KB stack). The cap sits before freerd_state consumption so rejected CREATEs leak nothing. This SUPERSEDES the finding proposal, which used a bound of 32 (empirically too high β 33 levels still overflow because kdmsg_state_abort re-enters the receive path, ~485 B/level). The full git-apply-able diff lives in findings/poc/DF-0017/fix.diff (git apply --check rc=0, nativekernel NK_DONE rc=0).
Verdict
REPRODUCED AND FIX VALIDATED. The bug is real: on the unpatched master DEV #0 kernel, the self-contained PoC (trigger.c: DIOCRECLUSTER on /dev/vbd0 + 300 forged chained DMSG CREATEs + root DELETE) drives unbounded recursion in kdmsg_simulate_failure()/kdmsg_state_dying() (sys/kern/kern_dmsg.c:1346/1428) that exhausts the 16 KB LWKT thread stack, producing a deterministic 'Fatal double fault' with page-aligned rsp=0xfffff800ab38f000 and a dblfault_handler() trace, freezing the guest in DDB. Phase-8 fix validation then built a single-fix kernel (fix.diff: cap DMSG circuit-nesting depth via a new depth field on struct kdmsg_state + a DMSG_MAX_CIRCUIT_DEPTH reject in the receive CREATE path). CRITICAL CORRECTION: the finding's proposed bound of 32 was empirically disproved β a cap=32 kernel still double-faulted (33 levels overflow), because kdmsg_state_abort() re-enters the receive path (kern_dmsg.c:1404: kdmsg_msg_receive_handling->kdmsg_state_msgrx->kdmsg_state_cleanuprx->kdmsg_simulate_failure), making each nesting level a ~5-function cycle (~485 B/level), not the 1-2 frames the finding assumed. The bound was lowered to 8 (max chain 9 states, ~9-level recursion ~=4.4 KB, >3x margin under 16 KB). On the cap=8 #1 kernel the SAME PoC (depth 300, plus 300/300/5000/5 repeats) exits 0 cleanly with NO panic; boot.log shows the cap firing ('circuit nesting too deep (9)') then 290 'missing parent in stacked trans' rejects and 0 double faults. Deterministic across 4 deep runs.
No comments yet.