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.
Recommended fix
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)
PoC verification
Evidence pack
findings/poc/DF-2707 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| 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 documentedcluster_fdhook (hammer2_vfsops.c:1350-1357, same wire position as the userland hammer2 service daemon / a remote cluster node), then:- parks the kernel write thread in
fp_write()(smallSO_SNDBUFon the kernel's socket end + junk SPAN transactions that each elicit an immediate 64-byte terminating reply); - opens a victim LNK_SPAN transaction (stays open:
msg_result); - 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()(itstxcmd & DMSGF_DELETEcheck at kern_dmsg.c:2057 races the writer's cleanuptx) queues another terminating reply; - drains β the writer transmits reply#1 (legit close:
RB_REMOVE + "rbtree" refdrop), then reply#2 β
kdmsg_state_msgtx()had resettxcmd(:1571), sokdmsg_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 β prematurekdmsg_state_free()while replies #3..#N still holdmsg->state) β use-after-free; - sprays CREATEs concurrent with the drain (reclaim of the freed slot) and churns the tree.
- parks the kernel write thread in
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 inkdmsg_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βwedgemode: 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.
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)
- Absorbed duplicate rx DELETE β
kdmsg_state_msgrx():1075-1077 onlyKKASSERTs against a second DELETE (rxcmd & DMSGF_DELETEalready 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. - Re-reply race β the callback path (
kdmsg_autorxmsg():1195-1211 SPAN|DELETE βkdmsg_msg_reply(); same for CONN|DELETE :1161-1173) checksstate->txcmd & DMSGF_DELETE(:2056-2058) without the iocom lock;txcmd's DELETE bit is only set by the write thread inkdmsg_state_cleanuptx():1672 after transmission. While the writer is parked (blocked infp_write()on a full socket β the peer simply stops reading), every absorbed duplicate queues another terminating reply. - Duplicate transmitted DELETE not guarded β
kdmsg_state_msgtx():1571 resetstxcmd = cmd & ~DMSGF_DELETEfor REPLY|CREATE|DELETE messages, so theKKASSERT((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 β prematurekdmsg_state_free()while queued replies #3..#N still holdmsg->stateβ UAF writes (state->txcmd |= DELETE, refs decrement, further stale RB_REMOVEs) into freed (and spray-reclaimable)kdmsg_state_tallocations.
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/refsfields), plus (b) repeated stale-pointer relinking of the livestaterd_tree. The freed slot is same-zone reclaimable by peer-created states (every rx CREATE allocates fromiocom->mmsg), so a later duplicate'sRB_REMOVEcan 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_fdroute needs root to mount), and shaping reclaimedkdmsg_state_tcontent from the wire is limited to themsgidfield (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
- sys/kern/kern_dmsg.c:1075-1077 (absorbed duplicate rx DELETE)
- sys/kern/kern_dmsg.c:2056-2058, 2101-2103, 2144-2146, 2184-2186 (unlocked txcmd re-reply checks)
- sys/kern/kern_dmsg.c:1195-1211, 1161-1173 (auto-reply on DELETE)
- sys/kern/kern_dmsg.c:1558-1573 (msgtx txcmd reset defeats :1671 assert)
- sys/kern/kern_dmsg.c:1670-1707 (unguarded close re-run; RB_REMOVE + refdrop)
- sys/sys/tree.h:585-660 (RB_REMOVE stale-pointer relink)
- sys/sys/systm.h:93-118 (KKASSERT INVARIANTS-only)
- hammer2_vfsops.c:1350-1357 (cluster_fd peer hook used by the PoC)
Fix verification
fixedBaseline 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']
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.
No comments yet.