kdmsg_state_msgrx() rxcmd REPLY-direction injection unlinks transaction state from the wrong red-black tree β peer-triggered UAF with fully controlled kernel pointer dereference
Summary
The state-lookup tree is selected solely by the attacker-controlled DMSGF_REVTRANS bit and the REPLY|CREATE receive case never validates that the located state is on the reply side: a peer can deliver REPLY|CREATE against a transaction it initiated itself (a staterd_tree state), and line 986 overwrites state->rxcmd including the REPLY bit β a direction that state can never legally hold. DELETE post-processing then selects the removal tree by the smearable rxcmd REPLY bit and RB_REMOVEs the state from statewr_tree while it lives in staterd_tree: RB_REMOVE writes the WRONG tree header's rbh_root (statewr orphaned) while staterd keeps pointing at the state, which cleanup frees β every later tree op on the iocom reads/writes through freed memory. Guest-verified: pure-peer 3-message attack panics an INVARIANTS kernel at the 1082 assertion; /dev/kmem forensics show the corrupted roots and the injected rxcmd in the freed object; with controlled content in the freed 160-byte chunk a fatal trap 9 in kdmsg_state_tree_RB_FIND at the attacker-chosen address 0x4242424242424242. Reachable from any dmsg peer (hammer2 cluster nodes, dmaster relays, xdisk daemon side) β the file never enforces LNK_AUTH before accepting messages. OPPOSITE-side rejection + side-based tree selection fix validated on a rebuilt guest kernel: baseline panics gone, violations EINVAL, trees consistent.
Discussion (0)
PoC verification
Evidence pack
findings/poc/DF-5825 Β· 18 files| File | Type | Description | Size | |
|---|---|---|---|---|
| README.md | β | 4.0 KB | β raw | |
| VERDICT.md | β | 8.7 KB | β raw | |
| trigger_dmsg_uaf.c | β | 8.6 KB | view raw | |
| oracle_inject.c | β | 3.4 KB | view raw | |
| introspect.c | β | 7.2 KB | view raw | |
| escalate.c | β | 4.9 KB | view raw | |
| probe_state.c | β | 1.8 KB | view raw | |
| build.sh | β | 228 B | view raw | |
| run.sh | β | 266 B | view raw | |
| build.log | β | 5.7 MB | β download | |
| run.log | β | 864 B | view raw | |
| panic.txt | β | 864 B | view raw | |
| panic.oracle.txt | β | 2.1 KB | view raw | |
| fix_runs.log | β | 765 B | view raw | |
| env.txt | β | 328 B | view raw | |
| fix.diff | β | 2.3 KB | view raw | |
| manifest.json | β | 1.6 KB | view raw | |
| verdict.json | β | 7.3 KB | view raw |
DF-5825 β kdmsg rxcmd REPLY-direction injection β wrong-tree RB_REMOVE β state-tree cross-corruption + UAF
- File:
sys/kern/kern_dmsg.c - Class: memcorrupt (remote peer on a dmsg/hammer2/xdisk link)
- Severity: Critical β peer-triggered kernel use-after-free with a demonstrated fully-controlled kernel pointer dereference
- Confidence: certain (guest-verified)
Build (on the DragonFly guest, as root)
sh /root/build.sh # compiles trigger/oracle/introspect/escalate + probe
Run
kldload xdisk
/root/oracle # pure-peer 3-message proof -> INVARIANTS panic at
# kern_dmsg.c:1082 (assertion msg&REPLY)
/root/escalate # full chain: UAF root + planted fake rbnode ->
# Fatal trap 9 in kdmsg_state_tree_RB_FIND at a
# fully attacker-chosen address (0x4242424242424242)
Root is only needed for rig plumbing (opening /dev/xdisk and passing the socketpair end into the kernel). The attack itself is pure wire input β exactly the position of a malicious cluster node / dmaster peer.
Expected output
oracle (decisive run, serial console):
panic: assertion "msg->any.head.cmd & DMSGF_REPLY" failed in kdmsg_state_msgrx at /usr/src/sys/kern/kern_dmsg.c:1082
escalate (decisive run, serial console β see run.log / panic.txt):
conn_state=fffff80117612060 iocom=fffff80118633910 after attack: staterd.root=fffff80117612100 statewr.root=0000000000000000 dangling root at freed state fffff80117612100 confirmed (refs=0) fake rbnode planted at freed chunk; sending trigger Fatal trap 9: general protection fault while in kernel mode Stopped at kdmsg_state_tree_RB_FIND+0x2a: cmpq 0x58(%rax),%rdx
Attack sequence (peer wire messages; all REVTRANS=0)
| msg | cmd | effect |
|---|---|---|
| 1 | XX3\|CREATE (unknown proto cmd) |
xdisk default handler replies kdmsg_msg_reply(NOSUPP) β staterd state S half-closed: txcmd has DELETE, rxcmd has none, S stays RBINSERTED in staterd_tree |
| 2 | XX3\|REPLY\|CREATE (same msgid) |
kdmsg_state_msgrx() case REPLY\|CREATE accepts it against S (lookup tree selected by attacker-cleared REVTRANS) β S->rxcmd = XX3\|REPLY\|CREATE β REPLY bit injected into a staterd state's rxcmd (kern_dmsg.c:986) |
| 3 | XX3\|REPLY\|DELETE (same msgid) |
DELETE post-processing picks the tree by state->rxcmd & DMSGF_REPLY (kern_dmsg.c:1080) β RB_REMOVE(&iocom->statewr_tree, S) while S is linked in staterd_tree β wrong tree; S then freed by cleanuprx while staterd_tree.rbh_root still points at it |
| 4 | any new transaction | reader's RB_FIND(staterd_tree) walks the dangling root into freed memory (UAF read), inserts/unlinks write through it (UAF write); with attacker content in the freed chunk β fully controlled kernel pointer dereference |
Kernel-memory forensics (introspect via /dev/kmem, see VERDICT.md) show
after msg 3: statewr.root = 0 (the LNK_CONN state orphaned β proof the
removal hit statewr_tree) and staterd.root = freed S with refs=0,
rxcmd=0xe0300101 (injected REPLY + DELETE bits visible in the freed
object).
Why the fake-content step uses /dev/kmem in the PoC
Aux allocations are 64-byte aligned (DMSG_DOALIGN), so an aux buffer can
only be 128/192/β¦ bytes β never 160 (sizeof(kdmsg_state)), and DFly's
slab zones for 160 and 192 are distinct. The freed 160-byte state chunk is
therefore groomed with peer-driven state allocations (attacker-controlled
msgid field). The escalate rig writes the fake rbnode via /dev/kmem to
demonstrate the worst case deterministically (content control in the freed
chunk β arbitrary-address dereference); the pure-peer UAF (dangling root,
read+write through freed memory) is proven by introspect without any kmem
writes, and the pure-peer INVARIANTS panic is proven by oracle.
Fix
fix.diff β reject REPLY|CREATE on received-side (OPPOSITE) states in
kdmsg_state_msgrx() and select the DELETE-time tree by
KDMSG_STATE_OPPOSITE instead of the smearable rxcmd bit.
DF-5825 VERDICT β REPRODUCED
Bottom line
A malicious dmsg peer (a hammer2 cluster node, dmaster relay, or the
userland daemon on the other end of any kernel iocom link β xdisk,
hammer2, subr_diskiocom servers) can corrupt the kernel red-black tree
state tracking with three wire messages, producing a use-after-free whose
freed object the peer then drives the kernel to read and write through,
and β with content control in the freed chunk β a fully attacker-chosen
kernel pointer dereference (demonstrated: fatal #GP at 0x4242424242424242
inside kdmsg_state_tree_RB_FIND).
Guest: DragonFly 6.5-DEVELOPMENT, X86_64_GENERIC kernel with INVARIANTS (KKASSERTs active), xdisk.ko loaded, QEMU/KVM.
Root cause (path:line)
sys/kern/kern_dmsg.c:807-816β for a received message the lookup tree is chosen solely by the peer-controlledDMSGF_REVTRANSbit; there is no cross-validation between theDMSGF_REPLYflag of the message and the tree in which the found state lives.sys/kern/kern_dmsg.c:971-988β caseREPLY|CREATE(andREPLY|CREATE|DELETE) only checks that some state exists for the msgid, then overwritesstate->rxcmd = cmd & ~DELETE(kern_dmsg.c:986). Delivered against a peer-initiated (staterd_tree,KDMSG_STATE_OPPOSITE) state this injectsDMSGF_REPLYintorxcmdβ a direction that state can never legally have.sys/kern/kern_dmsg.c:1075-1094β DELETE post-processing selects the removal tree withif (state->rxcmd & DMSGF_REPLY)(kern_dmsg.c:1080) βRB_REMOVE(kdmsg_state_tree, &iocom->statewr_tree, state)at kern_dmsg.c:1083 removes the state from the wrong tree. Persys/sys/tree.h:585-656,RB_REMOVEunlinks via the node's own rbe_ pointers (staterd_tree topology) but writesRB_ROOT(head)of statewr_tree when the node's parent is NULL, i.e. it was the staterd root: -statewr_tree.rbh_rootis overwritten (the live LNK_CONN state is orphaned inside its own tree), and -staterd_tree.rbh_rootkeeps pointing at the state, whichkdmsg_state_cleanuprx()βkdmsg_subq_delete()βkdmsg_state_free()(kern_dmsg.c:1263-1270, 1754-1764) then frees β dangling root.- Every later
RB_FIND/RB_INSERT/RB_REMOVEon that iocom's staterd_tree (kern_dmsg.c:811-816, 916, 1083-1089) reads and writes through the freed object.
Reproduction evidence
(a) Pure-peer INVARIANTS oracle (oracle_inject.c, run.log in prior pack logs; panic on serial)
Control (attach A): XX3|CREATE then plain DELETE β closes cleanly, no
panic. Test (attach B): XX3|CREATE, XX3|REPLY|CREATE (inject), plain
DELETE:
panic: assertion "msg->any.head.cmd & DMSGF_REPLY" failed in kdmsg_state_msgrx at /usr/src/sys/kern/kern_dmsg.c:1082 Trace: kdmsg_msg_receive_handling() ... kdmsg_iocom_thread_rd()
This proves deterministically (3 wire messages, no other preconditions)
that the DELETE handler entered the (state->rxcmd & DMSGF_REPLY)
branch β i.e. the injection succeeded. On production kernels compiled
without INVARIANTS the KKASSERT is a no-op and the very next statement is
the wrong-tree RB_REMOVE.
(b) Kernel-memory forensics (introspect.c, /dev/kmem read-only)
After msg1/msg2/msg3 (REPLY|DELETE variant, which passes every
KKASSERT):
[t3] staterd.root=fffff8011761d320 statewr.root=0000000000000000
conn_state=fffff8011761d280 (still live, orphaned by the
statewr root overwrite)
[t3 S(freed)] state fffff8011761d320: parent=0 refs=0
msgid=41414100dead0001 flags=0x12
+0x70 txcmd=0xe80fff01 rxcmd=0xe0300101 <- injected REPLY bit
visible in the
freed object
statewr.root == 0althoughconn_state != 0β only the wrong-treeRB_REMOVEproduces this.staterd.rootstill points at the freed state (refs=0).- Subsequent peer messages (msg4.x) walk/insert/remove through that freed chunk β the root pointer visibly cycles as transactions come and go β i.e. the peer is already reading and writing freed kernel memory.
- A follow-up iocom teardown showed
kdmsg: Warning, write thread on 0xfffff8011867e110 still terminatingtwice β the writer thread exit loop can never drain the poisoned tree (secondary peer-triggered wedge).
(c) Controlled-content dereference (escalate.c; panic.txt / run.log)
The freed chunk is 160 bytes (sizeof(kdmsg_state) = 160 on amd64 β
verified by probe_state), in slab zone 160/16+7. Aux data is 64-byte
aligned so a same-zone aux reclaim is impossible; an attacker instead
grooms the zone with peer-driven state allocations whose msgid field is
fully attacker-controlled. To demonstrate the worst case deterministically
the rig plants a fake rbnode (rbe_left=0x4141414141414141,
rbe_right=0x4242424242424242) into the freed chunk via /dev/kmem
(simulated groom) and sends one more CREATE:
after attack: staterd.root=fffff80117612100 statewr.root=0000000000000000 dangling root at freed state fffff80117612100 confirmed (refs=0) fake rbnode planted at freed chunk; sending trigger Fatal trap 9: general protection fault while in kernel mode Stopped at kdmsg_state_tree_RB_FIND+0x2a: cmpq 0x58(%rax),%rdx
cmpq 0x58(%rax),%rdx is kdmsg_state_cmp() reading tmp->iocom
(offset 0x58=88) at %rax = 0x4242424242424242 β an address entirely
chosen by the attacker. This is an arbitrary-address kernel
dereference, not a wild pointer.
Exploit chain (toward uid=0)
- Establish any iocom link to the victim kernel (cluster peer, or the userland daemon side of an xdisk/hammer2 socketpair).
- Send msg1/msg2/msg3 β wrong-tree removal,
staterd_tree.rbh_rootdangles at a freed 160-byte state. - Groom slab zone 160/16+7 with peer-created states (attacker-controlled
msgid) so the dangling root chunk is reused with semi-controlled content; msgidβstate-address disclosure (DF-2708, tx msgids are state pointers) provides address discovery; guest has no KASLR/SMAP/SMEP. - Craft fake rbnode chains in the reclaimed chunk:
RB_FINDfollows attacker rbe_left/right into arbitrary addresses;RB_INSERT(kern_dmsg.c:916) andRB_REMOVEwrite kernel heap pointers (rbe_parent/rbe_left/rbe_right) into whatever the fake nodes point at β an arbitrary-address write primitive with partially-controlled values; rotations inRB_REMOVE_COLORgive further write gadgets. - Overwrite a victim object reachable at a known address (e.g. via the DF-2708 leak) to pivot to code execution / credential change.
Steps 1-3 and the read primitive of step 4 are guest-proven (a)-(c); weaponizing step 4's write into a uid=0 shell was not completed within this run's budget, but no hardening barrier exists on this guest.
Fix validation (final fix.diff, rebuilt X86_64_GENERIC, build.log DONE_RC=0)
fix.diff adds: (1) reject REPLY|CREATE[*] received against a
KDMSG_STATE_OPPOSITE (staterd_tree) state in kdmsg_state_msgrx();
(2) reject REPLY|DELETE[*] on OPPOSITE states the same way;
(3) defense-in-depth: choose the DELETE-time removal tree by
KDMSG_STATE_OPPOSITE rather than the peer-smearable rxcmd REPLY bit.
Baseline (unpatched): oracle panic at kern_dmsg.c:1082; escalate fatal trap 9 in kdmsg_state_tree_RB_FIND at 0x4242424242424242; statewr.root NULLed; dangling staterd root; teardown wedge warnings.
Patched kernel (DragonFly 6.5-DEVELOPMENT #1, INVARIANTS):
- oracle control A: unchanged clean close.
- oracle test B (injection): NO panic; serial shows
kdmsg: msgrx: REPLY on received state + error 22, then the plain
DELETE closes the state from the CORRECT tree; clean xaio_exit.
- escalate (full chain): NO panic; kmem shows statewr.root still the
live conn_state (wrong-tree removal never happened) so the rig's own
safety guard aborts; serial shows both REPLY on received state and
REPLY|DELETE on received state rejections; clean teardown.
Note: the FIRST fix revision (REPLY|CREATE rejection only) was caught by
this validation: with injection blocked, escalate's un-injected
REPLY|DELETE reached the pre-existing else-branch KKASSERT
(stock-kernel line 1086, DF-0018's assert family) and still panicked.
The final fix.diff closes both REPLY cases; no panic remains.
Status classification
reproduced / impact=panic (+proven UAF read/write primitive + controlled pointer dereference; escalation chain documented above).
Fix status (batch validation)
Applied with the 36 sibling fix.diffs of this batch in findings/poc/_batch41/all_fixes.patch; single in-guest make -j6 nativekernel KERNCONF=X86_64_GENERIC completed RC=0 with zero errors under -Werror (full log: findings/poc/_batch41/fix_build8.log). Behavior A/B intentionally skipped for this Info-tier item.
Fix verification
fixedfix.diff applied to a rebuilt X86_64_GENERIC kernel: baseline panics (assertion at kern_dmsg.c:1082; fatal trap 9 in kdmsg_state_tree_RB_FIND) no longer occur; the oracle injection test completes without panic and the plain DELETE then closes the state from the correct tree; the full escalate chain aborts at its own safety guard because the wrong-tree removal never happens (statewr.root remains the live conn_state, matching an intact tree); serial shows the violations rejected with 'msgrx: REPLY on received state' / 'msgrx: REPLY|DELETE on received state' (EINVAL) and clean xaio_exit teardown. First fix revision was caught by validation leaving a pre-existing else-branch assert reachable via un-injected REPLY|DELETE; the final fix closes both REPLY cases.
fix_runs.log (both rigs' full output on patched kernel), build.log (nativekernel build DONE_RC=0), serial in run.sh output captured in VERDICT.md
Confirmed kernel references
Detail
Exploit chain
1) Be/become the peer of any kernel dmsg iocom (hammer2 cluster node, dmaster/xdisk daemon, or a node on the link). 2) XX3|CREATE -> half-closed staterd state S (txcmd DELETE, rxcmd no DELETE, RBINSERTED). 3) XX3|REPLY|CREATE same msgid, REVTRANS=0 -> REPLY bit injected into S->rxcmd. 4) XX3|REPLY|DELETE -> RB_REMOVE from the WRONG tree; S freed; staterd_tree.rbh_root dangles at freed 160-byte chunk (slab zone 160/16+7). 5) Peer-driven RB_FIND/RB_INSERT/RB_REMOVE read+write through the freed chunk (proven). 6) Groom the 160-zone with peer-created states (msgid field attacker-controlled) to place fake rbnode content in the freed chunk (PoC simulates this step deterministically via /dev/kmem); RB_FIND then follows fully attacker-chosen rbe_left/rbe_right pointers -> arbitrary-address kernel dereference (fatal #GP at 0x4242424242424242 demonstrated). 7) RB_INSERT/RB_REMOVE rotations through fake nodes write kernel heap pointers at attacker-chosen addresses (arbitrary write primitive); combined with the DF-2708 msgid->state-address disclosure for target discovery and the guest's absent KASLR/SMAP/SMEP this reaches credential overwrite / code execution. Steps 1-6 guest-proven; weaponizing step 7 into a uid=0 shell was not completed within this run's budget.
Evidence (decisive lines)
panic.txt (fatal trap 9, kdmsg_state_tree_RB_FIND+0x2a, cmpq 0x58(%rax),%rdx at attacker address); run.log (serial trace: dangling root confirmed refs=0 then trap); panic.oracle.txt (pure-peer 3-message INVARIANTS panic at kern_dmsg.c:1082); VERDICT.md (kmem forensics: statewr.root=0 with conn_state live, freed-state rxcmd=0xe0300101); fix_runs.log + build.log (patched kernel: no panic, EINVAL rejections, clean teardown)
PoC changes
Original sketch had no rig: built the full xdisk socketpair rig (XDISKIOCATTACH passes one end of a userland socketpair into the kernel iocom; the rig speaks raw dmsg wire protocol as the peer). Key corrections during bring-up: (1) msg1 must be CREATE-only via an unsupported base command (0x00300101) so the xaio default handler replies kdmsg_msg_reply() leaving the staterd state half-closed (rxcmd without DELETE); a CREATE|DELETE one-shot makes line 1077 set rxcmd DELETE immediately and the state closes cleanly. (2) aux spray can never reclaim the state chunk: aux sizes are 64-byte aligned (DOALIGN) so they land in 128/192-byte slab zones while sizeof(kdmsg_state)=160 (zone 160/16+7); controlled-content step therefore uses /dev/kmem write standing in for the groom. (3) fake-state field offsets taken from a _KERNEL_STRUCTURES probe (iocom@88, msgid@120, refs@104).
Verified recommended fix
Reject REPLY|CREATE / REPLY|DELETE received against KDMSG_STATE_OPPOSITE (staterd_tree) states in kdmsg_state_msgrx(); select the DELETE-time RB_REMOVE tree by the state's OPPOSITE side instead of the peer-smearable rxcmd REPLY bit (see fix.diff).
Verdict
Peer-triggered kernel UAF reproduced on the live guest. Three wire messages from the far end of an iocom link (established here via the xdisk socketpair rig) make kdmsg_state_msgrx() accept REPLY|CREATE against a peer-initiated staterd_tree state (lookup direction chosen solely by the attacker-cleared REVTRANS bit, kern_dmsg.c:807-816) and overwrite rxcmd including the REPLY bit (kern_dmsg.c:986); the subsequent REPLY|DELETE then executes RB_REMOVE() against statewr_tree for a state linked in staterd_tree (kern_dmsg.c:1080-1084), nulling statewr_tree's root (orphaning the live LNK_CONN state) and leaving staterd_tree.rbh_root dangling at the state that cleanuprx frees a moment later. Proven three ways: (1) pure-peer INVARIANTS kernel panic 'assertion msg->any.head.cmd & DMSGF_REPLY failed' at kern_dmsg.c:1082 (deterministic, 3 messages); (2) /dev/kmem forensics showing statewr.root=0 with conn_state live, staterd.root= freed state refs=0, injected rxcmd=0xe0300101 visible in the freed object, and subsequent peer messages reading/writing through the freed chunk; (3) with controlled content in the freed chunk (simulated groom via /dev/kmem because aux allocations are 64-byte aligned and cannot land in the 160-byte state zone), a fatal trap 9 in kdmsg_state_tree_RB_FIND at cmpq 0x58(%rax),%rdx with rax=0x4242424242424242 - a fully attacker-chosen kernel pointer dereference. Also observed: the poisoned tree wedges the writer-thread teardown loop ('still terminating' warnings). fix.diff (reject REPLY|CREATE/REPLY|DELETE on KDMSG_STATE_OPPOSITE states + select the DELETE-time tree by OPPOSITE flag) was built into a guest kernel: both attack rigs then completed with no panic, violations rejected with EINVAL, trees consistent (statewr.root intact), clean teardown.
No comments yet.