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

Duplicate transmitted DELETE re-runs the state close sequence: double RB_REMOVE on a stale node plus phantom rbtree refcount drop β€” state-tree corruption and premature kdmsg_state_free (UAF), peer-driven on production kernels

Field Value
ID DF-2707
Status new
Severity High
CVSS 3.1 CVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
CWE CWE-416 UAF, CWE-362 Race, CWE-763
File sys/kern/kern_dmsg.c
Lines 1667-1710 (cleanuptx), 2056 (racy check), 1571, 1076, 1195
Area kern
Confidence certain
Discovered 2026-08-30
Pass 2 (GLM 5.3 second pass)
Bucket memcorrupt
Reported pending
Known CVE none
CVE match novel

Summary

While the iocom write thread is parked in fp_write (peer withholds reads), each absorbed duplicate received DELETE (the rx-side assert at :1076 is INVARIANTS-only) re-runs the state callback; kdmsg_autorxmsg's auto-reply calls kdmsg_msg_reply() whose unlocked txcmd check (:2056) races the writer's cleanuptx β€” txcmd's DELETE bit is only set after transmission β€” so every duplicate queues another terminating reply. On drain, kdmsg_state_msgtx() resets txcmd (&~DELETE, :1571), defeating the :1671 assert, and kdmsg_state_cleanuptx() re-runs the close: RB_REMOVE() on the already-removed node (re-walks stale rbnode pointers and re-links former live neighbors) and a second "state on rbtree" refdrop (:1707) for a ref that no longer exists β€” refcount underflow frees the state while queued replies #3..#N still hold msg->state, and their cleanuptx passes write into the freed, peer-reclaimable slot.

Threat model & preconditions

Any DMSG cluster peer — remote cluster node, the userland hammer2 service daemon's connection, or a network MITM (rx CRCs unverified, LNK_AUTH unimplemented) — corrupts kernel memory on production (non-INVARIANTS) kernels with ~40 small frames; INVARIANTS kernels convert the same input into DF-0018's assert, masking the corruption in testing. Remote-peer→code-exec ceiling documented: fixed-offset UAF writes into a freed 160-byte-class kdmsg_state_t with same-zone reclaim by peer-created states.

Proof of concept

Reproduced on a production-equivalent (no-INVARIANTS) guest build: the trigger (findings/poc/DF-2707/df2707_trigger.c) becomes the peer via the documented cluster_fd mount hook, parks the writer (SO_SNDBUF 2048; FIONREAD proves the park), opens a victim LNK_SPAN, sends 24 duplicate DELETEs, then drains while spraying CREATEs β†’ Fatal trap 12 ... Stopped at kdmsg_state_tree_RB_REMOVE_COLOR+0x6f (the double-unlink). Second unfixed run wedged the iocom post-drain (scheduling-dependent corruption). Stock INVARIANTS kernel on identical input panics at :1076 (DF-0018's assert β€” the precondition path). Fixed kernel (RBINSERTED guard) completes attack 3Γ—24 and stress 6Γ—40 cleanly.

Discard a transmitted DELETE for an already fully-closed state, keyed on the RBINSERTED invariant (see findings/poc/DF-2707/fix.diff; validated).

References

  • DF-0018 (the INVARIANTS-visible twin), DF-2710 (the park primitive), DF-2708 (pointer-valued msgids that aid forgery)

Timeline

  • 2026-08-30 Discovered during pass-2 audit of kern_dmsg.c (GLM 5.3); production-kernel corruption reproduced + fix validated same run.

Discussion (0)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-2707 Β· 18 files
FileTypeDescriptionSize
README.md β€” 3.9 KB ↓ raw
VERDICT.md β€” 6.9 KB ↓ raw
df2707_trigger.c β€” 13.5 KB view raw
run_df2707.sh β€” 1.2 KB view raw
build.sh β€” 454 B view raw
run.sh β€” 353 B view raw
build.log β€” 8 B view raw
panic.txt β€” 674 B view raw
panic.invariants_baseline.txt β€” 551 B view raw
run.attack2_ssh.log β€” 420 B view raw
run.attack2_trigger.out β€” 204 B view raw
run.wedge_probe.txt β€” 430 B view raw
run.fix.log β€” 2.8 KB view raw
run.fix.2.log β€” 3.2 KB view raw
fix.diff β€” 1.3 KB view raw
env.txt β€” 205 B view raw
manifest.json β€” 1.3 KB view raw
verdict.json β€” 5.1 KB view raw

DF-2707 β€” duplicate transmitted DELETE: double RB_REMOVE + phantom rbtree

refdrop in kdmsg_state_cleanuptx() β†’ state-tree corruption + premature

state free (UAF) β€” peer-driven, production (non-INVARIANTS) kernels

What this pack contains

  • df2707_trigger.c β€” the trigger (modes: leak (DF-2708 evidence), wedge (DF-2710 evidence), attack (this finding)). It becomes the cluster peer of a kernel hammer2 iocom using the mount program's documented cluster_fd hook (hammer2_vfsops.c:1350-1357, same wire position as the userland hammer2 service daemon / a remote cluster node), then:
    1. parks the kernel write thread in fp_write() (small SO_SNDBUF on the kernel's socket end + junk SPAN transactions that each elicit an immediate 64-byte terminating reply);
    2. opens a victim LNK_SPAN transaction (stays open: msg_result);
    3. sends N=24 duplicate DELETE frames for the victim msgid while the writer is parked β€” on production kernels the rx-side assert (kern_dmsg.c:1076, DF-0018) is compiled out, each absorbed duplicate re-runs the callback and kdmsg_msg_reply() (its txcmd & DMSGF_DELETE check at kern_dmsg.c:2057 races the writer's cleanuptx) queues another terminating reply;
    4. drains β†’ the writer transmits reply#1 (legit close: RB_REMOVE + "rbtree" refdrop), then reply#2 β†’ kdmsg_state_msgtx() had reset txcmd (:1571), so kdmsg_state_cleanuptx() re-runs the close: RB_REMOVE on the already-removed node (stale-pointer writes into the live state tree) + a second "state on rbtree" refdrop (refcount underflow β†’ premature kdmsg_state_free() while replies #3..#N still hold msg->state) β†’ use-after-free;
    5. sprays CREATEs concurrent with the drain (reclaim of the freed slot) and churns the tree.
  • run_df2707.sh β€” guest-side orchestration (image prep, newfs, build, run, dmesg capture, unmount).
  • fix.diff β€” the verified fix (guard at the RB_REMOVE site keyed on the RBINSERTED invariant).
  • logs (see VERDICT.md for interpretation):
  • panic.invariants_baseline.txt β€” stock INVARIANTS kernel: same peer input panics at the rx-side assert kern_dmsg.c:1076 (DF-0018), proving the harness reaches the duplicate-DELETE machinery.
  • panic.txt β€” non-INVARIANTS (production-equivalent) kernel: Fatal trap 12 page fault in kdmsg_state_tree_RB_REMOVE_COLOR β€” the double-remove corrupting the state tree. This is the finding.
  • run.attack2_ssh.log / run.attack2_trigger.out β€” second attack run on the non-INVARIANTS kernel: no panic, but the iocom reader wedges after the drain (alternate manifestation).
  • run.wedge_probe.txt β€” wedge mode: single-DELETE reader stall (DF-2710), separating the stall from the corruption.
  • run.fix.log β€” same attack on the fix kernel: completes cleanly.

Build & run (from the repo root)

scp -F dfbsd-qemu/config findings/poc/DF-2707/df2707_trigger.c \
                      findings/poc/DF-2707/run_df2707.sh dfbsd:/root/poc/df2707/
dfbsd-qemu/vm.sh run_root 'sh /root/poc/df2707/run_df2707.sh attack 3 24'

The attack requires a kernel built without options INVARIANTS (production configuration); on the stock INVARIANTS kernel the same input panics earlier at kern_dmsg.c:1076 (DF-0018). Kernel build in guest:

sed -i.bak 's/^options \tINVARIANTS/#options \tINVARIANTS/' \
    /usr/src/sys/config/X86_64_GENERIC
cd /usr/src && make -j6 nativekernel KERNCONF=X86_64_GENERIC && \
    make installkernel KERNCONF=X86_64_GENERIC && reboot

Expected output

  • baseline non-INVARIANTS: guest dies mid-run; serial console shows Fatal trap 12 ... Stopped at kdmsg_state_tree_RB_REMOVE_COLOR+0x6f (or an equivalent corruption crash / iocom wedge).
  • fix kernel (fix.diff applied, still non-INVARIANTS): trigger prints all rounds + TRIGGER_DONE, UMOUNT_RC=0, no new kdmsg errors.
VERDICT.md
↓ download raw

DF-2707 VERDICT β€” REPRODUCED (memory corruption, peer-driven, production kernels)

Bottom line

A cluster peer (wire position: the userland hammer2 service daemon's connection / any remote cluster node / the local cluster_fd mount hook, which is how the PoC drives it) can corrupt the kernel's dmsg state tracking on production (non-INVARIANTS) kernels by sending a handful of duplicate DELETE messages while the kernel's iocom write thread is blocked transmitting. The corruption is a double RB-tree unlink with stale pointers plus a phantom refcount drop, which frees the state early while further queued replies still reference it (use-after-free). Demonstrated as a kernel page fault inside kdmsg_state_tree_RB_REMOVE_COLOR on the non-INVARIANTS kernel; the identical input on the stock INVARIANTS kernel panics earlier at the rx-side assert (kern_dmsg.c:1076 = DF-0018), which is why this continuation was invisible to INVARIANTS testing.

Root cause chain (all in sys/kern/kern_dmsg.c unless noted)

  1. Absorbed duplicate rx DELETE β€” kdmsg_state_msgrx() :1075-1077 only KKASSERTs against a second DELETE (rxcmd & DMSGF_DELETE already set); on non-INVARIANTS kernels the assert is a no-op (sys/sys/systm.h:93-118) and the duplicate is absorbed, then the callback runs again.
  2. Re-reply race β€” the callback path (kdmsg_autorxmsg() :1195-1211 SPAN|DELETE β†’ kdmsg_msg_reply(); same for CONN|DELETE :1161-1173) checks state->txcmd & DMSGF_DELETE (:2056-2058) without the iocom lock; txcmd's DELETE bit is only set by the write thread in kdmsg_state_cleanuptx() :1672 after transmission. While the writer is parked (blocked in fp_write() on a full socket β€” the peer simply stops reading), every absorbed duplicate queues another terminating reply.
  3. Duplicate transmitted DELETE not guarded β€” kdmsg_state_msgtx() :1571 resets txcmd = cmd & ~DMSGF_DELETE for REPLY|CREATE|DELETE messages, so the KKASSERT((state->txcmd & DMSGF_DELETE) == 0) at :1671 passes even on INVARIANTS kernels for this route; kdmsg_state_cleanuptx() then re-runs the close: - RB_REMOVE() (:1678/:1683) on a node already removed β†’ the tree.h macro re-walks the node's stale rbnode pointers and re-links former neighbors (sys/sys/tree.h:585-660) β†’ live-tree corruption; - kdmsg_state_drop(state) "state on rbtree" (:1707) drops a ref that no longer exists β†’ refcount underflow β†’ premature kdmsg_state_free() while queued replies #3..#N still hold msg->state β†’ UAF writes (state->txcmd |= DELETE, refs decrement, further stale RB_REMOVEs) into freed (and spray-reclaimable) kdmsg_state_t allocations.

Runs

run kernel input result
1 stock INVARIANTS #0 attack 1 24 panic assertion "(state->rxcmd & DMSGF_DELETE) == 0" failed in kdmsg_state_msgrx at kern_dmsg.c:1076 (DF-0018's assert; proves harness reaches the machinery) β€” panic.invariants_baseline.txt
2 non-INVARIANTS #1 (built in guest) attack 3 24 Fatal trap 12: page fault ... Stopped at kdmsg_state_tree_RB_REMOVE_COLOR+0x6f during round 0 drain β€” panic.txt
3 non-INVARIANTS #1 (rebooted) attack 3 24 no panic; after DRAIN_DONE the iocom reader stopped consuming (trigger blocked writing churn; mount still unmounted cleanly) β€” corruption-manifestation variance / see DF-2710 for the benign-stall control β€” run.attack2_ssh.log, run.attack2_trigger.out
4 (control) non-INVARIANTS #1 wedge (single DELETE, no duplicates) reader stall with no duplicate DELETEs (863/1024 probe then EAGAIN), fully reversible after drain (1024/1024) β€” isolates DF-2710 from this finding β€” run.wedge_probe.txt
5 (fix) non-INVARIANTS + fix.diff attack 3 24 no crash, no stall, all rounds complete, clean unmount, no new kdmsg console errors β€” run.fix.log

Exploitability assessment (honest)

  • Primitive: peer-controlled count (N replies) of (a) fixed-offset UAF writes into a freed 160-byte-class kdmsg_state_t (rbnode/flags/txcmd/refs fields), plus (b) repeated stale-pointer relinking of the live staterd_tree. The freed slot is same-zone reclaimable by peer-created states (every rx CREATE allocates from iocom->mmsg), so a later duplicate's RB_REMOVE can operate on a live replacement state's rbnode β€” compounding tree corruption with two owners of one node.
  • Impact ceiling: remote kernel memory corruption β†’ code execution on clustered HAMMER2 deployments (the peer position is exactly the trust boundary LNK_AUTH was supposed to guard and is unimplemented; receive-side CRCs are not verified). A full uid=0 chain was not constructed in this run: the attacker position is a cluster peer (not an unprivileged local user β€” the local cluster_fd route needs root to mount), and shaping reclaimed kdmsg_state_t content from the wire is limited to the msgid field (offset 120); everything else in the slot is kernel-chosen. The page-fault repro + the double-free/refcount-underflow derivation above establish the corruption primitive; weaponization would need a spray object with wire-controlled bytes in the 160-byte slab class and a target adjacent to the state trees, which the dmsg protocol surface does not directly offer. No "hard blocker" beyond that β€” the primitive is real and demonstrated.
  • On INVARIANTS/debug kernels the same input is "only" a reachable assert (DF-0018) β€” production kernels are the vulnerable population.

Fix validation

fix.diff adds a guard at the only place the close sequence can legitimately run from (cleanuptx, before the RB_REMOVE): if the state is already fully closed (!(flags & KDMSG_STATE_RBINSERTED) inside rxcmd & DMSGF_DELETE), the duplicate DELETE transmit is discarded benignly (msg freed, temp ref dropped, return). The RBINSERTED flag is set at state creation (rx :904-916 / tx :1808-1817) and cleared exactly once by the single legitimate close (msgrx :1091 / cleanuptx :1686), so !RBINSERTED here ⇔ duplicate.

Rebuilt the non-INVARIANTS kernel with only this change, rebooted, re-ran the identical attack (attack 3 24): trigger completed all 3 rounds, printed TRIGGER_DONE, UMOUNT_RC=0, no kdmsg errors, no panic, no wedge (run.fix.log). Baseline (same kernel sans fix) panicked in RB_REMOVE_COLOR.

References

Fix verification

fixed
baseline reproduced→ patch + rebuild →patched clean

Baseline non-INVARIANTS kernel #1 panicked in kdmsg_state_tree_RB_REMOVE_COLOR under attack 3x24; after applying only fix.diff (guard at :1689) and rebuilding, the identical attack completed all rounds with TRIGGER_RC=0/UMOUNT_RC=0, and a heavier 6x40 stress run also completed with the guest healthy. fix_patched_reproduced=0 means the BUG did not reproduce on the fix kernel (expected/desired).

['run.fix.log', 'run.fix.2.log', 'fix.diff']
↓ fix.diffDragonFly dfbsd 6.5-DEVELOPMENT #2: Mon Aug 31 03:35:20 UTC 2026 (non-INVARIANTS + fix.diff)

Confirmed kernel references

Detail

Exploit chain

cluster peer (cluster_fd mount hook / service-daemon link / remote node) -> park writer (SO_SNDBUF small or just stop reading; junk reply-generating transactions) -> open victim LNK_SPAN (peer_type=HAMMER2 proto=1, stays open) -> N duplicate DELETEs for its msgid (each absorbed duplicate queues REPLY|CREATE|DELETE) -> peer drains -> cleanuptx#2 double RB_REMOVE (tree corruption) + phantom refdrop -> premature kdmsg_state_free -> replies #3..#N write into freed 160-byte-class slot (txcmd |= DELETE, refs--, further stale RB_REMOVEs); slot reclaimable by peer-created states (same mmsg zone) compounding corruption. Full uid=0 chain not constructed: attacker position is a cluster peer (local route requires root mount), and reclaimed kdmsg_state_t content is kernel-chosen except msgid (offset 120) - documented as the practical ceiling rather than a hard blocker.

Evidence (decisive lines)

['panic.txt (Fatal trap 12 in kdmsg_state_tree_RB_REMOVE_COLOR, non-INVARIANTS kernel #1)', 'panic.invariants_baseline.txt (stock INVARIANTS: same input -> DF-0018 assert at :1076)', 'run.attack2_ssh.log + run.attack2_trigger.out (second unfixed run: post-drain iocom reader wedge)', 'run.wedge_probe.txt (single-DELETE control: DF-2710 stall isolated from this corruption)', 'run.fix.log + run.fix.2.log (fix kernel: attack 3x24 and 6x40 all rounds complete, UMOUNT_RC=0, no panic)', 'fix.diff (RBINSERTED guard, validated)']

PoC changes

Adapted the DF-2614 cluster_fd mount harness; added SO_SNDBUF-based writer parking, junk reply-generating transactions, victim SPAN state (peer_type=HAMMER2, proto=1 so kdmsg_msg_result keeps it open), N duplicate DELETEs, drain+concurrent state-spray, tree churn, plus leak/wedge control modes. Fixed the CONN reply flags (needs REVTRANS) was unnecessary - an unanswered CONN does not affect the attack.

Verified recommended fix

In kdmsg_state_cleanuptx(), discard a transmitted DELETE for an already fully-closed state: inside 'if (state->rxcmd & DMSGF_DELETE)', if !(flags & KDMSG_STATE_RBINSERTED), free the msg, drop the cleanuptx hold, and return (see fix.diff).

Verdict

Peer-driven kernel memory corruption in kern_dmsg.c on production (non-INVARIANTS) kernels: while the iocom write thread is parked in fp_write (peer withholds reads), each absorbed duplicate DELETE re-runs the state callback and kdmsg_msg_reply()'s unlocked txcmd check queues another terminating reply; on drain, kdmsg_state_cleanuptx() re-runs the close sequence (msgtx's txcmd reset at :1571 defeats the :1671 assert), RB_REMOVEs the already-removed state (stale-pointer writes into the live staterd_tree) and drops a phantom rbtree ref, prematurely freeing the state while further queued replies still reference it (UAF). Demonstrated: Fatal trap 12 page fault in kdmsg_state_tree_RB_REMOVE_COLOR on the non-INVARIANTS kernel (panic.txt); identical input on stock INVARIANTS panics earlier at :1076 (DF-0018's assert), which masked this continuation. Fix (RBINSERTED-invariant guard at the RB_REMOVE site) validated on a rebuilt non-INVARIANTS kernel: baseline panics, patched completes 3+6 rounds of 24-40 dup-DELETEs with clean unmount and no errors.