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

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)

No comments yet.

PoC verification

Evidence pack

findings/poc/DF-5825 Β· 18 files
FileTypeDescriptionSize
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.

VERDICT.md
↓ download raw

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)

  1. sys/kern/kern_dmsg.c:807-816 β€” for a received message the lookup tree is chosen solely by the peer-controlled DMSGF_REVTRANS bit; there is no cross-validation between the DMSGF_REPLY flag of the message and the tree in which the found state lives.
  2. sys/kern/kern_dmsg.c:971-988 β€” case REPLY|CREATE (and REPLY|CREATE|DELETE) only checks that some state exists for the msgid, then overwrites state->rxcmd = cmd & ~DELETE (kern_dmsg.c:986). Delivered against a peer-initiated (staterd_tree, KDMSG_STATE_OPPOSITE) state this injects DMSGF_REPLY into rxcmd β€” a direction that state can never legally have.
  3. sys/kern/kern_dmsg.c:1075-1094 β€” DELETE post-processing selects the removal tree with if (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. Per sys/sys/tree.h:585-656, RB_REMOVE unlinks via the node's own rbe_ pointers (staterd_tree topology) but writes RB_ROOT(head) of statewr_tree when the node's parent is NULL, i.e. it was the staterd root: - statewr_tree.rbh_root is overwritten (the live LNK_CONN state is orphaned inside its own tree), and - staterd_tree.rbh_root keeps pointing at the state, which kdmsg_state_cleanuprx() β†’ kdmsg_subq_delete() β†’ kdmsg_state_free() (kern_dmsg.c:1263-1270, 1754-1764) then frees β†’ dangling root.
  4. Every later RB_FIND/RB_INSERT/RB_REMOVE on 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 == 0 although conn_state != 0 β€” only the wrong-tree RB_REMOVE produces this.
  • staterd.root still 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 terminating twice β€” 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)

  1. Establish any iocom link to the victim kernel (cluster peer, or the userland daemon side of an xdisk/hammer2 socketpair).
  2. Send msg1/msg2/msg3 β†’ wrong-tree removal, staterd_tree.rbh_root dangles at a freed 160-byte state.
  3. 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.
  4. Craft fake rbnode chains in the reclaimed chunk: RB_FIND follows attacker rbe_left/right into arbitrary addresses; RB_INSERT (kern_dmsg.c:916) and RB_REMOVE write 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 in RB_REMOVE_COLOR give further write gadgets.
  5. 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

fixed
baseline reproduced→ patch + rebuild →patched clean

fix.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
↓ fix.diffper-fix-DF-5825

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.